#![cfg_attr(all(windows, not(test)), windows_subsystem = "windows")]
#![deny(clippy::undocumented_unsafe_blocks)]
//! wxdragon application entry point and top-level event wiring.
//!
//! Keep substantial feature workflow in focused modules. This file owns startup,
//! application-state lifetime, XRC event binding, deferred menu dispatch, and
//! the remaining modal orchestration that needs application-wide callbacks.

#[cfg(test)]
use std::cell::Cell;
use std::cell::RefCell;
#[cfg(test)]
use std::collections::HashMap;
#[cfg(test)]
use std::collections::HashSet;
use std::fs;
#[cfg(test)]
use std::io;
#[cfg(debug_assertions)]
use std::io::Write;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::Instant;
#[cfg(debug_assertions)]
use std::time::{SystemTime, UNIX_EPOCH};

mod app_config;
mod app_state;
mod audio_cleanup;
mod audio_generation;
mod card_batch_dialog;
mod card_details_model;
mod confidence_diagnostics;
mod deck_bundle;
mod deck_data;
mod deck_properties_actions;
mod deck_properties_model;
mod dialog_support;
mod duplicate_prompt_dialog;
mod exam_dialog;
mod exam_mode;
mod file_ops;
mod help_actions;
mod keyboard;
mod learning_dashboard_model;
mod localization;
mod menu_dispatch;
mod menu_model;
mod menu_ui;
mod notifications;
mod options_dialog;
mod profile_lock;
mod quiz_dialog;
mod quiz_mode;
mod ready_card_actions;
mod ready_list;
mod ready_screen;
mod recall_estimate;
mod resident_tray;
mod review_audio;
mod review_dialogs;
mod review_input;
mod review_report;
mod review_session;
mod review_ui;
mod runtime;
mod startup_state;
mod study_actions;
mod study_item;
mod sync_actions;
mod sync_identity;
mod sync_menu;
#[allow(dead_code)]
mod sync_remote_decks_model;
mod sync_runtime;
#[allow(dead_code)]
mod sync_status_model;
#[allow(dead_code)]
mod sync_workflow;
mod time_format;

#[cfg(test)]
use app_config::{AppConfig, DEFAULT_SYNC_SERVER_URL, MAX_RECENT_DECKS};
use app_config::{
    AppSettings, CONFIG_PATH, LEGACY_LAST_OPEN_DECK_PATH_FILE, LEGACY_SETTINGS_PATH,
    ReviewDbBackupPolicy, load_app_config, load_effective_app_settings,
    migrate_legacy_portable_config, persist_app_options, persist_open_deck_path,
    persist_ready_view_state,
};
#[cfg(test)]
use app_config::{
    AudioGenerationFormat, AutomaticFullDeckBackupPolicy, ExamPostAnswerAudioPolicy,
    ParsedAppSettings, QuizAudioPolicy, ReviewAudioPolicy, SoundNotificationPolicy,
    effective_app_settings_from, format_app_config, format_app_settings, language_for_locale_codes,
    load_app_settings, parse_app_config, parse_app_settings,
    parse_app_settings_without_default_language, persist_app_settings, push_recent_deck_path,
};
use app_state::{AppState, ReadyShownBulkAction};
use audio_cleanup::{plan_inoperative_audio_cleanup, remove_inoperative_audio_files};
use audio_generation::{
    audio_generation_launch_for_current_exe, audio_generation_metadata_allows_generation,
    audio_generation_metadata_is_complete, show_audio_generation_progress_dialog,
    spawn_audio_generation,
};
#[cfg(test)]
use cosimo::{
    CardSchedule, Datum, DatumKey, Review, ReviewEvent, SchedulerConfig, format_deck, parse_deck,
};
#[cfg(test)]
use cosimo::{
    DeckMetadata, HttpSyncClientError, LocalDeckSyncState, SyncDownloadError, SyncDownloadKind,
    SyncRemoteDeckStatus, SyncSharedDeckStatus,
};
#[cfg(test)]
use cosimo::{PromptGuess, RecallFeedback};
use cosimo::{
    SqliteReviewStore, format_deck_file, import_anki_apkg, write_imported_anki_audio_files,
};
use deck_bundle::{
    DeckBundleProgress, DeckBundleSummary, deck_bundle_default_file_name,
    deck_bundle_with_study_history_default_file_name, inspect_deck_bundle_restore,
    preview_obsolete_automatic_deck_bundle_backups, prune_obsolete_automatic_deck_bundle_backups,
    restore_deck_bundle_from_preview, write_deck_bundle_backup_with_progress,
    write_deck_bundle_with_progress, write_deck_bundle_with_review_database_with_progress,
};
#[cfg(test)]
use deck_bundle::{automatic_deck_bundle_backup_index_path, automatic_deck_bundle_backup_path};
#[cfg(test)]
use deck_data::{DEFAULT_DECK_PATH, enumerate_deck_cards, startup_deck_path};
use deck_data::{default_deck_path, review_db_path_for_deck};
use deck_properties_actions::{edit_deck_properties_from_ui, open_deck_properties_from_ui};
use dialog_support::{
    show_error_dialog_with_parent, show_info_dialog_with_parent, show_yes_no_dialog_with_parent,
};
use exam_dialog::{
    ExamInexactReviewOutcome, ExamQuestionOutcome, TimedExamQuestionContext,
    confirm_exam_answer_correct_dialog, show_exam_finished_dialog,
    show_exam_inexact_answer_review_dialog, show_exam_question_dialog, show_exam_setup_dialog,
    show_exam_summary_dialog, show_exam_timeout_dialog,
};
use exam_mode::{
    GradedExamAnswer, ManualExamGrading, TimedExamProgress,
    classify_exam_answer_for_manual_grading, grade_exam_answers_exact_only,
};
#[cfg(test)]
use file_ops::backup_review_database;
use file_ops::write_deck_file_durably;
#[cfg(test)]
use file_ops::{
    DeckBackupPolicy, PruneBackupsSummary, file_url_from_path, review_db_backup_index_path,
    review_db_backup_path,
};
use file_ops::{
    default_backup_dir, portable_path, preview_obsolete_deck_backups, prune_obsolete_deck_backups,
    wipe_backup_directory,
};
use help_actions::{
    open_bug_report_from_menu, open_project_wiki_from_menu, open_user_guide_from_menu,
};
#[cfg(test)]
use keyboard::{
    card_batch_fields_are_complete, is_control_enter_key, is_edit_revealed_card_key,
    is_plain_tab_key, is_reveal_response_key, is_shift_tab_key, key_to_digit,
};
use keyboard::{
    is_ready_list_delete_key, is_ready_list_toggle_reversible_key,
    is_ready_list_toggle_suspension_key, is_ready_screen_refresh_key, is_toggle_deferral_key,
    is_toggle_flag_key, is_toggle_review_audio_key,
};
#[cfg(test)]
use learning_dashboard_model::CalibrationRecallRule;
use localization::{ExamSummaryText, Localizer, QuizSummaryText};
use menu_dispatch::{DeferredMenuCommand, RecentDeckSlot, install_deferred_menu_handlers};
use menu_model::recent_deck_paths_for_menu;
#[cfg(test)]
use menu_model::{
    RecentDeckMenuLayout, RecentDeckMenuSlot, localized_menu_item_labels, localized_submenu_labels,
    localized_top_level_menu_labels, recent_deck_menu_item_label, recent_deck_menu_item_names,
    recent_deck_menu_layout, recent_deck_menu_slots, single_recent_deck_menu_item_label,
};
use menu_ui::sync_recent_deck_menu;
#[cfg(test)]
use notifications::due_notification_kind;
use options_dialog::show_options_dialog;
#[cfg(test)]
use options_dialog::{
    automatic_full_deck_backup_policy_selection_index, calibration_recall_rule_selection_index,
    deck_backup_policy_selection_index, exam_post_answer_audio_policy_selection_index,
    language_selection_index, quiz_audio_policy_selection_index,
    review_audio_policy_selection_index, review_db_backup_policy_selection_index,
    sound_notification_policy_selection_index,
};
use profile_lock::{ProfileLock, acquire_profile_lock};
use quiz_dialog::{
    QuizAnswerReviewDialog, QuizQuestionOutcome, QuizReviewOutcome, show_quiz_answer_review_dialog,
    show_quiz_question_dialog, show_quiz_setup_dialog, show_quiz_summary_dialog,
};
#[cfg(test)]
use ready_card_actions::{
    BulkReversalBackupImpact, BulkReversalPrompt, bulk_reversal_prompt, mutate_ready_card_state,
    next_unskipped_duplicate_prompt_group,
};
use ready_card_actions::{
    ReviewFlagFocusTarget, add_card_batch_from_menu, add_card_from_menu,
    apply_ready_shown_bulk_action_from_ui, edit_current_revealed_card_from_ui,
    edit_selected_card_from_ui, make_eligible_cards_reversible_from_ui,
    mutate_ready_card_maintenance_from_ui, open_selected_card_details_from_ui,
    remove_selected_card_from_ui, resolve_duplicate_prompts_from_ui,
    restore_ready_screen_focus_if_available, toggle_current_or_selected_card_flag_from_button,
    toggle_current_or_selected_card_flag_from_ui, toggle_selected_card_deferral_from_ui,
    toggle_selected_card_reversal_exclusion_from_ui, toggle_selected_card_reversal_from_ui,
    toggle_selected_card_suspension_from_ui, undo_latest_bulk_reversal_from_ui,
};
use ready_list::{
    ReadyCardFilter, ReadyCardFilterSelection, ReadyCardSort, ReadyCardSortDirection, ReadyListRow,
};
#[cfg(test)]
use ready_list::{
    ReadyListUpdate, ReadyStudyItemFilterState, ReadyStudyItemReviewStats,
    normalize_ready_selection, ready_card_filter_counts, ready_card_indices_for_filter,
    ready_card_labels, ready_card_labels_for_indices, ready_card_matches_text_filter,
    ready_filter_choice_label, ready_list_update, ready_study_item_filter_counts,
    ready_study_item_indices_for_filter, ready_study_item_labels_for_indices,
};
use ready_screen::{
    EditCardMenuAction, ReadyFilterChangeKind, install_ready_refresh_key_handlers,
    install_ready_refresh_timer, refresh_ready_screen_after_filter_change,
    refresh_ready_screen_without_notification, render, render_ready_screen_after_card_change,
    show_ready_card_context_menu, sync_cards_menu_state, sync_ready_selection_buttons,
    sync_ready_selection_controls,
};
#[cfg(test)]
use ready_screen::{
    NotificationVisibility, ReadyCardContextMenuLabels, ReadySchedulePreview, ReadyScheduleStatus,
    current_card_flag_button_label, current_or_selected_card_flag_menu_label,
    eligible_reversal_menu_enabled, main_status_bar_text, ready_card_context_menu_labels,
    ready_phase_schedule_status, ready_summary_warning_text, ready_to_start_summary_text,
    schedule_preview_at, schedule_preview_if_started_now, selected_ready_card_is_suspended,
    selected_ready_card_reversal_available, selected_ready_card_reversal_exclusion_available,
    sound_notification_policy_allows, text_control_value_matches,
};
use resident_tray::{
    ResidentTray, install_resident_tray_handlers, install_resident_tray_minimize_timer,
    quit_from_resident_tray, release_resident_tray_icon_for_shutdown, restore_from_resident_tray,
    trace_resident_tray_event,
};
use review_audio::{ReviewAudioSide, audio_file_for_datum_key, sidecar_dir_for_deck_path};
use review_dialogs::{
    confirm_create_deck_dialog, confirm_reclaim_database_space_dialog,
    confirm_scrub_review_database_dialog, confirm_wipe_backup_directory_dialog, show_about_dialog,
    show_export_deck_bundle_dialog, show_export_deck_bundle_with_study_history_dialog,
    show_import_anki_deck_dialog, show_learning_dashboard_dialog, show_open_deck_dialog,
    show_restore_deck_bundle_dialog, show_save_imported_deck_dialog,
};
use review_input::{ReviewKeyCommand, install_review_key_handlers};
use review_session::ReviewPhase;
#[cfg(test)]
use review_session::ReviewSession;
#[cfg(test)]
use review_session::{ReviewCycleStats, ReviewDeferralProgress, format_count_and_percentage};
use review_ui::ReviewUi;
use runtime::current_time_and_seed;
#[cfg(test)]
use runtime::shuffle_indices;
use startup_state::{create_empty_deck, load_startup_state};
#[cfg(test)]
use study_actions::{ReviewDeferralUiAction, review_deferral_ui_action};
use study_actions::{
    activate_rating, defer_current_review_card_from_ui, end_current_pass_from_ui,
    force_start_study_from_ui, reveal_response_without_guess, review_flagged_cards_from_ui,
    start_scheduled_study_from_ui, submit_feedback,
};
#[cfg(test)]
use study_item::study_items_for_deck;
use sync_actions::{
    download_deck_sync_from_ui as sync_download_deck_sync_from_ui,
    invalidate_all_sync_tokens_from_ui as sync_invalidate_all_sync_tokens_from_ui,
    invalidate_sync_token_from_ui as sync_invalidate_sync_token_from_ui,
    log_in_to_sync_server_from_ui as sync_log_in_to_sync_server_from_ui,
    log_out_of_sync_server_from_ui as sync_log_out_of_sync_server_from_ui,
    open_sync_account_deletion_from_ui as sync_open_sync_account_deletion_from_ui,
    open_sync_remote_decks_from_ui as sync_open_sync_remote_decks_from_ui,
    open_sync_status_from_ui as sync_open_sync_status_from_ui,
    register_sync_account_from_ui as sync_register_sync_account_from_ui,
    upload_deck_sync_from_ui as sync_upload_deck_sync_from_ui,
};
#[cfg(test)]
use sync_menu::{SyncAuthMenuAction, sync_auth_menu_action_for_config};
use sync_menu::{current_sync_auth_menu_action, sync_auth_menu_state};
#[cfg(test)]
use sync_runtime::{
    CurrentDeckSyncDownloadPlan, SyncUploadConflict, SyncUploadPreflight,
    current_deck_sync_download_plan, http_sync_runtime_settings_from_config,
    local_sync_revision_for_upload, remote_status_after_owner_authentication_failure,
    remote_status_after_owner_read_error, remote_status_from_shared_result,
    sync_owner_and_token_for_config, sync_upload_preflight_for_remote_owner,
};
#[cfg(test)]
use sync_status_model::RemoteSyncStatusOverride;
use time_format::format_relative_duration_complete;
#[cfg(test)]
use time_format::format_relative_duration_rounded;
#[cfg(test)]
use time_format::format_unix_timestamp_utc;
use wxdragon::id::ID_NONE;
#[cfg(test)]
use wxdragon::id::{ID_NO, ID_OK, ID_YES};
use wxdragon::prelude::*;

wxdragon::include_xrc!("../ui/main.xrc", CosimoUi);
wxdragon::include_xrc!("../ui/card_editor_dialog.xrc", CardEditorUi);
wxdragon::include_xrc!("../ui/card_batch_dialog.xrc", CardBatchUi);
wxdragon::include_xrc!(
    "../ui/duplicate_prompt_dialog.xrc",
    DuplicatePromptResolverUi
);
wxdragon::include_xrc!("../ui/card_details_dialog.xrc", CardDetailsUi);
wxdragon::include_xrc!("../ui/learning_dashboard_dialog.xrc", LearningDashboardUi);
wxdragon::include_xrc!("../ui/stats_dialog.xrc", CycleStatsUi);
wxdragon::include_xrc!("../ui/options_dialog.xrc", OptionsUi);
wxdragon::include_xrc!(
    "../ui/deck_properties_editor_dialog.xrc",
    DeckPropertiesEditorUi
);
wxdragon::include_xrc!("../ui/exam_grade_dialog.xrc", ExamGradeUi);
wxdragon::include_xrc!("../ui/exam_setup_dialog.xrc", ExamSetupUi);
wxdragon::include_xrc!("../ui/exam_question_dialog.xrc", ExamQuestionUi);
wxdragon::include_xrc!("../ui/exam_summary_dialog.xrc", ExamSummaryUi);
wxdragon::include_xrc!("../ui/exam_timeout_dialog.xrc", ExamTimeoutUi);
wxdragon::include_xrc!("../ui/quiz_setup_dialog.xrc", QuizSetupUi);
wxdragon::include_xrc!("../ui/quiz_question_dialog.xrc", QuizQuestionUi);
wxdragon::include_xrc!("../ui/sync_remote_decks_dialog.xrc", SyncRemoteDecksUi);

#[cfg(debug_assertions)]
const DEBUG_LOG_ENV: &str = "COSIMO_DEBUG_LOG";
#[cfg(debug_assertions)]
const DEFAULT_DEBUG_LOG_PATH: &str = "cosimo-debug.log";
const DECK_BUNDLE_PROGRESS_DIALOG_RANGE: i32 = 1000;

fn main() {
    install_debug_panic_hook();
    trace_debug_event("main: entering wx main");
    let _ = wxdragon::main(|_| {
        trace_debug_event("main: wx callback started");
        let xrc = CosimoUi::new(None, false);
        let ui = ReviewUi::from_xrc(&xrc);
        trace_debug_event("main: XRC loaded");
        let config_path = portable_path(CONFIG_PATH);
        let startup_localizer = Localizer::default();
        let profile_lock = match acquire_profile_lock(&config_path, startup_localizer) {
            Ok(lock) => {
                trace_debug_event(&format!(
                    "main: acquired profile lock at {}",
                    lock.path().display()
                ));
                lock
            }
            Err(error) => {
                trace_debug_event(&format!("main: profile lock failed: {error}"));
                ui.apply_localized_labels(startup_localizer);
                show_error_dialog(
                    &ui,
                    startup_localizer.could_not_start_cosimo_caption(),
                    &error,
                );
                trace_debug_event("main: profile lock failure acknowledged; exiting process");
                std::process::exit(1);
            }
        };
        // wxdragon continues the GUI event loop after this startup callback
        // returns, so the close handler owns the lock and releases it during
        // the normal shutdown path.
        let profile_lock = Rc::new(RefCell::new(Some(profile_lock)));
        if let Err(error) = migrate_legacy_portable_config(
            &config_path,
            portable_path(LEGACY_SETTINGS_PATH),
            portable_path(LEGACY_LAST_OPEN_DECK_PATH_FILE),
        ) {
            trace_debug_event(&format!("main: legacy config migration failed: {error}"));
            let settings = load_effective_app_settings(&config_path);
            let localizer = Localizer::new(settings.language);
            ui.apply_localized_labels(localizer);
            let Some(state) = recover_startup_state_after_error(&ui, localizer, settings, error)
            else {
                trace_debug_event("main: config migration recovery declined; exiting");
                std::process::exit(0);
            };
            trace_debug_event("main: config migration recovery loaded startup state");
            initialise_loaded_state_and_events(&ui, state, Rc::clone(&profile_lock));
            ui.frame.show(true);
            ui.frame.centre();
            trace_debug_event("main: frame shown");
            return;
        }
        let settings = load_effective_app_settings(&config_path);
        let localizer = Localizer::new(settings.language);
        ui.apply_localized_labels(localizer);
        trace_debug_event("main: settings loaded and labels applied");

        let Some(state) = load_startup_state_or_recover(&ui, localizer, settings) else {
            trace_debug_event("main: startup recovery declined; exiting");
            std::process::exit(0);
        };

        initialise_loaded_state_and_events(&ui, state, Rc::clone(&profile_lock));

        ui.frame.show(true);
        ui.frame.centre();
        trace_debug_event("main: frame shown");
    });
    trace_debug_event("main: wx main returned");
}

fn load_startup_state_or_recover(
    ui: &ReviewUi,
    localizer: Localizer,
    settings: AppSettings,
) -> Option<AppState> {
    match load_startup_state(localizer, settings) {
        Ok(state) => Some(state),
        Err(error) => {
            trace_debug_event(&format!("main: startup state failed: {error}"));
            recover_startup_state_after_error(ui, localizer, settings, error)
        }
    }
}

fn recover_startup_state_after_error(
    ui: &ReviewUi,
    localizer: Localizer,
    settings: AppSettings,
    mut error: String,
) -> Option<AppState> {
    ui.show_no_active_review(&localizer.unable_to_start_review(&error));
    ui.frame.show(true);
    ui.frame.centre();
    trace_debug_event("main: startup recovery shell shown");

    loop {
        ui.show_no_active_review(&localizer.unable_to_start_review(&error));
        if !show_yes_no_dialog_with_parent(
            &ui.frame,
            localizer.startup_recovery_caption(),
            &localizer.startup_recovery_message(&error),
        ) {
            return None;
        }

        match open_startup_recovery_deck(ui, localizer, settings) {
            Ok(Some(state)) => return Some(state),
            Ok(None) => {}
            Err(next_error) => error = next_error,
        }
    }
}

fn initialise_loaded_state_and_events(
    ui: &ReviewUi,
    state: AppState,
    profile_lock: Rc<RefCell<Option<ProfileLock>>>,
) {
    trace_debug_event("main: startup state loaded");
    let state = Rc::new(RefCell::new(state));
    let resident_tray = Rc::new(RefCell::new(None));
    let ready_refresh_timer =
        install_ready_refresh_timer(ui, Rc::clone(&state), Rc::clone(&resident_tray));
    let resident_tray_minimize_timer =
        install_resident_tray_minimize_timer(ui, Rc::clone(&state), Rc::clone(&resident_tray));
    wire_events(
        ui,
        Rc::clone(&state),
        ready_refresh_timer,
        resident_tray_minimize_timer,
        resident_tray,
        profile_lock,
    );
    render(ui, &state.borrow());
    ui.focus_ready_summary();
    trace_debug_event("main: initial render complete");
}

fn open_startup_recovery_deck(
    ui: &ReviewUi,
    localizer: Localizer,
    settings: AppSettings,
) -> Result<Option<AppState>, String> {
    let default_deck_path = default_deck_path();
    let decks_dir = prepare_file_dialog_default_dir(&default_decks_dir());
    let default_file = deck_file_name_for_dialog(&default_deck_path);
    let Some(deck_path) = show_open_deck_dialog(ui.frame, localizer, &decks_dir, &default_file)
    else {
        return Ok(None);
    };

    let deck_path_ref = Path::new(&deck_path);
    let deck_exists = deck_path_ref
        .try_exists()
        .map_err(|error| localizer.could_not_read_deck_file(deck_path_ref, &error.to_string()))?;
    let state = if deck_exists {
        AppState::load_deck_with_localizer(&deck_path, localizer, settings)
    } else if confirm_create_deck_dialog(ui.frame, localizer, &deck_path) {
        create_empty_deck(&deck_path, localizer, settings)
    } else {
        return Ok(None);
    }?;

    if let Err(error) =
        persist_open_deck_path(portable_path(CONFIG_PATH), &state.deck_path, localizer)
    {
        show_error_dialog(ui, localizer.could_not_save_startup_deck_caption(), &error);
    }

    Ok(Some(state))
}

#[cfg(debug_assertions)]
fn install_debug_panic_hook() {
    let previous_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |panic_info| {
        trace_debug_event(&format!("panic: {panic_info}"));
        previous_hook(panic_info);
    }));
}

#[cfg(not(debug_assertions))]
fn install_debug_panic_hook() {}

#[cfg(debug_assertions)]
fn trace_debug_event(event: &str) {
    let path = std::env::var(DEBUG_LOG_ENV)
        .ok()
        .filter(|path| !path.trim().is_empty())
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from(DEFAULT_DEBUG_LOG_PATH));

    let timestamp_ms = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis())
        .unwrap_or(0);
    if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(path) {
        let _ = writeln!(
            file,
            "{timestamp_ms}\tpid={}\t{}",
            std::process::id(),
            event
        );
    }
}

#[cfg(not(debug_assertions))]
fn trace_debug_event(_event: &str) {}

fn persist_ready_view_state_from_state(state: &Rc<RefCell<AppState>>) -> Result<(), String> {
    let state_ref = state.borrow();
    let filter = state_ref.ready_card_filter;
    let sort = state_ref.ready_card_sort;
    let sort_direction = state_ref.ready_card_sort_direction;
    let localizer = state_ref.localizer;
    drop(state_ref);

    persist_ready_view_state(
        portable_path(CONFIG_PATH),
        filter,
        sort,
        sort_direction,
        localizer,
    )
}

fn replace_loaded_state(state: &Rc<RefCell<AppState>>, new_state: AppState) {
    *state.borrow_mut() = new_state;
}

fn apply_ready_list_selection(state: &Rc<RefCell<AppState>>, row: ReadyListRow) -> bool {
    let selected_index = {
        let state_ref = state.borrow();
        if state_ref.ready_list_sync_in_progress.get() {
            return false;
        }
        state_ref
            .ready_visible_indices
            .borrow()
            .get(row.index_usize())
            .copied()
    };

    let Some(selected_index) = selected_index else {
        return false;
    };

    let selected_key = {
        let state_ref = state.borrow();
        state_ref
            .study_items()
            .unwrap_or_else(|_| state_ref.fallback_study_items())
            .get(selected_index)
            .map(|item| item.datum_key().clone())
    };

    if state.borrow().selected_ready_key != selected_key {
        let mut state_ref = state.borrow_mut();
        state_ref.selected_ready_index = Some(selected_index);
        state_ref.selected_ready_key = selected_key;
        true
    } else {
        false
    }
}

fn handle_deferred_menu_command(
    command: DeferredMenuCommand,
    ui: &ReviewUi,
    state: &Rc<RefCell<AppState>>,
    resident_tray: &Rc<RefCell<Option<ResidentTray>>>,
) {
    match command {
        DeferredMenuCommand::OpenDeck => open_deck_from_menu(ui, state),
        DeferredMenuCommand::Options => open_options_dialog_from_menu(ui, state),
        DeferredMenuCommand::DeckProperties => open_deck_properties_from_ui(ui, state),
        DeferredMenuCommand::EditDeckProperties => edit_deck_properties_from_ui(ui, state),
        DeferredMenuCommand::SyncStatus => sync_open_sync_status_from_ui(ui, state),
        DeferredMenuCommand::SyncRemoteDecks => open_sync_remote_decks_from_ui(ui, state),
        DeferredMenuCommand::SyncLogin => sync_log_in_to_sync_server_from_ui(ui, state),
        DeferredMenuCommand::SyncLogout => sync_log_out_of_sync_server_from_ui(ui, state),
        DeferredMenuCommand::SyncRegisterAccount => sync_register_sync_account_from_ui(ui, state),
        DeferredMenuCommand::SyncInvalidateToken => sync_invalidate_sync_token_from_ui(ui, state),
        DeferredMenuCommand::SyncInvalidateAllTokens => {
            sync_invalidate_all_sync_tokens_from_ui(ui, state)
        }
        DeferredMenuCommand::SyncDeleteAccount => {
            sync_open_sync_account_deletion_from_ui(ui, state)
        }
        DeferredMenuCommand::SyncUpload => upload_deck_sync_from_ui(ui, state),
        DeferredMenuCommand::SyncDownload => download_deck_sync_from_ui(ui, state),
        DeferredMenuCommand::LearningDashboard => open_learning_dashboard_from_ui(ui, state),
        DeferredMenuCommand::GenerateAudio => generate_audio_from_ui(ui, state),
        DeferredMenuCommand::RemoveInoperativeAudio => remove_inoperative_audio_from_ui(ui, state),
        DeferredMenuCommand::ExportDeckBundle => export_deck_bundle_from_ui(ui, state),
        DeferredMenuCommand::ExportDeckBundleWithStudyHistory => {
            export_deck_bundle_with_study_history_from_ui(ui, state)
        }
        DeferredMenuCommand::WriteDeckBundleBackup => write_deck_bundle_backup_from_ui(ui, state),
        DeferredMenuCommand::RestoreDeckBundle => restore_deck_bundle_from_ui(ui, state),
        DeferredMenuCommand::ImportAnkiDeck => import_anki_deck_from_ui(ui, state),
        DeferredMenuCommand::AddCard => add_card_from_menu(ui, state),
        DeferredMenuCommand::AddCardBatch => add_card_batch_from_menu(ui, state),
        DeferredMenuCommand::EditCard => edit_card_from_menu(ui, state),
        DeferredMenuCommand::CardDetails => open_selected_card_details_from_ui(ui, state),
        DeferredMenuCommand::UserGuide => open_user_guide_from_menu(ui, state),
        DeferredMenuCommand::ReportBug => open_bug_report_from_menu(ui, state),
        DeferredMenuCommand::ProjectWiki => open_project_wiki_from_menu(ui, state),
        DeferredMenuCommand::About => {
            let localizer = state.borrow().localizer;
            show_about_dialog(ui.frame, localizer);
        }
        DeferredMenuCommand::RemoveCard => remove_selected_card_from_ui(ui, state),
        DeferredMenuCommand::ResolveDuplicatePrompts => {
            resolve_duplicate_prompts_from_ui(ui, state)
        }
        DeferredMenuCommand::MakeEligibleCardsReversible => {
            make_eligible_cards_reversible_from_ui(ui, state)
        }
        DeferredMenuCommand::UndoBulkReversal => undo_latest_bulk_reversal_from_ui(ui, state),
        DeferredMenuCommand::FlagAllShownCards => {
            apply_ready_shown_bulk_action_from_ui(ui, state, ReadyShownBulkAction::Flag)
        }
        DeferredMenuCommand::UnflagAllShownCards => {
            apply_ready_shown_bulk_action_from_ui(ui, state, ReadyShownBulkAction::Unflag)
        }
        DeferredMenuCommand::SuspendAllShownCards => {
            apply_ready_shown_bulk_action_from_ui(ui, state, ReadyShownBulkAction::Suspend)
        }
        DeferredMenuCommand::UnsuspendAllShownCards => {
            apply_ready_shown_bulk_action_from_ui(ui, state, ReadyShownBulkAction::Unsuspend)
        }
        DeferredMenuCommand::ScrubDatabase => scrub_review_database_from_ui(ui, state),
        DeferredMenuCommand::ReclaimDatabaseSpace => reclaim_database_space_from_ui(ui, state),
        DeferredMenuCommand::PruneBackups => prune_backups_from_ui(ui, state),
        DeferredMenuCommand::WipeBackupDirectory => wipe_backup_directory_from_ui(ui, state),
        DeferredMenuCommand::TrayRestore => {
            trace_resident_tray_event("frame_menu: processing posted restore command");
            restore_from_resident_tray(ui, resident_tray);
        }
        DeferredMenuCommand::TrayQuit => {
            trace_resident_tray_event("frame_menu: processing posted quit command");
            quit_from_resident_tray(ui, resident_tray);
        }
        DeferredMenuCommand::RecentDeck(slot) => open_recent_deck_from_menu(ui, state, slot),
    }
}

fn install_deferred_menu_posters(ui: &ReviewUi) {
    macro_rules! bind_deferred_menu {
        ($menu_item:expr, $command:expr) => {{
            let frame = ui.frame;
            $menu_item.on_click(move |event| {
                event.skip(false);
                frame.post_menu_command($command.id());
            });
        }};
    }

    bind_deferred_menu!(ui.menu_open_deck, DeferredMenuCommand::OpenDeck);
    for (index, menu_item) in ui.menu_recent_deck_items.iter().copied().enumerate() {
        let frame = ui.frame;
        let slot = RecentDeckSlot::new(index).expect("recent deck menu item must have a slot");
        menu_item.on_click(move |event| {
            event.skip(false);
            frame.post_menu_command(DeferredMenuCommand::RecentDeck(slot).id());
        });
    }
    bind_deferred_menu!(ui.menu_options, DeferredMenuCommand::Options);
    bind_deferred_menu!(ui.menu_deck_properties, DeferredMenuCommand::DeckProperties);
    bind_deferred_menu!(
        ui.menu_edit_deck_properties,
        DeferredMenuCommand::EditDeckProperties
    );
    bind_deferred_menu!(ui.menu_sync_status, DeferredMenuCommand::SyncStatus);
    bind_deferred_menu!(
        ui.menu_sync_remote_decks,
        DeferredMenuCommand::SyncRemoteDecks
    );
    bind_deferred_menu!(
        ui.menu_sync_register_account,
        DeferredMenuCommand::SyncRegisterAccount
    );
    bind_deferred_menu!(
        ui.menu_sync_invalidate_token,
        DeferredMenuCommand::SyncInvalidateToken
    );
    bind_deferred_menu!(
        ui.menu_sync_invalidate_all_tokens,
        DeferredMenuCommand::SyncInvalidateAllTokens
    );
    bind_deferred_menu!(
        ui.menu_sync_delete_account,
        DeferredMenuCommand::SyncDeleteAccount
    );
    bind_deferred_menu!(ui.menu_sync_upload, DeferredMenuCommand::SyncUpload);
    bind_deferred_menu!(ui.menu_sync_download, DeferredMenuCommand::SyncDownload);
    bind_deferred_menu!(
        ui.menu_learning_dashboard,
        DeferredMenuCommand::LearningDashboard
    );
    bind_deferred_menu!(ui.menu_generate_audio, DeferredMenuCommand::GenerateAudio);
    bind_deferred_menu!(
        ui.menu_remove_inoperative_audio,
        DeferredMenuCommand::RemoveInoperativeAudio
    );
    bind_deferred_menu!(
        ui.menu_export_deck_bundle,
        DeferredMenuCommand::ExportDeckBundle
    );
    bind_deferred_menu!(
        ui.menu_export_deck_bundle_with_study_history,
        DeferredMenuCommand::ExportDeckBundleWithStudyHistory
    );
    bind_deferred_menu!(
        ui.menu_write_deck_bundle_backup,
        DeferredMenuCommand::WriteDeckBundleBackup
    );
    bind_deferred_menu!(
        ui.menu_restore_deck_bundle,
        DeferredMenuCommand::RestoreDeckBundle
    );
    bind_deferred_menu!(
        ui.menu_import_anki_deck,
        DeferredMenuCommand::ImportAnkiDeck
    );
    bind_deferred_menu!(
        ui.menu_scrub_review_database,
        DeferredMenuCommand::ScrubDatabase
    );
    bind_deferred_menu!(
        ui.menu_reclaim_database_space,
        DeferredMenuCommand::ReclaimDatabaseSpace
    );
    bind_deferred_menu!(ui.menu_prune_backups, DeferredMenuCommand::PruneBackups);
    bind_deferred_menu!(
        ui.menu_wipe_backup_directory,
        DeferredMenuCommand::WipeBackupDirectory
    );
    bind_deferred_menu!(ui.menu_add_card, DeferredMenuCommand::AddCard);
    bind_deferred_menu!(ui.menu_add_card_batch, DeferredMenuCommand::AddCardBatch);
    bind_deferred_menu!(ui.menu_edit_card, DeferredMenuCommand::EditCard);
    bind_deferred_menu!(ui.menu_remove_card, DeferredMenuCommand::RemoveCard);
    bind_deferred_menu!(ui.menu_card_details, DeferredMenuCommand::CardDetails);
    bind_deferred_menu!(
        ui.menu_resolve_duplicate_prompts,
        DeferredMenuCommand::ResolveDuplicatePrompts
    );
    bind_deferred_menu!(
        ui.menu_flag_all_shown_cards,
        DeferredMenuCommand::FlagAllShownCards
    );
    bind_deferred_menu!(
        ui.menu_unflag_all_shown_cards,
        DeferredMenuCommand::UnflagAllShownCards
    );
    bind_deferred_menu!(
        ui.menu_suspend_all_shown_cards,
        DeferredMenuCommand::SuspendAllShownCards
    );
    bind_deferred_menu!(
        ui.menu_unsuspend_all_shown_cards,
        DeferredMenuCommand::UnsuspendAllShownCards
    );
    bind_deferred_menu!(
        ui.menu_make_eligible_cards_reversible,
        DeferredMenuCommand::MakeEligibleCardsReversible
    );
    bind_deferred_menu!(
        ui.menu_undo_last_bulk_reversal,
        DeferredMenuCommand::UndoBulkReversal
    );
    bind_deferred_menu!(ui.menu_user_guide, DeferredMenuCommand::UserGuide);
    bind_deferred_menu!(ui.menu_report_bug, DeferredMenuCommand::ReportBug);
    bind_deferred_menu!(ui.menu_project_wiki, DeferredMenuCommand::ProjectWiki);
    bind_deferred_menu!(ui.menu_about, DeferredMenuCommand::About);

    let frame = ui.frame;
    ui.menu_sync_auth.on_click(move |event| {
        event.skip(false);
        frame.post_menu_command(current_sync_auth_menu_action().command().id());
    });
}

fn sync_ready_sort_after_change(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    render(ui, &state.borrow());
    sync_ready_selection_controls(ui, &state.borrow());
}

fn install_ready_sort_menu_handlers(ui: &ReviewUi, state: Rc<RefCell<AppState>>) {
    macro_rules! bind_ready_sort {
        ($menu_item:expr, $change:expr) => {{
            let ui_copy = *ui;
            let state_copy = Rc::clone(&state);
            $menu_item.on_click(move |event| {
                event.skip(false);
                $change(&mut state_copy.borrow_mut());
                sync_ready_sort_after_change(&ui_copy, &state_copy);
            });
        }};
    }

    bind_ready_sort!(ui.menu_sort_deck_order, |state: &mut AppState| {
        state.set_ready_card_sort(ReadyCardSort::DeckOrder);
    });
    bind_ready_sort!(ui.menu_sort_next_scheduled, |state: &mut AppState| {
        state.set_ready_card_sort(ReadyCardSort::NextScheduledReview);
    });
    bind_ready_sort!(ui.menu_sort_last_reviewed, |state: &mut AppState| {
        state.set_ready_card_sort(ReadyCardSort::LastReviewed);
    });
    bind_ready_sort!(ui.menu_sort_prompt_text, |state: &mut AppState| {
        state.set_ready_card_sort(ReadyCardSort::PromptText);
    });
    bind_ready_sort!(ui.menu_sort_response_text, |state: &mut AppState| {
        state.set_ready_card_sort(ReadyCardSort::ResponseText);
    });
    bind_ready_sort!(ui.menu_sort_estimated_recall, |state: &mut AppState| {
        state.set_ready_card_sort(ReadyCardSort::EstimatedRecall);
    });
    bind_ready_sort!(ui.menu_sort_ascending, |state: &mut AppState| {
        state.set_ready_card_sort_direction(ReadyCardSortDirection::Ascending);
    });
    bind_ready_sort!(ui.menu_sort_descending, |state: &mut AppState| {
        state.set_ready_card_sort_direction(ReadyCardSortDirection::Descending);
    });
}

fn wire_events(
    ui: &ReviewUi,
    state: Rc<RefCell<AppState>>,
    ready_refresh_timer: Rc<RefCell<Option<Timer<Panel>>>>,
    resident_tray_minimize_timer: Rc<RefCell<Option<Timer<Frame>>>>,
    resident_tray: Rc<RefCell<Option<ResidentTray>>>,
    profile_lock: Rc<RefCell<Option<ProfileLock>>>,
) {
    install_resident_tray_handlers(ui, Rc::clone(&state), Rc::clone(&resident_tray));

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    let resident_tray_copy = Rc::clone(&resident_tray);
    install_deferred_menu_handlers(ui.frame, move |command| {
        handle_deferred_menu_command(command, &ui_copy, &state_copy, &resident_tray_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.frame.on_menu_opened(move |event| {
        let state_ref = state_copy.borrow();
        sync_cards_menu_state(&ui_copy, &state_ref);
        sync_auth_menu_state(&ui_copy, state_ref.localizer);
        event.skip(true);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    let resident_tray_copy = Rc::clone(&resident_tray);
    let profile_lock_copy = Rc::clone(&profile_lock);
    ui.frame.on_close(move |event| {
        trace_resident_tray_event("frame_close: requested");
        if let Some(timer) = ready_refresh_timer.borrow().as_ref() {
            trace_debug_event("frame_close: stopping ready refresh timer");
            timer.stop();
        }
        state_copy.borrow().review_audio_player.borrow_mut().stop();
        if let Some(timer) = resident_tray_minimize_timer.borrow().as_ref() {
            trace_debug_event("frame_close: stopping tray minimise timer");
            timer.stop();
        }
        let state_ref = state_copy.borrow();
        let localizer = state_ref.localizer;
        drop(state_ref);
        trace_debug_event("frame_close: persisting ready view state");
        match persist_ready_view_state_from_state(&state_copy) {
            Ok(()) => {
                trace_debug_event("frame_close: dropping timers");
                ready_refresh_timer.borrow_mut().take();
                resident_tray_minimize_timer.borrow_mut().take();
                let had_resident_tray =
                    release_resident_tray_icon_for_shutdown(&resident_tray_copy);
                trace_debug_event("frame_close: releasing profile lock");
                profile_lock_copy.borrow_mut().take();
                trace_resident_tray_event("frame_close: accepted");
                if had_resident_tray {
                    trace_resident_tray_event("frame_close: tray object released; exiting process");
                    std::process::exit(0);
                }
                trace_debug_event("frame_close: skipping close event to wx");
                event.skip(true)
            }
            Err(error) => {
                trace_resident_tray_event("frame_close: rejected after persistence failure");
                if let WindowEventData::General(close_event) = event
                    && close_event.can_veto()
                {
                    close_event.veto();
                }
                show_error_dialog(&ui_copy, localizer.could_not_save_view_caption(), &error);
            }
        }
    });

    install_deferred_menu_posters(ui);

    let ui_copy = *ui;
    ui.menu_quit.on_click(move |event| {
        event.skip(false);
        trace_debug_event("menu_quit: close requested");
        ui_copy.frame.close(false);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.menu_start_scheduled.on_click(move |event| {
        event.skip(false);
        start_scheduled_study_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.menu_force_start.on_click(move |event| {
        event.skip(false);
        force_start_study_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.start_cycle_button.on_click(move |_| {
        start_scheduled_study_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.force_start_cycle_button.on_click(move |_| {
        force_start_study_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.review_flagged_cards_button.on_click(move |_| {
        review_flagged_cards_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.start_exam_button.on_click(move |_| {
        start_exam_pass_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.start_quiz_button.on_click(move |_| {
        start_quiz_pass_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    ui.start_quit_button.on_click(move |_| {
        trace_debug_event("start_quit_button: close requested");
        ui_copy.frame.close(false);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.menu_toggle_card_suspension.on_click(move |event| {
        event.skip(false);
        toggle_selected_card_suspension_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.menu_toggle_card_flag.on_click(move |event| {
        event.skip(false);
        toggle_current_or_selected_card_flag_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.menu_review_flagged_cards.on_click(move |event| {
        event.skip(false);
        review_flagged_cards_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.menu_start_exam.on_click(move |event| {
        event.skip(false);
        start_exam_pass_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.menu_start_quiz.on_click(move |event| {
        event.skip(false);
        start_quiz_pass_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.menu_toggle_card_deferral.on_click(move |event| {
        event.skip(false);
        toggle_selected_card_deferral_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.menu_toggle_card_reversal.on_click(move |event| {
        event.skip(false);
        toggle_selected_card_reversal_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.menu_toggle_card_reversal_exclusion
        .on_click(move |event| {
            event.skip(false);
            toggle_selected_card_reversal_exclusion_from_ui(&ui_copy, &state_copy);
        });

    install_ready_sort_menu_handlers(ui, Rc::clone(&state));

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.ready_add_card_button.on_click(move |event| {
        event.event.skip(false);
        add_card_from_menu(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.ready_edit_card_button.on_click(move |event| {
        event.event.skip(false);
        edit_selected_card_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.ready_remove_card_button.on_click(move |event| {
        event.event.skip(false);
        remove_selected_card_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.ready_audio_button.on_click(move |event| {
        event.event.skip(false);
        toggle_selected_ready_audio_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.ready_cards.on_selection_changed(move |event| {
        if let Some(row) = ReadyListRow::from_wx_event_selection(event.get_selection())
            && apply_ready_list_selection(&state_copy, row)
        {
            let state_ref = state_copy.borrow();
            sync_ready_selection_buttons(&ui_copy, &state_ref);
        }
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.ready_cards.on_item_double_clicked(move |event| {
        if let Some(row) = ReadyListRow::from_wx_event_selection(event.get_selection()) {
            apply_ready_list_selection(&state_copy, row);
        }
        open_selected_card_details_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.ready_cards.on_mouse_right_up(move |event| {
        let position = match &event {
            WindowEventData::MouseButton(mouse_event) => mouse_event.get_position(),
            _ => None,
        };
        show_ready_card_context_menu(&ui_copy, &state_copy, position);
        event.skip(false);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.frame.on_context_menu(move |event| {
        if ui_copy.ready_cards.has_focus() {
            show_ready_card_context_menu(&ui_copy, &state_copy, None);
            event.skip(false);
        } else {
            event.skip(true);
        }
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.ready_card_filter_choice
        .on_selection_changed(move |event| {
            let filter = ReadyCardFilter::from_selection(
                ReadyCardFilterSelection::from_wx_event_selection(event.get_selection()),
            );
            let changed = {
                let mut state_ref = state_copy.borrow_mut();
                state_ref.update_ready_card_filter_value(filter)
            };
            if changed {
                refresh_ready_screen_after_filter_change(
                    &ui_copy,
                    &state_copy,
                    ReadyFilterChangeKind::Choice,
                );
            }
        });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.ready_card_text_filter.on_text_changed(move |event| {
        let text_filter = event
            .get_string()
            .unwrap_or_else(|| ui_copy.ready_card_text_filter.get_value());
        let changed = {
            let mut state_ref = state_copy.borrow_mut();
            state_ref.update_ready_card_text_filter_value(text_filter)
        };
        if changed {
            refresh_ready_screen_after_filter_change(
                &ui_copy,
                &state_copy,
                ReadyFilterChangeKind::Text,
            );
        }
        event.skip(true);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.ready_cards.on_key_down(move |event| {
        if let WindowEventData::Keyboard(key_event) = event {
            let ready_to_start = matches!(
                state_copy.borrow().session.phase(),
                ReviewPhase::ReadyToStart
            );
            if ready_to_start && is_ready_screen_refresh_key(key_event.get_key_code()) {
                refresh_ready_screen_without_notification(&ui_copy, &state_copy);
                key_event.event.skip(false);
            } else if is_ready_list_delete_key(key_event.get_key_code()) {
                remove_selected_card_from_ui(&ui_copy, &state_copy);
                key_event.event.skip(false);
            } else if is_ready_list_toggle_suspension_key(
                key_event.get_unicode_key(),
                key_event.get_key_code(),
                key_event.control_down(),
            ) {
                toggle_selected_card_suspension_from_ui(&ui_copy, &state_copy);
                key_event.event.skip(false);
            } else if is_toggle_flag_key(
                key_event.get_unicode_key(),
                key_event.get_key_code(),
                key_event.control_down(),
            ) {
                toggle_current_or_selected_card_flag_from_ui(&ui_copy, &state_copy);
                key_event.event.skip(false);
            } else if is_toggle_deferral_key(
                key_event.get_unicode_key(),
                key_event.get_key_code(),
                key_event.control_down(),
            ) {
                toggle_selected_card_deferral_from_ui(&ui_copy, &state_copy);
                key_event.event.skip(false);
            } else if is_toggle_review_audio_key(
                key_event.get_unicode_key(),
                key_event.get_key_code(),
                key_event.control_down(),
            ) {
                toggle_selected_ready_audio_from_ui(&ui_copy, &state_copy);
                key_event.event.skip(false);
            } else if is_ready_list_toggle_reversible_key(
                key_event.get_unicode_key(),
                key_event.get_key_code(),
                key_event.control_down(),
            ) {
                toggle_selected_card_reversal_from_ui(&ui_copy, &state_copy);
                key_event.event.skip(false);
            } else {
                key_event.event.skip(true);
            }
        }
    });

    for (button, rating_button) in ui.guess_rating_buttons() {
        let ui = *ui;
        let state = Rc::clone(&state);
        button.on_click(move |_| {
            state
                .borrow_mut()
                .show_response(rating_button.prompt_guess());
            render(&ui, &state.borrow());
        });
    }

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.reveal_response_button.on_click(move |_| {
        reveal_response_without_guess(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.guess_flag_card_button.on_click(move |event| {
        event.event.skip(false);
        toggle_current_or_selected_card_flag_from_button(
            &ui_copy,
            &state_copy,
            ReviewFlagFocusTarget::GuessButton,
        );
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.guess_defer_card_button.on_click(move |_| {
        defer_current_review_card_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.review_audio_button.on_click(move |_| {
        toggle_review_audio_from_ui(&ui_copy, &state_copy);
    });

    for (button, rating_button) in ui.feedback_rating_buttons() {
        let ui = *ui;
        let state = Rc::clone(&state);
        button.on_click(move |_| {
            submit_feedback(&ui, &state, rating_button.recall_feedback());
        });
    }

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.guess_end_pass_button.on_click(move |_| {
        end_current_pass_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.feedback_end_pass_button.on_click(move |_| {
        end_current_pass_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.feedback_edit_card_button.on_click(move |event| {
        event.event.skip(false);
        edit_current_revealed_card_from_ui(&ui_copy, &state_copy);
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    ui.feedback_flag_card_button.on_click(move |event| {
        event.event.skip(false);
        toggle_current_or_selected_card_flag_from_button(
            &ui_copy,
            &state_copy,
            ReviewFlagFocusTarget::FeedbackButton,
        );
    });

    let ui_copy = *ui;
    let state_copy = Rc::clone(&state);
    install_review_key_handlers(ui, Rc::clone(&state), move |command| {
        handle_review_key_command(command, &ui_copy, &state_copy)
    });

    install_ready_refresh_key_handlers(ui, Rc::clone(&state));
}

fn handle_review_key_command(
    command: ReviewKeyCommand,
    ui: &ReviewUi,
    state: &Rc<RefCell<AppState>>,
) -> bool {
    match command {
        ReviewKeyCommand::ToggleFlag => {
            toggle_current_or_selected_card_flag_from_ui(ui, state);
            true
        }
        ReviewKeyCommand::ToggleDeferral => {
            defer_current_review_card_from_ui(ui, state);
            true
        }
        ReviewKeyCommand::ToggleReviewAudio => toggle_review_audio_from_ui(ui, state),
        ReviewKeyCommand::EditRevealedCard => {
            edit_current_revealed_card_from_ui(ui, state);
            true
        }
        ReviewKeyCommand::RevealResponse => reveal_response_without_guess(ui, state),
        ReviewKeyCommand::EndCurrentPass => end_current_pass_from_ui(ui, state),
        ReviewKeyCommand::ActivateRating(rating_button) => {
            activate_rating(ui, state, rating_button)
        }
    }
}

struct StudyModeSetupPreflight {
    active_card_count: usize,
    show_study_direction: bool,
}

fn study_mode_setup_preflight(
    ui: &ReviewUi,
    state: &Rc<RefCell<AppState>>,
    localizer: Localizer,
) -> Option<StudyModeSetupPreflight> {
    let active_card_count_result = { state.borrow().active_card_count() };
    let active_card_count = match active_card_count_result {
        Ok(count) => count,
        Err(error) => {
            show_error_dialog(ui, localizer.could_not_record_review_caption(), &error);
            render(ui, &state.borrow());
            return None;
        }
    };
    if active_card_count == 0 {
        render(ui, &state.borrow());
        show_info_dialog(
            ui,
            localizer.no_cards_reviewed_caption(),
            localizer.no_cards_available_to_start_pass(),
        );
        return None;
    }

    let has_reverse_study_items_result = { state.borrow().has_reverse_study_items() };
    let show_study_direction = match has_reverse_study_items_result {
        Ok(has_reverse_study_items) => has_reverse_study_items,
        Err(error) => {
            show_error_dialog(ui, localizer.could_not_record_review_caption(), &error);
            render(ui, &state.borrow());
            return None;
        }
    };

    Some(StudyModeSetupPreflight {
        active_card_count,
        show_study_direction,
    })
}

fn study_mode_setup_request<Request>(
    ui: &ReviewUi,
    state: &Rc<RefCell<AppState>>,
    show_setup_dialog: impl FnOnce(Frame, Localizer, usize, bool) -> Option<Request>,
) -> Option<(Localizer, Request)> {
    let localizer = state.borrow().localizer;
    let preflight = study_mode_setup_preflight(ui, state, localizer)?;
    let request = show_setup_dialog(
        ui.frame,
        localizer,
        preflight.active_card_count,
        preflight.show_study_direction,
    )?;

    Some((localizer, request))
}

enum StudyModePassStartFailure {
    RenderOnly,
    ReportStatus(String),
}

enum StudyModePassStartUiOutcome<Pass> {
    Started(Pass),
    Failed(StudyModePassStartFailure),
}

fn study_mode_pass_start_outcome<Pass>(
    pass: Option<Pass>,
    failure: impl FnOnce() -> StudyModePassStartFailure,
) -> StudyModePassStartUiOutcome<Pass> {
    match pass {
        Some(pass) => StudyModePassStartUiOutcome::Started(pass),
        None => StudyModePassStartUiOutcome::Failed(failure()),
    }
}

fn apply_study_mode_pass_start_failure(
    ui: &ReviewUi,
    state: &Rc<RefCell<AppState>>,
    localizer: Localizer,
    failure: StudyModePassStartFailure,
) {
    render(ui, &state.borrow());
    match failure {
        StudyModePassStartFailure::RenderOnly => {}
        StudyModePassStartFailure::ReportStatus(message) => {
            show_info_dialog(ui, localizer.no_cards_reviewed_caption(), &message);
        }
    }
}

fn start_exam_pass_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let Some((localizer, request)) = study_mode_setup_request(ui, state, show_exam_setup_dialog)
    else {
        return;
    };
    state.borrow().review_audio_player.borrow_mut().stop();
    let exact_only = request.exact_only();
    let time_limit_minutes = request.time_limit_minutes();
    let exam_pass = state.borrow_mut().start_exam_pass(request);
    let exam_pass =
        match study_mode_pass_start_outcome(exam_pass, || StudyModePassStartFailure::RenderOnly) {
            StudyModePassStartUiOutcome::Started(exam_pass) => exam_pass,
            StudyModePassStartUiOutcome::Failed(failure) => {
                apply_study_mode_pass_start_failure(ui, state, localizer, failure);
                return;
            }
        };
    let (review_audio_policy, exam_post_answer_audio_policy, deck_path) = {
        let state_ref = state.borrow();
        (
            state_ref.settings.review_audio_policy,
            state_ref.settings.exam_post_answer_audio_policy,
            state_ref.deck_path.clone(),
        )
    };

    let mut answers = Vec::new();
    let timed_exam_started_at = Instant::now();
    let timed_exam_progress = time_limit_minutes
        .map(TimedExamProgress::new)
        .map(|progress| Rc::new(RefCell::new(progress)));
    let mut timed_out = false;
    for (index, question) in exam_pass.questions().iter().enumerate() {
        ui.status_bar.set_status_text(
            &localizer.exam_status_bar(answers.len(), exam_pass.questions().len()),
            0,
        );
        let prompt_audio =
            audio_file_for_datum_key(&deck_path, question.datum_key(), ReviewAudioSide::Prompt);
        let timed_context = timed_exam_progress.as_ref().map(|progress| {
            TimedExamQuestionContext::new(timed_exam_started_at, Rc::clone(progress), ui.status_bar)
        });
        match show_exam_question_dialog(
            ui.frame,
            localizer,
            question,
            index + 1,
            exam_pass.questions().len(),
            review_audio_policy,
            prompt_audio,
            timed_context,
        ) {
            ExamQuestionOutcome::Submitted(answer) => answers.push(answer),
            ExamQuestionOutcome::SubmittedAndEnded(answer) => {
                answers.push(answer);
                break;
            }
            ExamQuestionOutcome::TimedOut(answer) => {
                if let Some(answer) = answer {
                    answers.push(answer);
                }
                timed_out = true;
                break;
            }
            ExamQuestionOutcome::Ended => break,
        }
    }
    if timed_out {
        show_exam_timeout_dialog(ui.frame, localizer, answers.len());
    } else if !answers.is_empty() {
        show_exam_finished_dialog(ui.frame, localizer, answers.len());
    }

    let graded_answers = if exact_only {
        let review_plan = grade_exam_answers_exact_only(answers);
        let inexact_answers = review_plan.inexact_answers();
        for (index, answer) in inexact_answers.iter().enumerate() {
            let prompt_audio = audio_file_for_datum_key(
                &deck_path,
                answer.question().datum_key(),
                ReviewAudioSide::Prompt,
            );
            let response_audio = audio_file_for_datum_key(
                &deck_path,
                answer.question().datum_key(),
                ReviewAudioSide::Response,
            );
            if show_exam_inexact_answer_review_dialog(
                ui.frame,
                localizer,
                answer,
                index + 1 < inexact_answers.len(),
                exam_post_answer_audio_policy,
                prompt_audio,
                response_audio,
            ) == ExamInexactReviewOutcome::Summary
            {
                break;
            }
        }
        review_plan.graded_answers()
    } else {
        let grading_plan: Vec<_> = answers
            .into_iter()
            .map(classify_exam_answer_for_manual_grading)
            .collect();
        let unanswered_review_count = grading_plan
            .iter()
            .filter(|grading| matches!(grading, ManualExamGrading::ReviewIncorrect(_)))
            .count();
        let mut unanswered_review_index = 0;
        let mut skip_remaining_unanswered_reviews = false;
        let mut graded_answers = Vec::with_capacity(grading_plan.len());
        for grading in grading_plan {
            match grading {
                ManualExamGrading::Exact(graded_answer) => graded_answers.push(graded_answer),
                ManualExamGrading::ReviewIncorrect(answer) => {
                    unanswered_review_index += 1;
                    if !skip_remaining_unanswered_reviews {
                        let prompt_audio = audio_file_for_datum_key(
                            &deck_path,
                            answer.question().datum_key(),
                            ReviewAudioSide::Prompt,
                        );
                        let response_audio = audio_file_for_datum_key(
                            &deck_path,
                            answer.question().datum_key(),
                            ReviewAudioSide::Response,
                        );
                        if show_exam_inexact_answer_review_dialog(
                            ui.frame,
                            localizer,
                            &answer,
                            unanswered_review_index < unanswered_review_count,
                            exam_post_answer_audio_policy,
                            prompt_audio,
                            response_audio,
                        ) == ExamInexactReviewOutcome::Summary
                        {
                            skip_remaining_unanswered_reviews = true;
                        }
                    }
                    graded_answers.push(GradedExamAnswer::new(answer, false));
                }
                ManualExamGrading::NeedsDecision(answer) => {
                    let prompt_audio = audio_file_for_datum_key(
                        &deck_path,
                        answer.question().datum_key(),
                        ReviewAudioSide::Prompt,
                    );
                    let response_audio = audio_file_for_datum_key(
                        &deck_path,
                        answer.question().datum_key(),
                        ReviewAudioSide::Response,
                    );
                    let is_correct = confirm_exam_answer_correct_dialog(
                        ui.frame,
                        localizer,
                        &answer,
                        exam_post_answer_audio_policy,
                        prompt_audio,
                        response_audio,
                    );
                    graded_answers.push(GradedExamAnswer::new(answer, is_correct));
                }
            }
        }
        graded_answers
    };

    let finished_at_unix_seconds = current_time_and_seed()
        .map(|(now_unix_seconds, _)| now_unix_seconds)
        .unwrap_or_else(|_| exam_pass.started_at_unix_seconds());

    let summary = state.borrow_mut().finish_exam_pass_with_timing(
        &exam_pass,
        &graded_answers,
        finished_at_unix_seconds,
        time_limit_minutes,
        timed_out,
    );
    render(ui, &state.borrow());
    if summary.submitted_answers() == 0 {
        show_info_dialog(
            ui,
            localizer.no_cards_reviewed_caption(),
            localizer.no_cards_reviewed_message(),
        );
    } else {
        let elapsed =
            format_relative_duration_complete(localizer, summary.elapsed_seconds() as i64);
        let time_per_question = format_relative_duration_complete(
            localizer,
            summary.seconds_per_submitted_answer() as i64,
        );
        let time_limit = summary
            .time_limit_minutes()
            .map(|minutes| format_relative_duration_complete(localizer, (minutes * 60) as i64));
        let summary_text = localizer.exam_summary_text(ExamSummaryText {
            total_questions: summary.total_questions(),
            submitted_answers: summary.submitted_answers(),
            exact_matches: summary.exact_matches(),
            accepted_non_exact: summary.accepted_non_exact(),
            correct_answers: summary.correct_answers(),
            correct_percentage: summary.correct_percentage(),
            incorrect_answers: summary.incorrect_answers(),
            elapsed: &elapsed,
            time_per_question: &time_per_question,
            ended_early: summary.ended_early(),
            time_limit: time_limit.as_deref(),
            timed_out: summary.timed_out(),
        });
        let (deck_path, review_db_path) = {
            let state_ref = state.borrow();
            (
                state_ref.deck_path.clone(),
                state_ref.review_db_path.clone(),
            )
        };
        show_exam_summary_dialog(
            ui.frame,
            localizer,
            &summary_text,
            &deck_path,
            &review_db_path,
            exam_pass.started_at_unix_seconds(),
            finished_at_unix_seconds,
        );
    }
    ui.cycle_summary.set_focus();
}

fn start_quiz_pass_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let Some((localizer, request)) = study_mode_setup_request(ui, state, show_quiz_setup_dialog)
    else {
        return;
    };
    state.borrow().review_audio_player.borrow_mut().stop();
    let quiz_pass = state.borrow_mut().start_quiz_pass(request);
    let quiz_pass = match study_mode_pass_start_outcome(quiz_pass, || {
        StudyModePassStartFailure::ReportStatus(state.borrow().last_status.clone())
    }) {
        StudyModePassStartUiOutcome::Started(quiz_pass) => quiz_pass,
        StudyModePassStartUiOutcome::Failed(failure) => {
            apply_study_mode_pass_start_failure(ui, state, localizer, failure);
            return;
        }
    };

    let mut answers = Vec::new();
    let (deck_path, quiz_audio_policy, review_audio_cues) = {
        let state_ref = state.borrow();
        (
            state_ref.deck_path.clone(),
            state_ref.settings.quiz_audio_policy,
            state_ref.settings.review_audio_cues,
        )
    };
    for (index, question) in quiz_pass.questions().iter().enumerate() {
        ui.status_bar.set_status_text(
            &localizer.quiz_status_bar(answers.len(), quiz_pass.questions().len()),
            0,
        );
        match show_quiz_question_dialog(
            ui.frame,
            localizer,
            question,
            index + 1,
            quiz_pass.questions().len(),
            &deck_path,
            quiz_audio_policy,
        ) {
            QuizQuestionOutcome::Submitted(answer) => answers.push(answer),
            QuizQuestionOutcome::SubmittedAndEnded(answer) => {
                answers.push(answer);
                break;
            }
            QuizQuestionOutcome::Ended => break,
        }
    }

    for (index, answer) in answers.iter().enumerate() {
        if show_quiz_answer_review_dialog(QuizAnswerReviewDialog {
            parent: ui.frame,
            localizer,
            answer,
            current_answer: index + 1,
            total_answers: answers.len(),
            deck_path: &deck_path,
            quiz_audio_policy,
            guiding_tones: review_audio_cues,
        }) == QuizReviewOutcome::Summary
        {
            break;
        }
    }

    let finished_at_unix_seconds = current_time_and_seed()
        .map(|(now_unix_seconds, _)| now_unix_seconds)
        .unwrap_or_else(|_| quiz_pass.started_at_unix_seconds());
    let summary =
        state
            .borrow_mut()
            .finish_quiz_pass(&quiz_pass, &answers, finished_at_unix_seconds);
    render(ui, &state.borrow());
    if summary.submitted_answers() == 0 {
        show_info_dialog(
            ui,
            localizer.no_cards_reviewed_caption(),
            localizer.no_cards_reviewed_message(),
        );
    } else {
        let elapsed =
            format_relative_duration_complete(localizer, summary.elapsed_seconds() as i64);
        let time_per_question = format_relative_duration_complete(
            localizer,
            summary.seconds_per_submitted_answer() as i64,
        );
        let summary_text = localizer.quiz_summary_text(QuizSummaryText {
            total_questions: summary.total_questions(),
            submitted_answers: summary.submitted_answers(),
            correct_answers: summary.correct_answers(),
            correct_percentage: summary.correct_percentage(),
            incorrect_answers: summary.incorrect_answers(),
            elapsed: &elapsed,
            time_per_question: &time_per_question,
            ended_early: summary.ended_early(),
        });
        let (deck_path, review_db_path) = {
            let state_ref = state.borrow();
            (
                state_ref.deck_path.clone(),
                state_ref.review_db_path.clone(),
            )
        };
        show_quiz_summary_dialog(
            ui.frame,
            localizer,
            &summary_text,
            &deck_path,
            &review_db_path,
            quiz_pass.started_at_unix_seconds(),
            finished_at_unix_seconds,
        );
    }
    ui.cycle_summary.set_focus();
}

fn toggle_review_audio_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) -> bool {
    let state_ref = state.borrow();
    state_ref
        .review_audio_player
        .borrow_mut()
        .toggle_current_audio(ui, &state_ref)
}

fn toggle_selected_ready_audio_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) -> bool {
    let state_ref = state.borrow();
    state_ref
        .review_audio_player
        .borrow_mut()
        .toggle_selected_ready_audio(ui, &state_ref)
}
fn open_learning_dashboard_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let (localizer, deck_path, review_db_path, generated_at_unix_seconds, dashboard_result) = {
        let state_ref = state.borrow();
        (
            state_ref.localizer,
            state_ref.deck_path.clone(),
            state_ref.review_db_path.clone(),
            current_time_and_seed()
                .map(|(now_unix_seconds, _)| now_unix_seconds)
                .unwrap_or(0),
            state_ref.learning_dashboard_sections(),
        )
    };

    match dashboard_result {
        Ok(sections) => show_learning_dashboard_dialog(
            ui.frame,
            localizer,
            &sections,
            &deck_path,
            &review_db_path,
            generated_at_unix_seconds,
        ),
        Err(error) => show_error_dialog(
            ui,
            localizer.could_not_show_learning_dashboard_caption(),
            &error,
        ),
    }
}

fn open_sync_remote_decks_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let mut on_load_selected_deck =
        |localizer, new_state| load_selected_deck(ui, state, localizer, new_state);
    sync_open_sync_remote_decks_from_ui(ui, state, &mut on_load_selected_deck);
}

fn upload_deck_sync_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let mut on_load_selected_deck =
        |localizer, new_state| load_selected_deck(ui, state, localizer, new_state);
    sync_upload_deck_sync_from_ui(ui, state, &mut on_load_selected_deck);
}

fn download_deck_sync_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let mut on_load_selected_deck =
        |localizer, new_state| load_selected_deck(ui, state, localizer, new_state);
    sync_download_deck_sync_from_ui(ui, state, &mut on_load_selected_deck);
}

fn open_options_dialog_from_menu(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let state_ref = state.borrow();
    let current_localizer = state_ref.localizer;
    let current_settings = state_ref.settings;
    drop(state_ref);
    let current_config = load_app_config(portable_path(CONFIG_PATH)).unwrap_or_default();
    let current_sync_server_url = current_config.sync_server_url;
    let current_sync_server_url_disabled = current_config.sync_server_url_disabled;
    let current_local_device_name = current_config.local_device_name;

    let Some(updated_options) = show_options_dialog(
        ui.frame,
        current_localizer,
        current_settings,
        current_sync_server_url.as_deref(),
        current_local_device_name.as_deref(),
    ) else {
        return;
    };
    let updated_settings = updated_options.settings;
    let updated_sync_server_url = updated_options.sync_server_url;
    let updated_sync_server_url_disabled = updated_options.sync_server_url_disabled;
    let updated_local_device_name = updated_options.local_device_name;

    if updated_settings == current_settings
        && updated_sync_server_url == current_sync_server_url
        && updated_sync_server_url_disabled == current_sync_server_url_disabled
        && updated_local_device_name == current_local_device_name
    {
        return;
    }

    let new_localizer = Localizer::new(updated_settings.language);

    match persist_app_options(
        portable_path(CONFIG_PATH),
        updated_settings,
        updated_sync_server_url.as_deref(),
        updated_sync_server_url_disabled,
        updated_local_device_name.as_deref(),
        current_localizer,
    ) {
        Ok(()) => {
            {
                let mut state_ref = state.borrow_mut();
                state_ref.settings = updated_settings;
                state_ref.localizer = new_localizer;
                state_ref.last_status.clear();
            }
            ui.apply_localized_labels(new_localizer);
            render(ui, &state.borrow());
        }
        Err(error) => {
            show_error_dialog(
                ui,
                current_localizer.could_not_save_options_caption(),
                &error,
            );
            render(ui, &state.borrow());
        }
    }
}

fn open_deck_from_menu(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let state_ref = state.borrow();
    let current_deck_path = state_ref.deck_path.clone();
    let localizer = state_ref.localizer;
    let settings = state_ref.settings;
    drop(state_ref);
    let decks_dir = prepare_file_dialog_default_dir(&default_decks_dir());
    let default_file = open_deck_dialog_default_file_name(&current_deck_path, &decks_dir);
    let Some(deck_path) = show_open_deck_dialog(ui.frame, localizer, &decks_dir, &default_file)
    else {
        return;
    };

    let deck_path_ref = Path::new(&deck_path);
    let deck_exists = match deck_path_ref.try_exists() {
        Ok(exists) => exists,
        Err(error) => {
            show_error_dialog(
                ui,
                localizer.could_not_open_deck_caption(),
                &localizer.could_not_read_deck_file(deck_path_ref, &error.to_string()),
            );
            return;
        }
    };

    let new_state = if deck_exists {
        AppState::load_deck_with_localizer(&deck_path, localizer, settings)
    } else if confirm_create_deck_dialog(ui.frame, localizer, &deck_path) {
        create_empty_deck(&deck_path, localizer, settings)
    } else {
        return;
    };

    load_selected_deck(ui, state, localizer, new_state);
}

fn open_recent_deck_from_menu(
    ui: &ReviewUi,
    state: &Rc<RefCell<AppState>>,
    recent_slot: RecentDeckSlot,
) {
    let state_ref = state.borrow();
    let localizer = state_ref.localizer;
    let settings = state_ref.settings;
    let current_deck_path = state_ref.deck_path.clone();
    drop(state_ref);

    let config = match load_app_config(portable_path(CONFIG_PATH)) {
        Ok(config) => config,
        Err(error) => {
            show_error_dialog(ui, localizer.could_not_open_deck_caption(), &error);
            return;
        }
    };
    let recent_deck_paths = recent_deck_paths_for_menu(&config, Some(&current_deck_path));
    let Some(deck_path) = recent_deck_paths.get(recent_slot.index()).cloned() else {
        sync_recent_deck_menu(ui.frame, localizer, Some(&current_deck_path));
        return;
    };

    let new_state = AppState::load_deck_with_localizer(&deck_path, localizer, settings);
    load_selected_deck(ui, state, localizer, new_state);
}

fn load_selected_deck(
    ui: &ReviewUi,
    state: &Rc<RefCell<AppState>>,
    localizer: Localizer,
    new_state: Result<AppState, String>,
) {
    match new_state {
        Ok(mut new_state) => {
            let deck_path = new_state.deck_path.clone();
            let (
                ready_card_filter,
                ready_card_text_filter,
                ready_card_sort,
                ready_card_sort_direction,
            ) = {
                let state_ref = state.borrow();
                (
                    state_ref.ready_card_filter,
                    state_ref.ready_card_text_filter.clone(),
                    state_ref.ready_card_sort,
                    state_ref.ready_card_sort_direction,
                )
            };
            new_state.set_initial_ready_view_state(
                ready_card_filter,
                ready_card_sort,
                ready_card_sort_direction,
            );
            new_state.set_ready_card_text_filter(ready_card_text_filter);
            replace_loaded_state(state, new_state);
            if let Err(error) =
                persist_open_deck_path(portable_path(CONFIG_PATH), &deck_path, localizer)
            {
                show_error_dialog(ui, localizer.could_not_save_startup_deck_caption(), &error);
            }
            sync_recent_deck_menu(ui.frame, localizer, Some(&deck_path));
            render_ready_screen_after_card_change(ui, state);
        }
        Err(error) => {
            show_error_dialog(ui, localizer.could_not_open_deck_caption(), &error);
            render(ui, &state.borrow());
        }
    }
}

fn edit_card_from_menu(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let review_phase = state.borrow().session.phase();
    match EditCardMenuAction::for_phase(review_phase) {
        EditCardMenuAction::SelectedReadyCard => edit_selected_card_from_ui(ui, state),
        EditCardMenuAction::RevealedReviewCard => edit_current_revealed_card_from_ui(ui, state),
        EditCardMenuAction::DisabledDuringPrompt => {}
    }
}

fn scrub_review_database_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let state_ref = state.borrow();
    let localizer = state_ref.localizer;
    let backups_disabled = state_ref.settings.review_db_backup_policy == ReviewDbBackupPolicy::Off;
    let overwrites_review_db_backup =
        state_ref.settings.review_db_backup_policy == ReviewDbBackupPolicy::OnSessionStart;
    drop(state_ref);
    let previous_focus = ui.current_ready_screen_focus();

    if !confirm_scrub_review_database_dialog(
        ui.frame,
        localizer,
        backups_disabled,
        overwrites_review_db_backup,
    ) {
        return;
    }

    mutate_ready_card_maintenance_from_ui(ui, state, previous_focus, |state| {
        state.scrub_review_database().map(|_| ())
    });
}

fn reclaim_database_space_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let localizer = state.borrow().localizer;
    let previous_focus = ui.current_ready_screen_focus();

    if !confirm_reclaim_database_space_dialog(
        ui.frame,
        localizer,
        state.borrow().settings.review_db_backup_policy == ReviewDbBackupPolicy::Off,
    ) {
        return;
    }

    mutate_ready_card_maintenance_from_ui(
        ui,
        state,
        previous_focus,
        AppState::reclaim_database_space,
    );
}

fn prune_backups_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let localizer = state.borrow().localizer;
    let backup_dir = default_backup_dir();
    let previous_focus = ui.current_ready_screen_focus();

    let planned = preview_obsolete_deck_backups(&backup_dir).and_then(|deck_summary| {
        preview_obsolete_automatic_deck_bundle_backups(&backup_dir).map(|automatic_summary| {
            (
                deck_summary.removed_count + automatic_summary.removed_count,
                deck_summary.kept_count + automatic_summary.kept_count,
            )
        })
    });
    let (planned_removed_count, _planned_kept_count) = match planned {
        Ok(summary) => summary,
        Err(error) => {
            show_error_dialog(
                ui,
                localizer.could_not_prune_backups_caption(),
                &error.to_string(),
            );
            restore_ready_screen_focus_if_available(ui, state, previous_focus);
            return;
        }
    };

    if !show_yes_no_dialog_with_parent(
        &ui.frame,
        localizer.prune_backups_caption(),
        &localizer.prune_backups_message(planned_removed_count),
    ) {
        restore_ready_screen_focus_if_available(ui, state, previous_focus);
        return;
    }

    match prune_obsolete_deck_backups(&backup_dir).and_then(|deck_summary| {
        prune_obsolete_automatic_deck_bundle_backups(&backup_dir).map(|automatic_summary| {
            (
                deck_summary.removed_count + automatic_summary.removed_count,
                deck_summary.kept_count + automatic_summary.kept_count,
            )
        })
    }) {
        Ok((removed_count, kept_count)) => {
            show_info_dialog(
                ui,
                localizer.prune_backups_completed_caption(),
                &localizer.prune_backups_completed_message(removed_count, kept_count),
            );
            restore_ready_screen_focus_if_available(ui, state, previous_focus);
        }
        Err(error) => {
            show_error_dialog(
                ui,
                localizer.could_not_prune_backups_caption(),
                &error.to_string(),
            );
            restore_ready_screen_focus_if_available(ui, state, previous_focus);
        }
    }
}

fn wipe_backup_directory_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let localizer = state.borrow().localizer;
    let backup_dir = default_backup_dir();
    let previous_focus = ui.current_ready_screen_focus();
    let backup_dir_text = backup_dir.display().to_string();

    if !confirm_wipe_backup_directory_dialog(ui.frame, localizer, &backup_dir_text) {
        return;
    }

    match wipe_backup_directory(&backup_dir) {
        Ok(removed_count) => {
            show_info_dialog(
                ui,
                localizer.wipe_backup_directory_caption(),
                &localizer.wiped_backup_directory_status(removed_count),
            );
            restore_ready_screen_focus_if_available(ui, state, previous_focus);
        }
        Err(error) => {
            show_error_dialog(
                ui,
                localizer.could_not_wipe_backup_directory_caption(),
                &error.to_string(),
            );
            restore_ready_screen_focus_if_available(ui, state, previous_focus);
        }
    }
}

enum DeckBundleProgressEvent<T> {
    Progress(DeckBundleProgress),
    Finished(Result<T, String>),
}

struct DeckBundleProgressRun<T> {
    receiver: std::sync::mpsc::Receiver<DeckBundleProgressEvent<T>>,
    worker: std::thread::JoinHandle<()>,
}

fn spawn_deck_bundle_export(
    deck_path: PathBuf,
    destination: PathBuf,
) -> DeckBundleProgressRun<DeckBundleSummary> {
    let (sender, receiver) = std::sync::mpsc::channel();
    let worker = std::thread::spawn(move || {
        let result = write_deck_bundle_with_progress(&deck_path, &destination, |progress| {
            let _ = sender.send(DeckBundleProgressEvent::Progress(progress));
        })
        .map_err(|error| error.to_string());
        let _ = sender.send(DeckBundleProgressEvent::Finished(result));
    });
    DeckBundleProgressRun { receiver, worker }
}

fn spawn_deck_bundle_export_with_study_history(
    deck_path: PathBuf,
    review_db_path: PathBuf,
    destination: PathBuf,
) -> DeckBundleProgressRun<DeckBundleSummary> {
    let (sender, receiver) = std::sync::mpsc::channel();
    let worker = std::thread::spawn(move || {
        let result = SqliteReviewStore::open(&review_db_path)
            .map_err(|error| {
                format!(
                    "could not open review database for bundle export {}: {error}",
                    review_db_path.display()
                )
            })
            .and_then(|store| {
                write_deck_bundle_with_review_database_with_progress(
                    &deck_path,
                    &review_db_path,
                    &store,
                    &destination,
                    |progress| {
                        let _ = sender.send(DeckBundleProgressEvent::Progress(progress));
                    },
                )
                .map_err(|error| error.to_string())
            });
        let _ = sender.send(DeckBundleProgressEvent::Finished(result));
    });
    DeckBundleProgressRun { receiver, worker }
}

fn spawn_deck_bundle_backup(
    deck_path: PathBuf,
    backup_dir: PathBuf,
    timestamp_unix_seconds: i64,
) -> DeckBundleProgressRun<DeckBundleSummary> {
    let (sender, receiver) = std::sync::mpsc::channel();
    let worker = std::thread::spawn(move || {
        let result = write_deck_bundle_backup_with_progress(
            &deck_path,
            &backup_dir,
            timestamp_unix_seconds,
            |progress| {
                let _ = sender.send(DeckBundleProgressEvent::Progress(progress));
            },
        )
        .map_err(|error| error.to_string());
        let _ = sender.send(DeckBundleProgressEvent::Finished(result));
    });
    DeckBundleProgressRun { receiver, worker }
}

fn show_deck_bundle_progress_dialog<W, T>(
    parent: &W,
    localizer: Localizer,
    run: DeckBundleProgressRun<T>,
) -> Result<T, String>
where
    W: WxWidget,
    T: Send + 'static,
{
    let dialog = Dialog::builder(parent, localizer.deck_bundle_progress_caption())
        .with_style(DialogStyle::Caption)
        .build();
    dialog.set_escape_id(ID_NONE);

    let status = StaticText::builder(&dialog)
        .with_label(localizer.deck_bundle_progress_creating())
        .build();
    let gauge = Gauge::builder(&dialog)
        .with_size(Size::new(420, -1))
        .with_style(GaugeStyle::Smooth | GaugeStyle::ShowProgress)
        .with_range(DECK_BUNDLE_PROGRESS_DIALOG_RANGE)
        .build();
    gauge.set_name(localizer.deck_bundle_progress_caption());

    let sizer = BoxSizer::builder(Orientation::Vertical).build();
    sizer.add(&status, 0, SizerFlag::Expand | SizerFlag::All, 12);
    sizer.add(
        &gauge,
        0,
        SizerFlag::Expand | SizerFlag::Left | SizerFlag::Right | SizerFlag::Bottom,
        12,
    );
    dialog.set_sizer_and_fit(sizer, true);
    dialog.centre();

    let (result_sender, result_receiver) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let mut final_result = Err(localizer.deck_bundle_progress_unknown_failure());
        while let Ok(event) = run.receiver.recv() {
            match event {
                DeckBundleProgressEvent::Progress(progress) => {
                    let value = deck_bundle_progress_value(progress);
                    wxdragon::call_after(Box::new(move || {
                        gauge.set_value(value);
                    }));
                }
                DeckBundleProgressEvent::Finished(result) => {
                    final_result = result;
                    break;
                }
            }
        }
        wxdragon::call_after(Box::new(move || {
            gauge.set_value(DECK_BUNDLE_PROGRESS_DIALOG_RANGE);
        }));
        let _ = run.worker.join();
        let _ = result_sender.send(final_result);
        wxdragon::call_after(Box::new(move || {
            dialog.end_modal(ID_OK);
        }));
    });

    let _ = dialog.show_modal();
    dialog.destroy();
    result_receiver
        .try_recv()
        .unwrap_or_else(|_| Err(localizer.deck_bundle_progress_unknown_failure()))
}

fn deck_bundle_progress_value(progress: DeckBundleProgress) -> i32 {
    if progress.total == 0 {
        return 0;
    }
    let completed = progress.completed.min(progress.total);
    ((completed * DECK_BUNDLE_PROGRESS_DIALOG_RANGE as usize) / progress.total) as i32
}

fn export_deck_bundle_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let (localizer, deck_path) = {
        let state_ref = state.borrow();
        (state_ref.localizer, state_ref.deck_path.clone())
    };
    let now = current_time_and_seed()
        .map(|(time, _)| time)
        .unwrap_or_default();
    let default_file = deck_bundle_default_file_name(&deck_path, now);
    let backup_dir = prepare_file_dialog_default_dir(&default_backup_dir());
    let Some(destination) =
        show_export_deck_bundle_dialog(ui.frame, localizer, &backup_dir, &default_file)
    else {
        return;
    };
    let destination = deck_bundle_export_path(&destination);

    match show_deck_bundle_progress_dialog(
        &ui.frame,
        localizer,
        spawn_deck_bundle_export(deck_path, destination.clone()),
    ) {
        Ok(summary) => show_info_dialog(
            ui,
            localizer.exported_deck_bundle_caption(),
            &localizer.exported_deck_bundle_message(
                &summary.path.display().to_string(),
                summary.file_count,
            ),
        ),
        Err(error) => show_error_dialog(
            ui,
            localizer.could_not_export_deck_bundle_caption(),
            &localizer.could_not_export_deck_bundle_message(
                &destination.display().to_string(),
                &error.to_string(),
            ),
        ),
    }
}

fn deck_bundle_export_path(selected_path: &str) -> std::path::PathBuf {
    let mut path = Path::new(selected_path).to_path_buf();
    if path.extension().is_none() {
        path.set_extension("zip");
    }
    path
}

fn import_anki_deck_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let (localizer, settings) = {
        let state_ref = state.borrow();
        (state_ref.localizer, state_ref.settings)
    };
    let decks_dir = prepare_file_dialog_default_dir(&default_decks_dir());
    let Some(source_path) = show_import_anki_deck_dialog(ui.frame, localizer, &decks_dir) else {
        return;
    };
    let source_path = Path::new(&source_path).to_path_buf();

    let import = match import_anki_apkg(&source_path) {
        Ok(import) => import,
        Err(error) => {
            show_error_dialog(
                ui,
                localizer.could_not_import_deck_caption(),
                &localizer.could_not_import_anki_deck_message(
                    &source_path.display().to_string(),
                    &error.to_string(),
                ),
            );
            return;
        }
    };

    let default_file = imported_deck_default_file_name(&source_path);
    let Some(destination_path) =
        show_save_imported_deck_dialog(ui.frame, localizer, &decks_dir, &default_file)
    else {
        return;
    };
    let destination_path = imported_deck_destination_path(&destination_path);
    let review_db_path = review_db_path_for_deck(&destination_path);
    match review_db_path.try_exists() {
        Ok(true) => {
            show_error_dialog(
                ui,
                localizer.could_not_import_deck_caption(),
                &localizer.imported_deck_review_db_conflict_message(
                    &destination_path.display().to_string(),
                    &review_db_path.display().to_string(),
                ),
            );
            return;
        }
        Ok(false) => {}
        Err(error) => {
            show_error_dialog(
                ui,
                localizer.could_not_import_deck_caption(),
                &localizer.could_not_write_imported_deck_message(
                    &destination_path.display().to_string(),
                    &error.to_string(),
                ),
            );
            return;
        }
    }

    let deck_text = match format_deck_file(&import.deck) {
        Ok(deck_text) => deck_text,
        Err(error) => {
            show_error_dialog(
                ui,
                localizer.could_not_import_deck_caption(),
                &localizer.could_not_write_imported_deck_message(
                    &destination_path.display().to_string(),
                    &error.to_string(),
                ),
            );
            return;
        }
    };

    let audio_files = import.audio_files;
    let summary = import.summary;
    if !show_yes_no_dialog_with_parent(
        &ui.frame,
        localizer.import_anki_deck_confirm_caption(),
        &localizer.import_anki_deck_confirmation_message(
            &source_path.display().to_string(),
            &destination_path.display().to_string(),
            summary.imported_cards,
            summary.skipped_unsupported_notes,
            summary.skipped_empty_notes,
            summary.skipped_malformed_notes,
            summary.removed_sound_references,
            summary.imported_audio_files,
            summary.skipped_audio_references,
        ),
    ) {
        return;
    }

    if let Err(error) = write_deck_file_durably(
        &destination_path,
        default_backup_dir(),
        settings.deck_backup_policy,
        &deck_text,
    ) {
        show_error_dialog(
            ui,
            localizer.could_not_import_deck_caption(),
            &localizer.could_not_write_imported_deck_message(
                &destination_path.display().to_string(),
                &error.to_string(),
            ),
        );
        return;
    }

    if !audio_files.is_empty() {
        let Some(sidecar_dir) = sidecar_dir_for_deck_path(&destination_path) else {
            show_error_dialog(
                ui,
                localizer.could_not_import_deck_caption(),
                &localizer.could_not_write_imported_deck_message(
                    &destination_path.display().to_string(),
                    "could not derive imported deck audio directory",
                ),
            );
            return;
        };
        if let Err(error) =
            write_imported_anki_audio_files(&source_path, &sidecar_dir, &audio_files)
        {
            show_error_dialog(
                ui,
                localizer.could_not_import_deck_caption(),
                &localizer.could_not_write_imported_deck_message(
                    &destination_path.display().to_string(),
                    &error.to_string(),
                ),
            );
            return;
        }
    }

    match AppState::load_deck_with_localizer(&destination_path, localizer, settings) {
        Ok(new_state) => {
            load_selected_deck(ui, state, localizer, Ok(new_state));
            show_info_dialog(
                ui,
                localizer.imported_deck_caption(),
                &localizer.imported_deck_message(
                    &source_path.display().to_string(),
                    &destination_path.display().to_string(),
                    summary.imported_cards,
                    summary.imported_audio_files,
                ),
            );
        }
        Err(error) => show_error_dialog(ui, localizer.could_not_open_deck_caption(), &error),
    }
}

fn imported_deck_default_file_name(source_path: &Path) -> String {
    let stem = source_path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .filter(|stem| {
            let stem = stem.trim();
            !stem.is_empty() && !stem.starts_with('.')
        })
        .unwrap_or("imported");
    format!("{stem}.cosimo-deck")
}

fn imported_deck_destination_path(selected_path: &str) -> std::path::PathBuf {
    let mut path = Path::new(selected_path).to_path_buf();
    if path.extension().is_none() {
        path.set_extension("cosimo-deck");
    }
    path
}

fn export_deck_bundle_with_study_history_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let (localizer, deck_path, review_db_path) = {
        let state_ref = state.borrow();
        (
            state_ref.localizer,
            state_ref.deck_path.clone(),
            state_ref.review_db_path.clone(),
        )
    };
    let now = current_time_and_seed()
        .map(|(time, _)| time)
        .unwrap_or_default();
    let default_file = deck_bundle_with_study_history_default_file_name(&deck_path, now);
    let backup_dir = prepare_file_dialog_default_dir(&default_backup_dir());
    let Some(destination) = show_export_deck_bundle_with_study_history_dialog(
        ui.frame,
        localizer,
        &backup_dir,
        &default_file,
    ) else {
        return;
    };
    let destination = deck_bundle_export_path(&destination);

    match show_deck_bundle_progress_dialog(
        &ui.frame,
        localizer,
        spawn_deck_bundle_export_with_study_history(deck_path, review_db_path, destination.clone()),
    ) {
        Ok(summary) => show_info_dialog(
            ui,
            localizer.exported_deck_bundle_caption(),
            &localizer.exported_deck_bundle_with_study_history_message(
                &summary.path.display().to_string(),
                summary.file_count,
            ),
        ),
        Err(error) => show_error_dialog(
            ui,
            localizer.could_not_export_deck_bundle_caption(),
            &localizer.could_not_export_deck_bundle_message(
                &destination.display().to_string(),
                &error.to_string(),
            ),
        ),
    }
}

fn write_deck_bundle_backup_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let (localizer, deck_path) = {
        let state_ref = state.borrow();
        (state_ref.localizer, state_ref.deck_path.clone())
    };
    let now = current_time_and_seed()
        .map(|(time, _)| time)
        .unwrap_or_default();

    match show_deck_bundle_progress_dialog(
        &ui.frame,
        localizer,
        spawn_deck_bundle_backup(deck_path, default_backup_dir(), now),
    ) {
        Ok(summary) => show_info_dialog(
            ui,
            localizer.deck_bundle_backup_written_caption(),
            &localizer.deck_bundle_backup_written_message(
                &summary.path.display().to_string(),
                summary.file_count,
            ),
        ),
        Err(error) => show_error_dialog(
            ui,
            localizer.could_not_write_deck_bundle_backup_caption(),
            &localizer.could_not_write_deck_bundle_backup_message(&error.to_string()),
        ),
    }
}

fn restore_deck_bundle_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let (localizer, settings, current_deck_path) = {
        let state_ref = state.borrow();
        (
            state_ref.localizer,
            state_ref.settings,
            state_ref.deck_path.clone(),
        )
    };
    let decks_dir = default_decks_dir();
    let backup_dir = prepare_file_dialog_default_dir(&default_backup_dir());
    let Some(bundle_path) = show_restore_deck_bundle_dialog(ui.frame, localizer, &backup_dir)
    else {
        return;
    };
    let bundle_path = Path::new(&bundle_path).to_path_buf();
    let preview = match inspect_deck_bundle_restore(&bundle_path, &decks_dir) {
        Ok(preview) => preview,
        Err(error) => {
            show_error_dialog(
                ui,
                localizer.could_not_restore_deck_bundle_caption(),
                &localizer.could_not_restore_deck_bundle_message(
                    &bundle_path.display().to_string(),
                    &error.to_string(),
                ),
            );
            return;
        }
    };

    let study_history = if preview.has_review_database {
        localizer.deck_bundle_review_database_included()
    } else {
        localizer.deck_bundle_review_database_not_included()
    };
    let overwrite_notice = if preview.overwrites_existing_files() {
        localizer.restore_deck_bundle_overwrite_notice()
    } else {
        localizer.restore_deck_bundle_no_overwrite_notice()
    };
    let confirm_message = localizer.restore_deck_bundle_confirmation_message(
        &bundle_path.display().to_string(),
        &preview.deck_file_name,
        preview.card_count,
        preview.audio_file_count,
        preview.file_count,
        study_history,
        overwrite_notice,
    );
    if !show_yes_no_dialog_with_parent(
        &ui.frame,
        localizer.restore_deck_bundle_confirm_caption(),
        &confirm_message,
    ) {
        return;
    }

    let released_current_store = match release_current_review_store_before_restore(
        state,
        &current_deck_path,
        &preview.target_deck_path,
        preview.has_review_database,
    ) {
        Ok(released) => released,
        Err(error) => {
            show_error_dialog(
                ui,
                localizer.could_not_restore_deck_bundle_caption(),
                &localizer.could_not_restore_deck_bundle_message(
                    &bundle_path.display().to_string(),
                    &error,
                ),
            );
            return;
        }
    };

    let now = current_time_and_seed()
        .map(|(time, _)| time)
        .unwrap_or_default();
    state.borrow_mut().suspend_ready_due_notifications();
    let restore_result =
        restore_deck_bundle_from_preview(&bundle_path, &decks_dir, &backup_dir, now, &preview);
    state.borrow_mut().resume_ready_due_notifications();

    match restore_result {
        Ok(summary) => {
            let restored_deck_path = summary.preview.target_deck_path.clone();
            match AppState::load_deck_with_localizer(&restored_deck_path, localizer, settings) {
                Ok(new_state) => {
                    load_selected_deck(ui, state, localizer, Ok(new_state));
                    let study_history = if summary.preview.has_review_database {
                        localizer.deck_bundle_review_database_included()
                    } else {
                        localizer.deck_bundle_review_database_not_included()
                    };
                    let backup_notice = summary
                        .backup_path
                        .as_ref()
                        .map(|path| {
                            localizer
                                .restored_deck_bundle_backup_notice(&path.display().to_string())
                        })
                        .unwrap_or_else(|| {
                            localizer
                                .restored_deck_bundle_no_backup_notice()
                                .to_string()
                        });
                    show_info_dialog(
                        ui,
                        localizer.restored_deck_bundle_caption(),
                        &localizer.restored_deck_bundle_message(
                            &restored_deck_path.display().to_string(),
                            summary.preview.card_count,
                            summary.preview.audio_file_count,
                            study_history,
                            &backup_notice,
                        ),
                    );
                }
                Err(error) => {
                    show_error_dialog(ui, localizer.could_not_open_deck_caption(), &error)
                }
            }
        }
        Err(error) => {
            if released_current_store {
                let reloaded =
                    AppState::load_deck_with_localizer(&current_deck_path, localizer, settings);
                load_selected_deck(ui, state, localizer, reloaded);
            }
            show_error_dialog(
                ui,
                localizer.could_not_restore_deck_bundle_caption(),
                &localizer.could_not_restore_deck_bundle_message(
                    &bundle_path.display().to_string(),
                    &error.to_string(),
                ),
            );
        }
    }
}

fn default_decks_dir() -> PathBuf {
    default_deck_path()
        .parent()
        .unwrap_or_else(|| Path::new("."))
        .to_path_buf()
}

fn prepare_file_dialog_default_dir(path: &Path) -> PathBuf {
    if fs::create_dir_all(path).is_ok() || path.is_dir() {
        path.to_path_buf()
    } else {
        PathBuf::new()
    }
}

fn open_deck_dialog_default_file_name(current_deck_path: &Path, decks_dir: &Path) -> String {
    if current_deck_path.parent() == Some(decks_dir) {
        deck_file_name_for_dialog(current_deck_path)
    } else {
        String::new()
    }
}

fn deck_file_name_for_dialog(path: &Path) -> String {
    path.file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("")
        .to_string()
}

fn release_current_review_store_before_restore(
    state: &Rc<RefCell<AppState>>,
    current_deck_path: &Path,
    target_deck_path: &Path,
    restoring_review_database: bool,
) -> Result<bool, String> {
    if !restoring_review_database || !same_existing_path(current_deck_path, target_deck_path) {
        return Ok(false);
    }

    let memory_store = SqliteReviewStore::open_in_memory().map_err(|error| error.to_string())?;
    state.borrow_mut().store = memory_store;
    Ok(true)
}

fn same_existing_path(left: &Path, right: &Path) -> bool {
    if left == right {
        return true;
    }
    match (fs::canonicalize(left), fs::canonicalize(right)) {
        (Ok(left), Ok(right)) => left == right,
        _ => false,
    }
}

fn generate_audio_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let (
        localizer,
        deck_path,
        has_audio_metadata,
        audio_generation_allowed,
        audio_generation_format,
    ) = {
        let state_ref = state.borrow();
        (
            state_ref.localizer,
            state_ref.deck_path.clone(),
            audio_generation_metadata_is_complete(&state_ref.deck_metadata),
            audio_generation_metadata_allows_generation(&state_ref.deck_metadata),
            state_ref.settings.audio_generation_format,
        )
    };
    if !audio_generation_allowed {
        show_error_dialog(
            ui,
            localizer.could_not_generate_audio_caption(),
            localizer.audio_generation_disabled_message(),
        );
        return;
    }
    if !has_audio_metadata {
        show_error_dialog(
            ui,
            localizer.could_not_generate_audio_caption(),
            localizer.audio_generation_missing_metadata_message(),
        );
        return;
    }
    let generated_at = current_time_and_seed()
        .map(|(time, _)| time)
        .unwrap_or_default();

    let launch = match audio_generation_launch_for_current_exe(
        &deck_path,
        generated_at,
        audio_generation_format,
    ) {
        Ok(launch) => launch,
        Err(error) => {
            show_error_dialog(
                ui,
                localizer.could_not_generate_audio_caption(),
                &localizer.could_not_generate_audio_message(&error.to_string()),
            );
            return;
        }
    };

    let run = match spawn_audio_generation(&launch) {
        Ok(run) => run,
        Err(error) => {
            show_error_dialog(
                ui,
                localizer.could_not_generate_audio_caption(),
                &localizer.could_not_generate_audio_message(&error.to_string()),
            );
            return;
        }
    };

    match show_audio_generation_progress_dialog(&ui.frame, localizer, run) {
        Ok(summary) => show_info_dialog(
            ui,
            localizer.audio_generation_finished_caption(),
            &localizer.audio_generation_finished_message(
                summary.generated_cards,
                summary.generated_new_files,
                summary.regenerated_changed_files,
                summary.regenerated_untracked_files,
                summary.overwritten_files,
                summary.preserved_existing_files,
                summary.skipped_unchanged,
                summary.skipped_missing_id,
                summary.skipped_empty_text,
                &launch.log_path.display().to_string(),
            ),
        ),
        Err(error) => show_error_dialog(
            ui,
            localizer.could_not_generate_audio_caption(),
            &localizer.could_not_generate_audio_with_report_message(
                &error,
                &launch.log_path.display().to_string(),
            ),
        ),
    };
}

fn remove_inoperative_audio_from_ui(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    let (localizer, deck_path, data) = {
        let state_ref = state.borrow();
        (
            state_ref.localizer,
            state_ref.deck_path.clone(),
            state_ref.data.clone(),
        )
    };
    let previous_focus = ui.current_ready_screen_focus();
    let plan = match plan_inoperative_audio_cleanup(&deck_path, &data) {
        Ok(plan) => plan,
        Err(error) => {
            show_error_dialog(
                ui,
                localizer.could_not_remove_inoperative_audio_caption(),
                &error.to_string(),
            );
            restore_ready_screen_focus_if_available(ui, state, previous_focus);
            return;
        }
    };

    if plan.is_empty() {
        show_info_dialog(
            ui,
            localizer.remove_inoperative_audio_caption(),
            localizer.no_inoperative_audio_files_status(),
        );
        restore_ready_screen_focus_if_available(ui, state, previous_focus);
        return;
    }

    if !show_yes_no_dialog_with_parent(
        &ui.frame,
        localizer.remove_inoperative_audio_caption(),
        &localizer.remove_inoperative_audio_message(
            &plan.sidecar_dir.display().to_string(),
            plan.removed_card_count(),
            plan.shadowed_format_count(),
        ),
    ) {
        restore_ready_screen_focus_if_available(ui, state, previous_focus);
        return;
    }

    state.borrow().review_audio_player.borrow_mut().stop();
    match remove_inoperative_audio_files(&plan) {
        Ok(summary) => {
            show_info_dialog(
                ui,
                localizer.remove_inoperative_audio_caption(),
                &localizer.removed_inoperative_audio_status(
                    summary.removed_files,
                    summary.removed_card_files,
                    summary.shadowed_format_files,
                    summary.manifest_entries_removed,
                ),
            );
        }
        Err(error) => {
            show_error_dialog(
                ui,
                localizer.could_not_remove_inoperative_audio_caption(),
                &error.to_string(),
            );
        }
    }
    restore_ready_screen_focus_if_available(ui, state, previous_focus);
}

fn show_error_dialog(ui: &ReviewUi, caption: &str, message: &str) {
    show_error_dialog_with_parent(&ui.frame, caption, message);
}

fn show_info_dialog(ui: &ReviewUi, caption: &str, message: &str) {
    show_info_dialog_with_parent(&ui.frame, caption, message);
}

#[cfg(test)]
mod tests {
    use super::*;
    use rusqlite::Connection;
    use std::env;
    use std::fs;
    use std::path::PathBuf;
    use std::process;
    use std::time::{SystemTime, UNIX_EPOCH};

    const TEST_DAY_SECONDS: i64 = 24 * 60 * 60;

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

    fn datum_with_id(card_id: u64, prompt: &str, response: &str) -> Datum {
        datum(prompt, response).with_card_id(card_id)
    }

    fn english() -> Localizer {
        Localizer::default()
    }

    #[test]
    fn open_deck_dialog_prefills_current_deck_only_inside_default_decks_dir() {
        let decks_dir = Path::new("/portable/cosimo/decks");

        assert_eq!(
            open_deck_dialog_default_file_name(
                Path::new("/portable/cosimo/decks/Indonesian.cosimo-deck"),
                decks_dir,
            ),
            "Indonesian.cosimo-deck"
        );
        assert_eq!(
            open_deck_dialog_default_file_name(
                Path::new("/home/user/Other.cosimo-deck"),
                decks_dir
            ),
            ""
        );
    }

    #[test]
    fn deck_file_name_for_dialog_handles_paths_without_file_names() {
        assert_eq!(
            deck_file_name_for_dialog(Path::new("/portable/cosimo/decks/deck.cosimo-deck")),
            "deck.cosimo-deck"
        );
        assert_eq!(deck_file_name_for_dialog(Path::new("/")), "");
    }

    #[test]
    fn file_dialog_default_directory_is_created_when_missing() {
        let temp_dir = unique_temp_dir("file-dialog-default-dir");
        let default_dir = temp_dir.join("decks");

        assert_eq!(prepare_file_dialog_default_dir(&default_dir), default_dir);
        assert!(default_dir.is_dir());
    }

    #[test]
    fn file_dialog_default_directory_falls_back_when_path_is_not_a_directory() {
        let temp_dir = unique_temp_dir("file-dialog-default-dir-file");
        let default_dir = temp_dir.join("backups");
        fs::write(&default_dir, "not a directory").unwrap();

        assert_eq!(
            prepare_file_dialog_default_dir(&default_dir),
            PathBuf::new()
        );
    }

    fn prompt_guess_counts(entries: &[(PromptGuess, usize)]) -> [usize; PromptGuess::COUNT] {
        let mut counts = [0; PromptGuess::COUNT];
        for (guess, count) in entries {
            counts[guess.index()] = *count;
        }
        counts
    }

    fn recall_feedback_counts(
        entries: &[(RecallFeedback, usize)],
    ) -> [usize; RecallFeedback::COUNT] {
        let mut counts = [0; RecallFeedback::COUNT];
        for (feedback, count) in entries {
            counts[feedback.index()] = *count;
        }
        counts
    }

    fn study_item_filter_state<'a>(
        schedules: &'a HashMap<DatumKey, CardSchedule>,
        suspended: &'a HashSet<DatumKey>,
        flagged: &'a HashSet<DatumKey>,
        deferred: &'a HashSet<DatumKey>,
        non_reversible: &'a HashSet<DatumKey>,
        now_unix_seconds: i64,
    ) -> ReadyStudyItemFilterState<'a> {
        ReadyStudyItemFilterState {
            schedules,
            suspended,
            flagged,
            deferred,
            non_reversible,
            scheduled_study_due: None,
            review_stats: None,
            now_unix_seconds,
        }
    }

    fn study_item_filter_state_with_review_stats<'a>(
        schedules: &'a HashMap<DatumKey, CardSchedule>,
        suspended: &'a HashSet<DatumKey>,
        flagged: &'a HashSet<DatumKey>,
        deferred: &'a HashSet<DatumKey>,
        non_reversible: &'a HashSet<DatumKey>,
        review_stats: &'a HashMap<DatumKey, ReadyStudyItemReviewStats>,
        now_unix_seconds: i64,
    ) -> ReadyStudyItemFilterState<'a> {
        ReadyStudyItemFilterState {
            schedules,
            suspended,
            flagged,
            deferred,
            non_reversible,
            scheduled_study_due: None,
            review_stats: Some(review_stats),
            now_unix_seconds,
        }
    }

    fn stored_review_schedule(
        datum_key: DatumKey,
        difficulty: f64,
        lapses: u32,
        now_unix_seconds: i64,
    ) -> CardSchedule {
        CardSchedule::from_storage(
            datum_key,
            cosimo::CardMemoryState::Review,
            now_unix_seconds + 24 * 60 * 60,
            10.0,
            difficulty,
            0,
            10,
            10 * 24 * 60 * 60,
            10,
            lapses,
            Some(now_unix_seconds),
            Some(RecallFeedback::Easy),
        )
    }

    fn unique_temp_dir(prefix: &str) -> PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = env::temp_dir().join(format!("cosimo-{prefix}-{}-{unique}", process::id()));
        fs::create_dir_all(&path).unwrap();
        path
    }

    fn make_state(data: Vec<Datum>) -> (AppState, PathBuf) {
        let temp_dir = unique_temp_dir("main-tests");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        fs::write(&deck_path, format_deck(&data).unwrap()).unwrap();
        let selected_ready_key = data.first().map(DatumKey::for_datum);
        let selected_ready_index = if data.is_empty() { None } else { Some(0) };

        let ready_visible_indices = (0..data.len()).collect();
        let state = AppState {
            localizer: english(),
            settings: AppSettings::default(),
            deck_path: deck_path.clone(),
            review_db_path: db_path.clone(),
            backup_dir: temp_dir.join("backups"),
            deck_metadata: DeckMetadata::empty(),
            data,
            session: ReviewSession::Ready,
            selected_ready_key,
            selected_ready_index,
            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: Vec::new(),
            pending_warning_dialog: None,
            store: SqliteReviewStore::open_in_memory().unwrap(),
            last_status: String::new(),
            review_audio_player: RefCell::new(Default::default()),
        };

        (state, temp_dir)
    }

    fn record_review_at(
        state: &AppState,
        datum: &Datum,
        reviewed_at_unix_seconds: i64,
        prompt_guess: PromptGuess,
        recall_feedback: RecallFeedback,
    ) {
        state
            .store
            .record_review_at(
                datum,
                Review::new(prompt_guess, recall_feedback),
                reviewed_at_unix_seconds,
            )
            .unwrap();
    }

    #[test]
    fn learning_dashboard_uses_configured_calibration_recall_rule() {
        let card = datum_with_id(1, "Prompt", "Response");
        let (mut state, _temp_dir) = make_state(vec![card.clone()]);
        state.settings.calibration_recall_rule = CalibrationRecallRule::Strict;
        record_review_at(
            &state,
            &card,
            1_000,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Hard,
        );

        let dashboard = state.learning_dashboard_text().unwrap();

        assert!(dashboard.contains("Recall rule: Strict: Good and Easy count as recalled"));
        assert!(dashboard.contains("Observed recall rate: 0.0%"));
    }

    #[test]
    fn loading_deck_records_physical_cards_as_first_seen() {
        let temp_dir = unique_temp_dir("first-seen-load");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        fs::write(
            &deck_path,
            "First\n---\nAnswer\n=1=\n\nSecond\n---\nAnswer\n=2=\n",
        )
        .unwrap();

        let state = AppState::load(&deck_path, &db_path).unwrap();
        let deck_keys: HashSet<_> = state.data.iter().map(DatumKey::for_datum).collect();
        let first_seen = state
            .store
            .card_first_seen_for_datum_keys(&deck_keys)
            .unwrap();

        assert_eq!(first_seen.len(), 2);
        assert!(first_seen.keys().all(|key| !key.is_reverse()));
    }

    fn selected_ready_key(state: &AppState) -> DatumKey {
        state
            .selected_ready_study_item()
            .unwrap()
            .expect("selected ready item")
            .datum_key()
            .clone()
    }

    fn assert_selected_ready_key(state: &AppState, expected: DatumKey) {
        assert_eq!(selected_ready_key(state), expected);
    }

    #[test]
    fn app_state_load_starts_ready_to_start() {
        let temp_dir = unique_temp_dir("load");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        fs::write(
            &deck_path,
            "Capital of Spain\n---\nMadrid\n===\nOpposite of opaque\n---\nTransparent\n===\n",
        )
        .unwrap();

        let state = AppState::load(&deck_path, &db_path).unwrap();

        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(state.data.len(), 2);
        assert_eq!(state.session.current_index(), 0);
        assert_eq!(state.selected_ready_index, Some(0));
    }

    #[test]
    fn start_cycle_moves_into_prompt_phase() {
        let (mut state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);

        state.start_cycle();

        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
        assert_eq!(state.last_status, english().started_pass(1));
    }

    #[test]
    fn review_pass_suppresses_due_notification_baseline() {
        let (mut state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);
        state.last_ready_due_notification_count = Some(0);

        state.start_cycle();

        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
        assert_eq!(state.last_ready_due_notification_count, None);
        assert_eq!(
            due_notification_kind(state.last_ready_due_notification_count, 1),
            None
        );

        state.restart_cycle();

        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(state.last_ready_due_notification_count, None);
        assert_eq!(
            due_notification_kind(state.last_ready_due_notification_count, 1),
            None
        );
    }

    #[test]
    fn start_cycle_on_empty_deck_stays_ready_to_start() {
        let (mut state, _temp_dir) = make_state(vec![]);

        state.start_cycle();

        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(
            state.last_status,
            english().no_cards_available_to_start_pass()
        );
    }

    #[test]
    fn start_cycle_with_no_due_cards_and_zero_minimum_stays_ready() {
        let reviewed_datum = datum("Capital of Spain", "Madrid");
        let now = 2_000_000;
        let (mut state, _temp_dir) = make_state(vec![reviewed_datum.clone()]);
        record_review_at(
            &state,
            &reviewed_datum,
            now,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );

        state.start_cycle_with_config(SchedulerConfig::new(0, now, 7));

        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert!(state.session.queue().is_empty());
        assert_eq!(
            state.last_status,
            english().no_cards_scheduled_to_start_pass(0)
        );
    }

    #[test]
    fn force_start_cycle_starts_even_when_no_cards_are_due() {
        let reviewed_datum = datum("Capital of Spain", "Madrid");
        let now = 2_000_000;
        let (mut state, _temp_dir) = make_state(vec![reviewed_datum.clone()]);
        record_review_at(
            &state,
            &reviewed_datum,
            now,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );

        state.force_start_cycle_with_seed(19, 1_000_000);

        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
        assert_eq!(state.session.queue(), vec![0]);
        assert_eq!(state.last_status, english().force_started_pass(1));
    }

    #[test]
    fn force_start_cycle_includes_the_whole_deck() {
        let (mut state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
            datum("Capital of Italy", "Rome"),
        ]);

        state.force_start_cycle_with_seed(23, 1_000_000);

        let mut sorted_queue = state.session.queue().to_vec();
        sorted_queue.sort_unstable();
        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
        assert_eq!(sorted_queue, vec![0, 1, 2]);
    }

    #[test]
    fn review_flagged_cards_includes_only_available_flagged_cards() {
        let now = 1_800_000_000;
        let flagged_not_due = datum_with_id(1, "one", "uno");
        let flagged_suspended = datum_with_id(2, "two", "dos");
        let flagged_deferred = datum_with_id(3, "three", "tres");
        let unflagged = datum_with_id(4, "four", "cuatro");
        let (mut state, _temp_dir) = make_state(vec![
            flagged_not_due.clone(),
            flagged_suspended.clone(),
            flagged_deferred.clone(),
            unflagged.clone(),
        ]);
        record_review_at(
            &state,
            &flagged_not_due,
            now,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );
        for card in [&flagged_not_due, &flagged_suspended, &flagged_deferred] {
            state
                .store
                .set_datum_flagged(&DatumKey::for_datum(card), true)
                .unwrap();
        }
        state
            .store
            .set_datum_suspended(&DatumKey::for_datum(&flagged_suspended), true)
            .unwrap();
        state
            .store
            .defer_datum_until(&DatumKey::for_datum(&flagged_deferred), now + 60)
            .unwrap();

        state.review_flagged_cards_with_seed(23, now);

        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
        assert_eq!(state.session.queue(), vec![0]);
        assert_eq!(state.last_status, english().started_flagged_pass(1));
        assert_eq!(state.available_flagged_card_count_at(now).unwrap(), 1);
        assert!(
            state
                .store
                .flagged_datum_keys()
                .unwrap()
                .contains(&DatumKey::for_datum(&flagged_not_due))
        );
    }

    #[test]
    fn review_flagged_cards_randomizes_all_available_flagged_cards() {
        let now = 1_800_000_000;
        let cards = vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
            datum_with_id(3, "three", "tres"),
            datum_with_id(4, "four", "cuatro"),
        ];
        let (mut state, _temp_dir) = make_state(cards.clone());
        for card in &cards {
            state
                .store
                .set_datum_flagged(&DatumKey::for_datum(card), true)
                .unwrap();
        }
        let mut expected_queue = vec![0, 1, 2, 3];
        shuffle_indices(&mut expected_queue, 23);

        state.review_flagged_cards_with_seed(23, now);

        assert_eq!(state.session.queue(), expected_queue);
        let summary = state.end_current_pass().unwrap();
        assert!(!summary.was_scheduled);
    }

    #[test]
    fn review_flagged_cards_reports_when_none_are_available() {
        let now = 1_800_000_000;
        let card = datum_with_id(1, "one", "uno");
        let (mut state, _temp_dir) = make_state(vec![card]);

        state.review_flagged_cards_with_seed(23, now);

        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(
            state.last_status,
            english().no_flagged_cards_available_to_review()
        );
    }

    #[test]
    fn start_exam_pass_samples_active_cards_and_marks_review_active() {
        let due = datum_with_id(1, "Capital of Spain", "Madrid");
        let not_due = datum_with_id(2, "Capital of France", "Paris");
        let suspended = datum_with_id(3, "Capital of Italy", "Rome");
        let deferred = datum_with_id(4, "Capital of Portugal", "Lisbon");
        let now = 1_800_000_000;
        let (mut state, _temp_dir) = make_state(vec![
            due.clone(),
            not_due.clone(),
            suspended.clone(),
            deferred.clone(),
        ]);
        record_review_at(
            &state,
            &not_due,
            now,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );
        state
            .store
            .set_datum_suspended(&DatumKey::for_datum(&suspended), true)
            .unwrap();
        state
            .store
            .defer_datum_until(&DatumKey::for_datum(&deferred), now + 60)
            .unwrap();
        state.last_ready_due_notification_count = Some(0);
        let request = crate::exam_mode::ExamSizeRequest::new(
            crate::exam_mode::ExamSizeMode::QuestionCount,
            10,
        );
        let mut expected_queue = vec![0, 1];
        shuffle_indices(&mut expected_queue, 17);

        let exam = state
            .start_exam_pass_with_request(request, 17, now)
            .expect("exam pass");

        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
        assert_eq!(state.session.queue(), expected_queue);
        assert_eq!(state.last_ready_due_notification_count, None);
        assert_eq!(exam.questions().len(), 2);
        assert_eq!(state.last_status, english().started_exam_pass(2));
        assert!(exam.questions().iter().any(|question| {
            question.datum_key() == &DatumKey::for_datum(&due)
                && question.prompt() == "Capital of Spain"
        }));
        assert!(exam.questions().iter().any(|question| {
            question.datum_key() == &DatumKey::for_datum(&not_due)
                && question.prompt() == "Capital of France"
        }));
    }

    #[test]
    fn start_exam_pass_can_be_limited_to_standard_or_reverse_direction() {
        let cards = vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
            datum_with_id(3, "three", "tres"),
        ];
        let now = 1_800_000_000;
        let (mut state, _temp_dir) = make_state(cards);
        state
            .store
            .set_datum_reversible(&DatumKey::for_card_id(1), true)
            .unwrap();
        state
            .store
            .set_datum_reversible(&DatumKey::for_card_id(2), true)
            .unwrap();

        let standard_request = crate::exam_mode::ExamSizeRequest::new_with_direction_and_options(
            crate::exam_mode::ExamSizeMode::Percentage,
            100,
            crate::exam_mode::ExamStudyDirection::StandardOnly,
            false,
            None,
        );
        let standard_exam = state
            .start_exam_pass_with_request(standard_request, 17, now)
            .expect("standard exam");
        assert_eq!(standard_exam.questions().len(), 3);
        assert!(
            standard_exam
                .questions()
                .iter()
                .all(|question| !question.datum_key().is_reverse())
        );

        let reverse_request = crate::exam_mode::ExamSizeRequest::new_with_direction_and_options(
            crate::exam_mode::ExamSizeMode::Percentage,
            100,
            crate::exam_mode::ExamStudyDirection::ReverseOnly,
            false,
            None,
        );
        let reverse_exam = state
            .start_exam_pass_with_request(reverse_request, 17, now)
            .expect("reverse exam");
        assert_eq!(reverse_exam.questions().len(), 2);
        assert!(
            reverse_exam
                .questions()
                .iter()
                .all(|question| question.datum_key().is_reverse())
        );
    }

    #[test]
    fn exam_pass_finishes_without_recording_review_history_or_schedule_changes() {
        let first = datum_with_id(1, "Capital of Spain", "Madrid");
        let second = datum_with_id(2, "Capital of France", "Paris");
        let now = 1_800_000_000;
        let (mut state, _temp_dir) = make_state(vec![first.clone(), second.clone()]);
        let request = crate::exam_mode::ExamSizeRequest::new(
            crate::exam_mode::ExamSizeMode::QuestionCount,
            2,
        );

        let exam = state
            .start_exam_pass_with_request(request, 17, now)
            .expect("exam pass");

        let answers = vec![
            GradedExamAnswer::new(
                crate::exam_mode::ExamAnswer::new(
                    exam.questions()[0].clone(),
                    exam.questions()[0].expected_response(),
                ),
                true,
            ),
            GradedExamAnswer::new(
                crate::exam_mode::ExamAnswer::new(exam.questions()[1].clone(), "Lyon"),
                false,
            ),
        ];

        let summary = state.finish_exam_pass(&exam, &answers, now + 30);

        assert_eq!(summary.total_questions(), 2);
        assert_eq!(summary.submitted_answers(), 2);
        assert_eq!(summary.exact_matches(), 1);
        assert_eq!(summary.incorrect_answers(), 1);
        assert_eq!(summary.elapsed_seconds(), 30);
        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(state.last_status, english().completed_exam_pass(2));
        assert!(
            state
                .store
                .reviews_for_datum_key(exam.questions()[0].datum_key())
                .unwrap()
                .is_empty()
        );
        assert!(
            state
                .store
                .reviews_for_datum_key(exam.questions()[1].datum_key())
                .unwrap()
                .is_empty()
        );
        assert!(
            state
                .store
                .schedule_for_datum_key(exam.questions()[0].datum_key())
                .unwrap()
                .is_none()
        );
        assert!(
            state
                .store
                .schedule_for_datum_key(exam.questions()[1].datum_key())
                .unwrap()
                .is_none()
        );
    }

    #[test]
    fn exam_pass_with_no_submitted_answers_returns_ready_without_records() {
        let card = datum_with_id(1, "Capital of Spain", "Madrid");
        let now = 1_800_000_000;
        let (mut state, _temp_dir) = make_state(vec![card]);
        let request = crate::exam_mode::ExamSizeRequest::new(
            crate::exam_mode::ExamSizeMode::QuestionCount,
            1,
        );
        let exam = state
            .start_exam_pass_with_request(request, 17, now)
            .expect("exam pass");

        let summary = state.finish_exam_pass(&exam, &[], now + 5);

        assert_eq!(summary.submitted_answers(), 0);
        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(state.last_status, english().no_cards_reviewed_message());
        assert!(
            state
                .store
                .reviews_for_datum_key(exam.questions()[0].datum_key())
                .unwrap()
                .is_empty()
        );
    }

    #[test]
    fn exam_pass_ended_early_reports_answers_against_original_question_count() {
        let first = datum_with_id(1, "Capital of Spain", "Madrid");
        let second = datum_with_id(2, "Capital of France", "Paris");
        let now = 1_800_000_000;
        let (mut state, _temp_dir) = make_state(vec![first, second]);
        let request = crate::exam_mode::ExamSizeRequest::new(
            crate::exam_mode::ExamSizeMode::QuestionCount,
            2,
        );
        let exam = state
            .start_exam_pass_with_request(request, 17, now)
            .expect("exam pass");
        let answers = vec![GradedExamAnswer::new(
            crate::exam_mode::ExamAnswer::new(exam.questions()[0].clone(), "Madrid"),
            true,
        )];

        let summary = state.finish_exam_pass(&exam, &answers, now + 30);

        assert!(summary.ended_early());
        assert_eq!(summary.submitted_answers(), 1);
        assert_eq!(summary.total_questions(), 2);
    }

    #[test]
    fn exam_flow_shows_finished_notice_before_review_or_grading() {
        let source = include_str!("main.rs");
        let exam_flow_start = source
            .find("fn start_exam_pass_from_ui")
            .expect("exam flow function");
        let quiz_flow_start = source[exam_flow_start..]
            .find("fn start_quiz_pass_from_ui")
            .expect("next function after exam flow");
        let exam_flow = &source[exam_flow_start..exam_flow_start + quiz_flow_start];
        let timeout_notice = exam_flow
            .find("show_exam_timeout_dialog")
            .expect("timeout notice");
        let finished_notice = exam_flow
            .find("show_exam_finished_dialog")
            .expect("finished notice");
        let grading = exam_flow
            .find("grade_exam_answers_exact_only")
            .expect("review/grading step");

        assert!(timeout_notice < grading);
        assert!(finished_notice < grading);
        assert!(exam_flow.contains("} else if !answers.is_empty() {"));
        assert!(exam_flow.contains("let inexact_answers = review_plan.inexact_answers();"));
        assert!(exam_flow.contains("index + 1 < inexact_answers.len()"));
    }

    #[test]
    fn exam_pass_percentage_request_uses_active_card_count() {
        let now = 1_800_000_000;
        let cards = vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
            datum_with_id(3, "three", "tres"),
            datum_with_id(4, "four", "cuatro"),
            datum_with_id(5, "five", "cinco"),
        ];
        let (mut state, _temp_dir) = make_state(cards.clone());
        state
            .store
            .set_datum_suspended(&DatumKey::for_datum(&cards[4]), true)
            .unwrap();
        let request =
            crate::exam_mode::ExamSizeRequest::new(crate::exam_mode::ExamSizeMode::Percentage, 50);

        let exam = state
            .start_exam_pass_with_request(request, 17, now)
            .expect("exam pass");

        assert_eq!(exam.questions().len(), 2);
        assert!(
            !exam
                .questions()
                .iter()
                .any(|question| { question.datum_key() == &DatumKey::for_datum(&cards[4]) })
        );
    }

    #[test]
    fn exam_pass_reports_when_no_active_cards_are_available() {
        let now = 1_800_000_000;
        let card = datum_with_id(1, "Capital of Spain", "Madrid");
        let (mut state, _temp_dir) = make_state(vec![card.clone()]);
        state
            .store
            .set_datum_suspended(&DatumKey::for_datum(&card), true)
            .unwrap();
        let request = crate::exam_mode::ExamSizeRequest::new(
            crate::exam_mode::ExamSizeMode::QuestionCount,
            1,
        );

        assert_eq!(state.start_exam_pass_with_request(request, 17, now), None);
        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(
            state.last_status,
            english().no_cards_available_to_start_pass()
        );
    }

    #[test]
    fn start_quiz_pass_samples_active_cards_and_marks_review_active() {
        let due = datum_with_id(1, "Capital of Spain", "Madrid");
        let not_due = datum_with_id(2, "Capital of France", "Paris");
        let suspended = datum_with_id(3, "Capital of Italy", "Rome");
        let deferred = datum_with_id(4, "Capital of Portugal", "Lisbon");
        let active_extra = datum_with_id(5, "Capital of Germany", "Berlin");
        let active_extra_two = datum_with_id(6, "Capital of Greece", "Athens");
        let now = 1_800_000_000;
        let (mut state, _temp_dir) = make_state(vec![
            due.clone(),
            not_due.clone(),
            suspended.clone(),
            deferred.clone(),
            active_extra,
            active_extra_two,
        ]);
        record_review_at(
            &state,
            &not_due,
            now,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );
        state
            .store
            .set_datum_suspended(&DatumKey::for_datum(&suspended), true)
            .unwrap();
        state
            .store
            .defer_datum_until(&DatumKey::for_datum(&deferred), now + 60)
            .unwrap();
        let request = crate::quiz_mode::QuizSizeRequest::new(
            crate::quiz_mode::QuizSizeMode::QuestionCount,
            4,
        );

        let quiz = state
            .start_quiz_pass_with_request(request, 17, now)
            .unwrap_or_else(|| panic!("quiz pass: {}", state.last_status));

        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
        assert_eq!(quiz.questions().len(), 4);
        assert_eq!(state.last_status, english().started_quiz_pass(4));
        assert!(quiz.questions().iter().any(|question| {
            question.datum_key() == &DatumKey::for_datum(&due)
                && question.prompt() == "Capital of Spain"
        }));
        assert!(quiz.questions().iter().any(|question| {
            question.datum_key() == &DatumKey::for_datum(&not_due)
                && question.prompt() == "Capital of France"
        }));
        assert!(
            !quiz
                .questions()
                .iter()
                .any(|question| question.datum_key() == &DatumKey::for_datum(&suspended))
        );
        assert!(
            !quiz
                .questions()
                .iter()
                .any(|question| question.datum_key() == &DatumKey::for_datum(&deferred))
        );
    }

    #[test]
    fn quiz_pass_finishes_without_recording_review_history_or_schedule_changes() {
        let first = datum_with_id(1, "Capital of Spain", "Madrid");
        let second = datum_with_id(2, "Capital of France", "Paris");
        let third = datum_with_id(3, "Capital of Italy", "Rome");
        let fourth = datum_with_id(4, "Capital of Portugal", "Lisbon");
        let now = 1_800_000_000;
        let (mut state, _temp_dir) = make_state(vec![first.clone(), second, third, fourth]);
        let request = crate::quiz_mode::QuizSizeRequest::new(
            crate::quiz_mode::QuizSizeMode::QuestionCount,
            2,
        );

        let quiz = state
            .start_quiz_pass_with_request(request, 17, now)
            .expect("quiz pass");
        let answers = vec![crate::quiz_mode::QuizAnswer::new(
            quiz.questions()[0].clone(),
            quiz.questions()[0].correct_choice_index(),
        )];

        let summary = state.finish_quiz_pass(&quiz, &answers, now + 30);

        assert_eq!(summary.total_questions(), 2);
        assert_eq!(summary.submitted_answers(), 1);
        assert_eq!(summary.correct_answers(), 1);
        assert!(summary.ended_early());
        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(state.last_status, english().completed_quiz_pass(1));
        assert!(
            state
                .store
                .reviews_for_datum_key(quiz.questions()[0].datum_key())
                .unwrap()
                .is_empty()
        );
        assert!(
            state
                .store
                .schedule_for_datum_key(quiz.questions()[0].datum_key())
                .unwrap()
                .is_none()
        );
        assert!(
            state
                .store
                .reviews_for_datum_key(&DatumKey::for_datum(&first))
                .unwrap()
                .is_empty()
        );
    }

    #[test]
    fn quiz_pass_reports_when_distinct_answer_choices_are_insufficient() {
        let now = 1_800_000_000;
        let cards = vec![
            datum_with_id(1, "one", "same"),
            datum_with_id(2, "two", "same"),
            datum_with_id(3, "three", "other"),
        ];
        let (mut state, _temp_dir) = make_state(cards);
        let request = crate::quiz_mode::QuizSizeRequest::new(
            crate::quiz_mode::QuizSizeMode::QuestionCount,
            1,
        );

        assert_eq!(state.start_quiz_pass_with_request(request, 17, now), None);
        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(
            state.last_status,
            english().not_enough_distinct_answer_choices_for_quiz(2)
        );
    }

    #[test]
    fn start_cycle_excludes_suspended_cards() {
        let suspended_datum = datum_with_id(1, "Capital of Spain", "Madrid");
        let active_datum = datum_with_id(2, "Capital of France", "Paris");
        let (mut state, _temp_dir) =
            make_state(vec![suspended_datum.clone(), active_datum.clone()]);
        state
            .store
            .set_datum_suspended(&DatumKey::for_datum(&suspended_datum), true)
            .unwrap();

        state.start_cycle_with_config(SchedulerConfig::new(0, 1_800_000_000, 3));

        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
        assert_eq!(state.session.queue(), vec![1]);
    }

    #[test]
    fn start_cycle_excludes_deferred_cards_until_the_deferral_expires() {
        let now = 1_800_000_000;
        let deferred_datum = datum_with_id(1, "Capital of Spain", "Madrid");
        let active_datum = datum_with_id(2, "Capital of France", "Paris");
        let (mut state, _temp_dir) = make_state(vec![deferred_datum.clone(), active_datum.clone()]);
        state
            .store
            .defer_datum_until(&DatumKey::for_datum(&deferred_datum), now + 60)
            .unwrap();

        state.start_cycle_with_config(SchedulerConfig::new(0, now, 3));

        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
        assert_eq!(state.session.queue(), vec![1]);
    }

    #[test]
    fn start_cycle_clears_expired_card_states_before_building_queue() {
        let now = 1_800_000_000;
        let expired_deferred_datum = datum_with_id(1, "Capital of Spain", "Madrid");
        let active_datum = datum_with_id(2, "Capital of France", "Paris");
        let (mut state, _temp_dir) =
            make_state(vec![expired_deferred_datum.clone(), active_datum.clone()]);
        state
            .store
            .defer_datum_until(&DatumKey::for_datum(&expired_deferred_datum), now - 60)
            .unwrap();
        state
            .store
            .set_datum_flagged(&DatumKey::for_datum(&active_datum), true)
            .unwrap();
        state
            .store
            .set_datum_flagged(&DatumKey::for_datum(&active_datum), false)
            .unwrap();

        state.start_cycle_with_config(SchedulerConfig::new(0, now, 3));

        let mut sorted_queue = state.session.queue().to_vec();
        sorted_queue.sort_unstable();
        assert_eq!(sorted_queue, vec![0, 1]);
        assert_eq!(state.store.clear_inactive_card_states(now).unwrap(), 0);
    }

    #[test]
    fn force_start_cycle_excludes_suspended_cards() {
        let suspended_datum = datum_with_id(1, "Capital of Spain", "Madrid");
        let active_datum = datum_with_id(2, "Capital of France", "Paris");
        let (mut state, _temp_dir) =
            make_state(vec![suspended_datum.clone(), active_datum.clone()]);
        state
            .store
            .set_datum_suspended(&DatumKey::for_datum(&suspended_datum), true)
            .unwrap();

        state.force_start_cycle_with_seed(23, 1_000_000);

        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
        assert_eq!(state.session.queue(), vec![1]);
    }

    #[test]
    fn force_start_cycle_excludes_deferred_cards() {
        let now = 1_800_000_000;
        let deferred_datum = datum_with_id(1, "Capital of Spain", "Madrid");
        let active_datum = datum_with_id(2, "Capital of France", "Paris");
        let (mut state, _temp_dir) = make_state(vec![deferred_datum.clone(), active_datum.clone()]);
        state
            .store
            .defer_datum_until(&DatumKey::for_datum(&deferred_datum), now + 60)
            .unwrap();

        state.force_start_cycle_with_seed(23, now);

        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
        assert_eq!(state.session.queue(), vec![1]);
    }

    #[test]
    fn force_start_cycle_clears_expired_card_states_before_building_queue() {
        let now = 1_800_000_000;
        let expired_deferred_datum = datum_with_id(1, "Capital of Spain", "Madrid");
        let active_datum = datum_with_id(2, "Capital of France", "Paris");
        let (mut state, _temp_dir) =
            make_state(vec![expired_deferred_datum.clone(), active_datum.clone()]);
        state
            .store
            .defer_datum_until(&DatumKey::for_datum(&expired_deferred_datum), now - 60)
            .unwrap();
        state
            .store
            .set_datum_suspended(&DatumKey::for_datum(&active_datum), true)
            .unwrap();
        state
            .store
            .set_datum_suspended(&DatumKey::for_datum(&active_datum), false)
            .unwrap();

        state.force_start_cycle_with_seed(23, now);

        let mut sorted_queue = state.session.queue().to_vec();
        sorted_queue.sort_unstable();
        assert_eq!(sorted_queue, vec![0, 1]);
        assert_eq!(state.store.clear_inactive_card_states(now).unwrap(), 0);
    }

    #[test]
    fn force_start_cycle_on_empty_deck_stays_ready_to_start() {
        let (mut state, _temp_dir) = make_state(vec![]);

        state.force_start_cycle_with_seed(29, 1_000_000);

        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert!(state.session.queue().is_empty());
        assert_eq!(
            state.last_status,
            english().no_cards_available_to_start_pass()
        );
    }

    #[test]
    fn schedule_preview_reports_zero_when_nothing_is_due() {
        let reviewed_datum = datum("Capital of Spain", "Madrid");
        let now = current_time_and_seed().unwrap().0;
        let (state, _temp_dir) = make_state(vec![reviewed_datum.clone()]);
        record_review_at(
            &state,
            &reviewed_datum,
            now,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );

        assert_eq!(
            schedule_preview_if_started_now(&state),
            Ok(ReadySchedulePreview {
                scheduled_cards: 0,
                due_cards: 0,
            })
        );
    }

    #[test]
    fn schedule_preview_changes_when_time_reaches_due_time() {
        let reviewed_datum = datum("Capital of Spain", "Madrid");
        let now = 1_000_000;
        let due_at = now + 180;
        let (mut state, _temp_dir) = make_state(vec![reviewed_datum.clone()]);
        state.settings.minimum_cards_per_session = 0;
        state
            .store
            .save_card_schedule(&CardSchedule::from_storage(
                DatumKey::for_datum(&reviewed_datum),
                cosimo::CardMemoryState::Review,
                due_at,
                1.0,
                5.0,
                0,
                0,
                0,
                1,
                0,
                Some(now - 60),
                Some(RecallFeedback::Good),
            ))
            .unwrap();

        assert_eq!(
            schedule_preview_at(&state, now, 0),
            Ok(ReadySchedulePreview {
                scheduled_cards: 0,
                due_cards: 0,
            })
        );
        assert_eq!(
            schedule_preview_at(&state, due_at, 0),
            Ok(ReadySchedulePreview {
                scheduled_cards: 1,
                due_cards: 1,
            })
        );
        assert_eq!(
            main_status_bar_text(&state, due_at).unwrap(),
            "Cards: 1 total; due: 1; cards due now."
        );
    }

    #[test]
    fn schedule_preview_excludes_deferred_cards_until_the_deferral_expires() {
        let now = 1_800_000_000;
        let deferred_datum = datum_with_id(1, "Capital of Spain", "Madrid");
        let (mut state, _temp_dir) = make_state(vec![deferred_datum.clone()]);
        state.settings.minimum_cards_per_session = 0;
        state
            .store
            .defer_datum_until(&DatumKey::for_datum(&deferred_datum), now + 60)
            .unwrap();

        assert_eq!(
            schedule_preview_at(&state, now, 0),
            Ok(ReadySchedulePreview {
                scheduled_cards: 0,
                due_cards: 0,
            })
        );
        assert_eq!(
            schedule_preview_at(&state, now + 60, 0),
            Ok(ReadySchedulePreview {
                scheduled_cards: 1,
                due_cards: 1,
            })
        );
    }

    #[test]
    fn schedule_preview_includes_due_cards() {
        let (state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);

        assert_eq!(
            schedule_preview_if_started_now(&state),
            Ok(ReadySchedulePreview {
                scheduled_cards: 1,
                due_cards: 1,
            })
        );
    }

    #[test]
    fn status_bar_due_count_respects_new_card_daily_limit() {
        let now = 1_800_000_000;
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "First", "Answer"),
            datum_with_id(2, "Second", "Answer"),
        ]);
        state.settings.minimum_cards_per_session = 0;
        state.settings.new_cards_per_day = 1;

        assert_eq!(
            schedule_preview_at(&state, now, 0),
            Ok(ReadySchedulePreview {
                scheduled_cards: 1,
                due_cards: 1,
            })
        );
        assert_eq!(
            main_status_bar_text(&state, now).unwrap(),
            "Cards: 2 total; due: 1; cards due now."
        );

        state.settings.new_cards_per_day = 0;

        assert_eq!(
            schedule_preview_at(&state, now, 0),
            Ok(ReadySchedulePreview {
                scheduled_cards: 0,
                due_cards: 0,
            })
        );
        assert_eq!(
            main_status_bar_text(&state, now).unwrap(),
            "Cards: 2 total; no review scheduled."
        );
    }

    #[test]
    fn ready_due_filter_respects_new_card_daily_limit_but_new_filter_keeps_backlog() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "First", "Answer"),
            datum_with_id(2, "Second", "Answer"),
        ]);
        state.settings.minimum_cards_per_session = 0;
        state.settings.new_cards_per_day = 1;

        state.set_ready_card_filter(ReadyCardFilter::Due);
        assert_eq!(
            state
                .try_ready_visible_indices_for_current_filter()
                .unwrap(),
            vec![0]
        );

        state.set_ready_card_filter(ReadyCardFilter::New);
        assert_eq!(
            state
                .try_ready_visible_indices_for_current_filter()
                .unwrap(),
            vec![0, 1]
        );
    }

    #[test]
    fn ready_due_notification_baseline_can_be_synced_after_user_changes() {
        let (mut state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);

        state.last_ready_due_notification_count = Some(0);
        state.sync_ready_due_notification_baseline();

        assert_eq!(state.last_ready_due_notification_count, Some(1));
    }

    #[test]
    fn bulk_deck_replacement_suppresses_due_notifications() {
        let (mut state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);

        state.suspend_ready_due_notifications();

        assert!(state.ready_due_notifications_suspended());
        assert_eq!(state.last_ready_due_notification_count, Some(1));

        state.resume_ready_due_notifications();

        assert!(!state.ready_due_notifications_suspended());
        assert_eq!(state.last_ready_due_notification_count, Some(1));

        let ready_screen_source = include_str!("ready_screen.rs");
        assert!(ready_screen_source.contains("!state_ref.ready_due_notifications_suspended()"));
        let sync_actions_source = include_str!("sync_actions.rs");
        assert!(sync_actions_source.contains("suspend_ready_due_notifications();"));
        assert!(sync_actions_source.contains("resume_ready_due_notifications();"));
        let main_source = include_str!("main.rs");
        assert!(
            main_source.contains("let restore_result =\n        restore_deck_bundle_from_preview")
        );
    }

    #[test]
    fn sound_notification_policy_respects_window_state() {
        assert!(sound_notification_policy_allows(
            SoundNotificationPolicy::Always,
            NotificationVisibility::new(false, false)
        ));
        assert!(sound_notification_policy_allows(
            SoundNotificationPolicy::WhenVisible,
            NotificationVisibility::new(true, false)
        ));
        assert!(!sound_notification_policy_allows(
            SoundNotificationPolicy::WhenVisible,
            NotificationVisibility::new(false, true)
        ));
        assert!(sound_notification_policy_allows(
            SoundNotificationPolicy::WhenInTray,
            NotificationVisibility::new(false, true)
        ));
        assert!(!sound_notification_policy_allows(
            SoundNotificationPolicy::WhenInTray,
            NotificationVisibility::new(true, false)
        ));
        assert!(!sound_notification_policy_allows(
            SoundNotificationPolicy::Never,
            NotificationVisibility::new(true, true)
        ));
    }

    #[test]
    fn schedule_preview_distinguishes_due_cards_from_minimum_fill() {
        let due_datum = datum("Capital of Spain", "Madrid");
        let reviewed_datum = datum("Capital of France", "Paris");
        let now = current_time_and_seed().unwrap().0;
        let (mut state, _temp_dir) = make_state(vec![due_datum, reviewed_datum.clone()]);
        state.settings.minimum_cards_per_session = 2;
        record_review_at(
            &state,
            &reviewed_datum,
            now,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );

        assert_eq!(
            schedule_preview_if_started_now(&state),
            Ok(ReadySchedulePreview {
                scheduled_cards: 2,
                due_cards: 1,
            })
        );
    }

    #[test]
    fn schedule_loading_failure_is_visible_on_ready_preview() {
        let temp_dir = unique_temp_dir("ready-schedule-load-failure");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let datum = datum_with_id(1, "Capital of Spain", "Madrid");
        fs::write(
            &deck_path,
            format_deck(std::slice::from_ref(&datum)).unwrap(),
        )
        .unwrap();
        let state = AppState::load(&deck_path, &db_path).unwrap();
        Connection::open(&db_path)
            .unwrap()
            .execute("DROP TABLE card_schedules", [])
            .unwrap();

        let error = schedule_preview_if_started_now(&state).unwrap_err();
        assert!(error.contains("card_schedules"));
        let status = ready_phase_schedule_status(&state);
        assert!(matches!(status, ReadyScheduleStatus::Unavailable(_)));
        let warning = status.warning().unwrap();
        assert!(warning.contains("Warning: could not preview scheduled cards"));
        assert!(
            ready_summary_warning_text(&state, status.warning())
                .unwrap()
                .contains("Warning: could not preview scheduled cards")
        );
        assert!(
            state
                .try_ready_visible_indices_for_current_filter()
                .is_err()
        );
        assert_eq!(state.ready_visible_indices_for_current_filter(), vec![0]);
    }

    #[test]
    fn start_cycle_fills_up_to_the_minimum_with_non_due_cards() {
        let due = datum("Capital of Spain", "Madrid");
        let not_due = datum("Capital of France", "Paris");
        let also_not_due = datum("Capital of Italy", "Rome");
        let now = 3_000_000;
        let (mut state, _temp_dir) =
            make_state(vec![due.clone(), not_due.clone(), also_not_due.clone()]);
        state.settings.minimum_cards_per_session = 2;
        record_review_at(
            &state,
            &not_due,
            now,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );
        record_review_at(
            &state,
            &also_not_due,
            now,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );

        state.start_cycle_with_config(SchedulerConfig::new(2, now, 13));

        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
        assert_eq!(state.session.queue().len(), 2);
        assert_eq!(state.session.queue()[0], 0);
        assert_eq!(state.last_status, english().started_pass(2));
    }

    #[test]
    fn recording_last_feedback_returns_completed_cycle_summary() {
        let (mut state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);
        state.start_cycle();
        state.show_response(PromptGuess::ProbablyKnow);

        let summary = state
            .record_feedback_and_check_for_cycle_completion(RecallFeedback::Easy)
            .unwrap();

        assert!(!summary.ended_early);
        assert_eq!(summary.total_cards, 1);
        assert_eq!(summary.stats.reviewed_cards, 1);
        assert_eq!(
            summary.stats.prompt_guess_counts,
            prompt_guess_counts(&[(PromptGuess::ProbablyKnow, 1)])
        );
        assert_eq!(
            summary.stats.recall_feedback_counts,
            recall_feedback_counts(&[(RecallFeedback::Easy, 1)])
        );
        assert_eq!(state.session.current_index(), 0);
        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
    }

    #[test]
    fn again_rating_does_not_repeat_card_in_the_same_pass() {
        let (mut state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);
        state.start_cycle();
        state.show_response(PromptGuess::ProbablyDontKnow);

        let summary = state
            .record_feedback_and_check_for_cycle_completion(RecallFeedback::Again)
            .unwrap();

        assert_eq!(state.session.queue(), vec![0]);
        assert_eq!(summary.total_cards, 1);
        assert_eq!(summary.stats.unique_reviewed_cards, 1);
        assert_eq!(summary.stats.reviewed_cards, 1);
        assert_eq!(
            summary.stats.prompt_guess_counts,
            prompt_guess_counts(&[(PromptGuess::ProbablyDontKnow, 1)])
        );
        assert_eq!(
            summary.stats.recall_feedback_counts,
            recall_feedback_counts(&[(RecallFeedback::Again, 1)])
        );
    }

    #[test]
    fn forward_and_reverse_directions_count_as_separate_studied_cards() {
        let card = datum_with_id(1, "a", "b");
        let (mut state, _temp_dir) = make_state(vec![card]);
        state
            .store
            .set_datum_reversible(&DatumKey::for_card_id(1), true)
            .unwrap();
        state.force_start_cycle_with_seed(17, 1_800_000_000);

        assert_eq!(state.session.queue().len(), 2);

        state.show_response(PromptGuess::ProbablyKnow);
        assert!(
            state
                .record_feedback_and_check_for_cycle_completion(RecallFeedback::Good)
                .is_none()
        );
        state.show_response(PromptGuess::ProbablyKnow);
        let summary = state
            .record_feedback_and_check_for_cycle_completion(RecallFeedback::Easy)
            .expect("completed summary");

        assert_eq!(summary.stats.reviewed_cards, 2);
        assert_eq!(summary.stats.unique_reviewed_cards, 2);
        assert!(
            summary
                .stats
                .summary_text(
                    english(),
                    summary.total_cards,
                    summary.elapsed_seconds,
                    false,
                    false
                )
                .contains("Cards studied: 2 of 2")
        );
    }

    #[test]
    fn completed_pass_records_review_pass_with_event_links() {
        let temp_dir = unique_temp_dir("record-review-pass");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        fs::write(
            &deck_path,
            format_deck(&[datum("Capital of Spain", "Madrid")]).unwrap(),
        )
        .unwrap();
        let mut state = AppState::load(&deck_path, &db_path).unwrap();
        let started_at = current_time_and_seed().unwrap().0;
        state.start_cycle_with_config(SchedulerConfig::new(0, started_at, 17));

        state.show_response(PromptGuess::ProbablyDontKnow);
        let summary = state
            .record_feedback_and_check_for_cycle_completion(RecallFeedback::Again)
            .unwrap();

        assert_eq!(summary.total_cards, 1);
        assert_eq!(summary.stats.unique_reviewed_cards, 1);
        assert_eq!(summary.items.len(), 1);

        let connection = Connection::open(&db_path).unwrap();
        let pass: (i64, i64, i64, i64) = connection
            .query_row(
                "
                SELECT initial_questions, total_questions, unique_cards_reviewed, ended_early
                FROM review_passes
                ",
                [],
                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
            )
            .unwrap();
        assert_eq!(pass, (1, 1, 1, 0));

        let items: Vec<(i64, i64)> = connection
            .prepare(
                "
                SELECT position, was_repeat
                FROM review_pass_items
                ORDER BY position
                ",
            )
            .unwrap()
            .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
            .unwrap()
            .collect::<Result<_, _>>()
            .unwrap();
        assert_eq!(items, vec![(1, 0)]);
    }

    #[test]
    fn cycle_completion_uses_the_scheduled_queue_length() {
        let due = datum("Capital of Spain", "Madrid");
        let not_due = datum("Capital of France", "Paris");
        let now = 4_000_000;
        let (mut state, _temp_dir) = make_state(vec![due.clone(), not_due.clone()]);
        record_review_at(
            &state,
            &not_due,
            now,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );

        state.start_cycle_with_config(SchedulerConfig::new(0, now, 17));
        state.show_response(PromptGuess::ProbablyKnow);

        let summary = state
            .record_feedback_and_check_for_cycle_completion(RecallFeedback::Easy)
            .unwrap();

        assert_eq!(summary.total_cards, 1);
        assert_eq!(summary.stats.reviewed_cards, 1);
        assert_eq!(state.session.queue(), vec![0]);
    }

    #[test]
    fn ending_current_pass_early_reports_partial_progress() {
        let (mut state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);
        state.start_cycle();
        state.show_response(PromptGuess::ProbablyDontKnow);
        assert!(
            state
                .record_feedback_and_check_for_cycle_completion(RecallFeedback::Hard)
                .is_none()
        );

        let summary = state.end_current_pass().unwrap();

        assert!(summary.ended_early);
        assert_eq!(summary.total_cards, 2);
        assert_eq!(summary.stats.reviewed_cards, 1);
        assert_eq!(
            summary.stats.prompt_guess_counts,
            prompt_guess_counts(&[(PromptGuess::ProbablyDontKnow, 1)])
        );
        assert_eq!(
            summary.stats.recall_feedback_counts,
            recall_feedback_counts(&[(RecallFeedback::Hard, 1)])
        );
        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
    }

    #[test]
    fn ending_current_pass_without_attempts_does_not_record_empty_pass() {
        let temp_dir = unique_temp_dir("empty-pass-end");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        fs::write(
            &deck_path,
            format_deck(&[datum("Capital of Spain", "Madrid")]).unwrap(),
        )
        .unwrap();
        let mut state = AppState::load(&deck_path, &db_path).unwrap();
        state.start_cycle();

        let summary = state.end_current_pass().unwrap();

        assert!(summary.ended_early);
        assert_eq!(summary.stats.reviewed_cards, 0);
        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        let connection = Connection::open(&db_path).unwrap();
        let pass_count: i64 = connection
            .query_row("SELECT COUNT(*) FROM review_passes", [], |row| row.get(0))
            .unwrap();
        let item_count: i64 = connection
            .query_row("SELECT COUNT(*) FROM review_pass_items", [], |row| {
                row.get(0)
            })
            .unwrap();
        let event_count: i64 = connection
            .query_row("SELECT COUNT(*) FROM review_events", [], |row| row.get(0))
            .unwrap();
        assert_eq!(pass_count, 0);
        assert_eq!(item_count, 0);
        assert_eq!(event_count, 0);
    }

    #[test]
    fn restarting_cycle_resets_counters_and_returns_to_ready_state() {
        let (mut state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);
        state.start_cycle();
        state.show_response(PromptGuess::CertainlyKnow);
        let _ = state.record_feedback_and_check_for_cycle_completion(RecallFeedback::Easy);

        state.restart_cycle();

        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(state.session.current_index(), 0);
        assert_eq!(state.session.stats().reviewed_cards, 0);
        assert_eq!(
            state.session.stats().prompt_guess_counts,
            [0; PromptGuess::COUNT]
        );
        assert_eq!(
            state.session.stats().recall_feedback_counts,
            [0; RecallFeedback::COUNT]
        );
        assert_eq!(state.selected_ready_index, Some(0));
    }

    #[test]
    fn restarting_cycle_preserves_ready_selection() {
        let (mut state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);
        state.select_ready_index(1);
        state.start_cycle();

        state.restart_cycle();

        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(state.selected_ready_index, Some(1));
    }

    #[test]
    fn add_card_persists_to_disk_and_resets_the_cycle() {
        let (mut state, temp_dir) =
            make_state(vec![datum_with_id(1, "Capital of Spain", "Madrid")]);

        state.add_card(datum("Capital of France", "Paris")).unwrap();

        let saved = fs::read_to_string(temp_dir.join("deck.cosimo-deck")).unwrap();
        let saved_data = parse_deck(&saved).unwrap();

        assert_eq!(state.data.len(), 2);
        assert_eq!(saved_data.len(), 2);
        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(
            saved_data[1],
            datum_with_id(2, "Capital of France", "Paris")
        );
        assert_eq!(state.selected_ready_index, Some(1));
        assert_eq!(state.last_status, english().added_card_status(2));
    }

    #[test]
    fn add_card_can_persist_non_reversible_marker() {
        let (mut state, temp_dir) =
            make_state(vec![datum_with_id(1, "Capital of Spain", "Madrid")]);

        state
            .add_card(datum("Capital of France", "Paris").with_reversal_prohibited(true))
            .unwrap();

        let saved = fs::read_to_string(temp_dir.join("deck.cosimo-deck")).unwrap();
        let saved_data = parse_deck(&saved).unwrap();

        assert!(state.data[1].is_reversal_prohibited());
        assert!(saved_data[1].is_reversal_prohibited());
    }

    #[test]
    fn add_card_rejects_duplicate_prompt() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one\r\n", "uno")]);

        let error = state.add_card(datum(" one\n", "eins")).unwrap_err();

        assert_eq!(error, english().duplicate_card_prompt());
        assert_eq!(state.data, vec![datum_with_id(1, "one\r\n", "uno")]);
    }

    #[test]
    fn repeated_add_card_rejects_duplicate_prompt_added_earlier_in_the_session() {
        let (mut state, _temp_dir) = make_state(Vec::new());

        state.add_card(datum("one", "uno")).unwrap();
        let error = state.add_card(datum("one", "eins")).unwrap_err();

        assert_eq!(error, english().duplicate_card_prompt());
        assert_eq!(state.data, vec![datum_with_id(1, "one", "uno")]);
    }

    #[test]
    fn add_card_selects_the_new_study_item_when_reverse_cards_shift_indices() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);
        state
            .store
            .set_datum_reversible(&DatumKey::for_card_id(1), true)
            .unwrap();

        state.add_card(datum("three", "tres")).unwrap();

        assert_eq!(state.selected_ready_index, Some(3));
        assert_eq!(
            state
                .selected_ready_study_item()
                .unwrap()
                .unwrap()
                .datum_key(),
            &DatumKey::for_card_id(3)
        );
    }

    #[test]
    fn ready_selection_preserves_card_identity_when_reverse_items_shift_indices() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);
        state.select_ready_index(1);

        state
            .store
            .set_datum_reversible(&DatumKey::for_card_id(1), true)
            .unwrap();
        state.normalize_ready_selection_for_filter();

        assert_eq!(state.selected_ready_index, Some(2));
        assert_eq!(
            state
                .selected_ready_study_item()
                .unwrap()
                .unwrap()
                .datum_key(),
            &DatumKey::for_card_id(2)
        );
    }

    #[test]
    fn changing_ready_sort_preserves_selected_card_identity() {
        let selected_card = datum_with_id(2, "Selected", "Answer");
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "Unreviewed", "Answer"),
            selected_card.clone(),
            datum_with_id(3, "Also unreviewed", "Answer"),
        ]);
        state.select_ready_index(1);
        record_review_at(
            &state,
            &selected_card,
            1_800_000_000,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Good,
        );

        state.set_ready_card_sort(ReadyCardSort::LastReviewed);

        assert_eq!(state.ready_visible_indices.borrow().as_slice(), &[1, 0, 2]);
        assert_selected_ready_key(&state, DatumKey::for_card_id(2));
    }

    #[test]
    fn edit_selected_card_preserves_selected_card_identity() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);
        state.select_ready_index(1);

        state
            .edit_selected_card(datum("two edited", "dos"))
            .unwrap();

        assert_selected_ready_key(&state, DatumKey::for_card_id(2));
        assert_eq!(
            state.selected_ready_study_item().unwrap().unwrap().datum(),
            &datum_with_id(2, "two edited", "dos")
        );
    }

    #[test]
    fn edit_selected_reverse_card_updates_parent_in_normal_orientation() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        state.toggle_selected_card_reversal().unwrap();
        state.select_ready_index(1);

        state
            .edit_selected_card(datum("one edited", "uno edited"))
            .unwrap();

        assert_eq!(
            state.data,
            vec![datum_with_id(1, "one edited", "uno edited")]
        );
        assert_selected_ready_key(&state, DatumKey::for_reverse_of(&DatumKey::for_card_id(1)));
        let selected_item = state.selected_ready_study_item().unwrap().unwrap();
        assert!(selected_item.is_reverse());
        assert_eq!(selected_item.datum().prompt(), "uno edited");
        assert_eq!(selected_item.datum().response(), "one edited");
    }

    #[test]
    fn edit_selected_card_rejects_duplicate_prompt() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);
        state.select_ready_index(1);

        let error = state
            .edit_selected_card(datum("one", "dos edited"))
            .unwrap_err();

        assert_eq!(error, english().duplicate_card_prompt());
        assert_eq!(
            state.data,
            vec![
                datum_with_id(1, "one", "uno"),
                datum_with_id(2, "two", "dos")
            ]
        );
    }

    #[test]
    fn adding_reversible_card_preserves_selected_parent_card() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);
        state.select_ready_index(1);

        state.toggle_selected_card_reversal().unwrap();

        assert_eq!(state.selected_ready_index, Some(1));
        assert_selected_ready_key(&state, DatumKey::for_card_id(2));
        let items = state.study_items().unwrap();
        assert_eq!(items.len(), 3);
        assert_eq!(
            items[2].datum_key(),
            &DatumKey::from_storage_key("card:2:reverse")
        );
    }

    #[test]
    fn add_card_selects_the_new_card() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);
        state.select_ready_index(0);

        state.add_card(datum("three", "tres")).unwrap();

        assert_eq!(state.selected_ready_index, Some(2));
        assert_selected_ready_key(&state, DatumKey::for_card_id(3));
        assert_eq!(
            state.selected_ready_study_item().unwrap().unwrap().datum(),
            &datum_with_id(3, "three", "tres")
        );
    }

    #[test]
    fn ready_text_filter_preserves_selected_card_when_still_visible() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "Spanish article", "el"),
            datum_with_id(2, "French article", "le"),
            datum_with_id(3, "French pronoun", "je"),
        ]);
        state.select_ready_index(1);

        state.set_ready_card_text_filter("French".to_string());

        assert_eq!(state.ready_visible_indices.borrow().as_slice(), &[1, 2]);
        assert_selected_ready_key(&state, DatumKey::for_card_id(2));
    }

    #[test]
    fn ready_combo_filter_preserves_selected_card_when_still_visible() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "Active", "Answer"),
            datum_with_id(2, "Suspended", "Answer"),
        ]);
        state.select_ready_index(1);
        state.toggle_selected_card_suspension().unwrap();

        state.set_ready_card_filter(ReadyCardFilter::Suspended);

        assert_eq!(state.ready_visible_indices.borrow().as_slice(), &[1]);
        assert_selected_ready_key(&state, DatumKey::for_card_id(2));
    }

    #[test]
    fn edit_selected_card_persists_updated_content() {
        let (mut state, temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);

        state
            .edit_selected_card(datum("Capital of Spain", "Madrid, Spain"))
            .unwrap();

        let saved = fs::read_to_string(temp_dir.join("deck.cosimo-deck")).unwrap();
        let saved_data = parse_deck(&saved).unwrap();

        assert_eq!(saved_data, vec![datum("Capital of Spain", "Madrid, Spain")]);
        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(state.selected_ready_index, Some(0));
        assert_eq!(state.last_status, english().saved_selected_card_status(1));
    }

    #[test]
    fn toggle_selected_card_suspension_persists_review_state() {
        let (mut state, _temp_dir) =
            make_state(vec![datum_with_id(1, "Capital of Spain", "Madrid")]);

        state.toggle_selected_card_suspension().unwrap();
        assert!(
            state
                .store
                .suspended_datum_keys()
                .unwrap()
                .contains(&DatumKey::for_datum(&state.data[0]))
        );
        assert_eq!(state.last_status, english().suspended_card_status());

        state.toggle_selected_card_suspension().unwrap();
        assert!(
            !state
                .store
                .suspended_datum_keys()
                .unwrap()
                .contains(&DatumKey::for_datum(&state.data[0]))
        );
        assert_eq!(state.last_status, english().unsuspended_card_status());
    }

    #[test]
    fn toggle_selected_card_deferral_persists_review_state() {
        let (mut state, _temp_dir) =
            make_state(vec![datum_with_id(1, "Capital of Spain", "Madrid")]);
        let datum_key = DatumKey::for_datum(&state.data[0]);

        state.toggle_selected_card_deferral().unwrap();
        assert!(
            state
                .load_deferred_card_keys()
                .unwrap()
                .contains(&datum_key)
        );
        assert_eq!(state.last_status, english().deferred_card_status());

        state.toggle_selected_card_deferral().unwrap();
        assert!(
            !state
                .load_deferred_card_keys()
                .unwrap()
                .contains(&datum_key)
        );
        assert_eq!(state.last_status, english().cleared_card_deferral_status());
    }

    #[test]
    fn deferring_current_prompt_moves_to_next_review_card() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "Capital of Spain", "Madrid"),
            datum_with_id(2, "Capital of France", "Paris"),
        ]);
        let first_key = DatumKey::for_datum(&state.data[0]);
        let second_key = DatumKey::for_datum(&state.data[1]);
        state.session = ReviewSession::start(
            vec![0, 1],
            state.study_items().unwrap().len(),
            true,
            0,
            1_800_000_000,
        )
        .unwrap();

        let progress = state.defer_current_review_card().unwrap();

        assert!(matches!(progress, ReviewDeferralProgress::Continuing));
        assert_eq!(state.session.queue(), vec![1]);
        assert_eq!(state.session.current_index(), 0);
        assert_eq!(state.current_datum().unwrap().prompt(), "Capital of France");
        assert!(
            state
                .load_deferred_card_keys()
                .unwrap()
                .contains(&first_key)
        );
        assert!(
            !state
                .load_deferred_card_keys()
                .unwrap()
                .contains(&second_key)
        );
        assert_eq!(state.last_status, english().deferred_card_status());
    }

    #[test]
    fn deferring_current_prompt_after_answering_keeps_moving_forward() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "Capital of Spain", "Madrid"),
            datum_with_id(2, "Capital of France", "Paris"),
            datum_with_id(3, "Capital of Portugal", "Lisbon"),
        ]);
        let deferred_key = DatumKey::for_datum(&state.data[1]);
        let next_key = DatumKey::for_datum(&state.data[2]);
        state.session = ReviewSession::start(
            vec![0, 1, 2],
            state.study_items().unwrap().len(),
            true,
            0,
            1_800_000_000,
        )
        .unwrap();
        state.show_response(PromptGuess::ProbablyKnow);
        assert!(
            state
                .record_feedback_and_check_for_cycle_completion(RecallFeedback::Good)
                .is_none()
        );
        assert_eq!(state.current_datum().unwrap().prompt(), "Capital of France");

        let progress = state.defer_current_review_card().unwrap();

        assert!(matches!(progress, ReviewDeferralProgress::Continuing));
        assert_eq!(state.session.queue(), vec![0, 2]);
        assert_eq!(state.session.current_index(), 1);
        assert_eq!(
            state.current_datum().unwrap().prompt(),
            "Capital of Portugal"
        );
        assert!(
            state
                .load_deferred_card_keys()
                .unwrap()
                .contains(&deferred_key)
        );
        assert!(!state.load_deferred_card_keys().unwrap().contains(&next_key));
    }

    #[test]
    fn deferring_only_prompt_ends_review_without_empty_summary() {
        let (mut state, _temp_dir) =
            make_state(vec![datum_with_id(1, "Capital of Spain", "Madrid")]);
        let datum_key = DatumKey::for_datum(&state.data[0]);
        state.session = ReviewSession::start(
            vec![0],
            state.study_items().unwrap().len(),
            true,
            0,
            1_800_000_000,
        )
        .unwrap();

        let progress = state.defer_current_review_card().unwrap();

        assert!(matches!(
            progress,
            ReviewDeferralProgress::Finished {
                completed_summary: None
            }
        ));
        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert!(
            state
                .load_deferred_card_keys()
                .unwrap()
                .contains(&datum_key)
        );
        assert_eq!(state.last_status, english().deferred_card_status());
    }

    #[test]
    fn deferring_last_prompt_after_reviews_completes_pass_summary() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "Capital of Spain", "Madrid"),
            datum_with_id(2, "Capital of France", "Paris"),
        ]);
        let deferred_key = DatumKey::for_datum(&state.data[1]);
        state.session = ReviewSession::start(
            vec![0, 1],
            state.study_items().unwrap().len(),
            true,
            0,
            1_800_000_000,
        )
        .unwrap();
        state.show_response(PromptGuess::ProbablyKnow);
        assert!(
            state
                .record_feedback_and_check_for_cycle_completion(RecallFeedback::Good)
                .is_none()
        );

        let progress = state.defer_current_review_card().unwrap();
        let ReviewDeferralProgress::Finished {
            completed_summary: Some(summary),
        } = progress
        else {
            panic!("expected completed deferral summary");
        };

        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(summary.total_cards, 1);
        assert_eq!(summary.initial_cards, 1);
        assert_eq!(summary.stats.reviewed_cards, 1);
        assert_eq!(summary.items.len(), 1);
        assert!(
            state
                .load_deferred_card_keys()
                .unwrap()
                .contains(&deferred_key)
        );
    }

    #[test]
    fn deferring_current_review_card_requires_prompt_phase() {
        let (mut state, _temp_dir) =
            make_state(vec![datum_with_id(1, "Capital of Spain", "Madrid")]);
        let datum_key = DatumKey::for_datum(&state.data[0]);

        assert!(state.defer_current_review_card().is_err());

        state.session = ReviewSession::start(
            vec![0],
            state.study_items().unwrap().len(),
            true,
            0,
            1_800_000_000,
        )
        .unwrap();
        state.show_response(PromptGuess::ProbablyKnow);

        assert!(state.defer_current_review_card().is_err());
        assert!(
            !state
                .load_deferred_card_keys()
                .unwrap()
                .contains(&datum_key)
        );
    }

    #[test]
    fn deferring_all_review_cards_reports_no_reviewed_cards() {
        let action = review_deferral_ui_action(ReviewDeferralProgress::Finished {
            completed_summary: None,
        });

        assert!(matches!(
            action,
            ReviewDeferralUiAction::ShowNoCardsReviewed
        ));
    }

    #[test]
    fn toggle_selected_card_flag_persists_review_state() {
        let (mut state, _temp_dir) =
            make_state(vec![datum_with_id(1, "Capital of Spain", "Madrid")]);

        state.toggle_current_or_selected_card_flag().unwrap();
        assert!(
            state
                .store
                .flagged_datum_keys()
                .unwrap()
                .contains(&DatumKey::for_datum(&state.data[0]))
        );
        assert_eq!(state.last_status, english().flagged_card_status());

        state.toggle_current_or_selected_card_flag().unwrap();
        assert!(
            !state
                .store
                .flagged_datum_keys()
                .unwrap()
                .contains(&DatumKey::for_datum(&state.data[0]))
        );
        assert_eq!(state.last_status, english().unflagged_card_status());
    }

    #[test]
    fn toggle_current_card_flag_works_during_review() {
        let (mut state, _temp_dir) =
            make_state(vec![datum_with_id(1, "Capital of Spain", "Madrid")]);
        state.start_cycle();

        state.toggle_current_or_selected_card_flag().unwrap();

        assert!(
            state
                .store
                .flagged_datum_keys()
                .unwrap()
                .contains(&DatumKey::for_card_id(1))
        );
        assert_eq!(state.last_status, english().flagged_card_status());
        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));

        state.toggle_current_or_selected_card_flag().unwrap();

        assert!(
            !state
                .store
                .flagged_datum_keys()
                .unwrap()
                .contains(&DatumKey::for_card_id(1))
        );
        assert_eq!(state.last_status, english().unflagged_card_status());
        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
    }

    #[test]
    fn review_flag_button_focus_is_explicit_not_inferred_from_click_focus() {
        let main_source = include_str!("main.rs");
        let ready_card_actions_source = include_str!("ready_card_actions.rs");

        assert!(ready_card_actions_source.contains("enum ReviewFlagFocusTarget"));
        assert!(main_source.contains("toggle_current_or_selected_card_flag_from_button("));
        assert!(ready_card_actions_source.contains("ReviewFlagFocusTarget::GuessButton"));
        assert!(ready_card_actions_source.contains("ReviewFlagFocusTarget::FeedbackButton"));
        let old_guess_focus_probe = concat!("let restore_guess_", "flag_focus = !was_ready");
        let old_feedback_focus_probe = concat!("let restore_feedback_", "flag_focus = !was_ready");
        assert!(!main_source.contains(old_guess_focus_probe));
        assert!(!main_source.contains(old_feedback_focus_probe));
        assert!(!ready_card_actions_source.contains(old_guess_focus_probe));
        assert!(!ready_card_actions_source.contains(old_feedback_focus_probe));
    }

    #[test]
    fn review_defer_button_uses_standard_click_and_render_focus() {
        let main_source = include_str!("main.rs");
        let study_actions_source = include_str!("study_actions.rs");

        assert!(main_source.contains("ui.guess_defer_card_button.on_click(move |_| {"));
        let defer_handler = study_actions_source
            .split("fn defer_current_review_card_from_ui")
            .nth(1)
            .and_then(|rest| rest.split("fn handle_cycle_summary").next())
            .unwrap();
        assert!(
            defer_handler
                .contains("let defer_result = { state.borrow_mut().defer_current_review_card() };")
        );
        assert!(defer_handler.contains("ReviewDeferralUiAction::ContinueReview => {"));
        assert!(defer_handler.contains("render(ui, &state.borrow());"));
        assert!(!defer_handler.contains("call_after"));
    }

    #[test]
    fn ui_mutation_results_are_not_matched_while_refcell_borrow_is_live() {
        let source_files = [
            ("main.rs", include_str!("main.rs")),
            ("card_batch_dialog.rs", include_str!("card_batch_dialog.rs")),
            ("exam_dialog.rs", include_str!("exam_dialog.rs")),
            ("quiz_dialog.rs", include_str!("quiz_dialog.rs")),
            (
                "deck_properties_actions.rs",
                include_str!("deck_properties_actions.rs"),
            ),
            ("study_actions.rs", include_str!("study_actions.rs")),
        ];
        let risky_match_patterns = [
            concat!("match state.", "borrow_mut()."),
            concat!("match state.", "borrow()."),
            concat!("match add_card.", "borrow_mut()"),
            concat!("match player.", "borrow_mut()"),
        ];

        for (file_name, source) in source_files {
            for pattern in risky_match_patterns {
                assert!(
                    !source.contains(pattern),
                    "{file_name} should not match directly on RefCell borrow expression {pattern:?}"
                );
            }
        }
    }

    #[test]
    fn ready_shown_bulk_flag_applies_to_visible_unflagged_cards() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "apple", "manzana"),
            datum_with_id(2, "banana", "plátano"),
            datum_with_id(3, "apricot", "albaricoque"),
        ]);
        state
            .store
            .set_datum_flagged(&DatumKey::for_card_id(1), true)
            .unwrap();
        state.set_ready_card_text_filter("ap".to_string());

        let preview = state
            .ready_shown_bulk_action_preview(ReadyShownBulkAction::Flag)
            .unwrap();
        assert_eq!(
            preview,
            app_state::ReadyShownBulkActionPreview {
                shown_card_count: 2,
                affected_card_count: 1,
            }
        );

        assert_eq!(
            state
                .apply_ready_shown_bulk_action(ReadyShownBulkAction::Flag)
                .unwrap(),
            1
        );
        let flagged = state.store.flagged_datum_keys().unwrap();
        assert!(flagged.contains(&DatumKey::for_card_id(1)));
        assert!(!flagged.contains(&DatumKey::for_card_id(2)));
        assert!(flagged.contains(&DatumKey::for_card_id(3)));
        assert_eq!(state.last_status, english().flagged_shown_cards_status(1));
    }

    #[test]
    fn ready_shown_bulk_unsuspend_respects_current_combo_filter() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
            datum_with_id(3, "three", "tres"),
        ]);
        state
            .store
            .set_datum_suspended(&DatumKey::for_card_id(1), true)
            .unwrap();
        state
            .store
            .set_datum_suspended(&DatumKey::for_card_id(2), true)
            .unwrap();
        state.set_ready_card_filter(ReadyCardFilter::Suspended);

        let preview = state
            .ready_shown_bulk_action_preview(ReadyShownBulkAction::Unsuspend)
            .unwrap();
        assert_eq!(preview.shown_card_count, 2);
        assert_eq!(preview.affected_card_count, 2);

        assert_eq!(
            state
                .apply_ready_shown_bulk_action(ReadyShownBulkAction::Unsuspend)
                .unwrap(),
            2
        );
        assert!(state.store.suspended_datum_keys().unwrap().is_empty());
        assert_eq!(
            state.last_status,
            english().unsuspended_shown_cards_status(2)
        );
    }

    #[test]
    fn reverse_card_flag_is_independent_from_parent_flag() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        let parent_key = DatumKey::for_card_id(1);
        let reverse_key = DatumKey::from_storage_key("card:1:reverse");
        state.toggle_selected_card_reversal().unwrap();

        state.select_ready_index(0);
        state.toggle_current_or_selected_card_flag().unwrap();
        state.select_ready_index(1);
        state.toggle_current_or_selected_card_flag().unwrap();

        let flagged = state.store.flagged_datum_keys().unwrap();
        assert!(flagged.contains(&parent_key));
        assert!(flagged.contains(&reverse_key));

        state.select_ready_index(1);
        state.toggle_current_or_selected_card_flag().unwrap();

        let flagged = state.store.flagged_datum_keys().unwrap();
        assert!(flagged.contains(&parent_key));
        assert!(!flagged.contains(&reverse_key));
    }

    #[test]
    fn reverse_card_suspension_is_independent_from_parent_suspension() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        let parent_key = DatumKey::for_card_id(1);
        let reverse_key = DatumKey::from_storage_key("card:1:reverse");
        state.toggle_selected_card_reversal().unwrap();

        state.select_ready_index(0);
        state.toggle_selected_card_suspension().unwrap();
        state.select_ready_index(1);
        state.toggle_selected_card_suspension().unwrap();

        let suspended = state.store.suspended_datum_keys().unwrap();
        assert!(suspended.contains(&parent_key));
        assert!(suspended.contains(&reverse_key));

        state.select_ready_index(1);
        state.toggle_selected_card_suspension().unwrap();

        let suspended = state.store.suspended_datum_keys().unwrap();
        assert!(suspended.contains(&parent_key));
        assert!(!suspended.contains(&reverse_key));
        assert!(!selected_ready_card_is_suspended(&state).unwrap());
    }

    #[test]
    fn toggle_selected_card_reversal_adds_generated_reverse_item() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);

        state.toggle_selected_card_reversal().unwrap();

        assert!(
            state
                .store
                .reversible_datum_keys()
                .unwrap()
                .contains(&DatumKey::for_card_id(1))
        );
        assert_eq!(state.last_status, english().marked_card_reversible_status());
        let items = state.study_items().unwrap();
        assert_eq!(items.len(), 3);
        assert!(items[1].is_reverse());
        assert_eq!(items[1].datum().prompt(), "uno");
        assert_eq!(items[1].datum().response(), "one");
    }

    #[test]
    fn toggle_selected_reverse_card_moves_selection_to_parent_card() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);
        state.toggle_selected_card_reversal().unwrap();
        state.select_ready_index(1);

        state.toggle_selected_card_reversal().unwrap();

        assert_selected_ready_key(&state, DatumKey::for_card_id(1));
        assert_eq!(state.selected_ready_index, Some(0));
    }

    #[test]
    fn toggle_selected_reverse_card_clears_selection_when_parent_is_filtered_out() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);
        state.toggle_selected_card_reversal().unwrap();
        state.set_ready_card_filter(ReadyCardFilter::Reverse);
        state.select_ready_index(1);

        state.toggle_selected_card_reversal().unwrap();

        assert_eq!(
            state.ready_visible_indices.borrow().as_slice(),
            &[] as &[usize]
        );
        assert_eq!(state.selected_ready_key, None);
        assert_eq!(state.selected_ready_index, None);
    }

    #[test]
    fn excluding_selected_reverse_card_moves_selection_to_parent_card() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);
        state.toggle_selected_card_reversal().unwrap();
        state.select_ready_index(1);

        state.toggle_selected_card_reversal_exclusion().unwrap();

        assert_selected_ready_key(&state, DatumKey::for_card_id(1));
        assert_eq!(state.selected_ready_index, Some(0));
        assert!(
            state
                .load_reversal_excluded_card_keys()
                .unwrap()
                .contains(&DatumKey::for_card_id(1))
        );
    }

    #[test]
    fn toggle_selected_card_reversal_rejects_duplicate_responses() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "another one", "uno"),
        ]);
        state.toggle_selected_card_reversal().unwrap();
        state.select_ready_index(2);

        let error = state.toggle_selected_card_reversal().unwrap_err();

        assert_eq!(error, english().cannot_reverse_ambiguous_response());
        assert_eq!(
            state.store.reversible_datum_keys().unwrap(),
            HashSet::from([DatumKey::for_card_id(1)])
        );
    }

    #[test]
    fn toggle_selected_card_reversal_rejects_non_reversible_cards() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        state.data[0] = state.data[0].clone().with_reversal_prohibited(true);

        let error = state.toggle_selected_card_reversal().unwrap_err();

        assert_eq!(error, english().cannot_reverse_excluded_card());
        assert!(state.store.reversible_datum_keys().unwrap().is_empty());
    }

    #[test]
    fn toggle_selected_card_reversal_exclusion_removes_reverse_and_allows_reversal_later() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        state.toggle_selected_card_reversal().unwrap();

        state.toggle_selected_card_reversal_exclusion().unwrap();

        assert!(
            state
                .load_reversal_excluded_card_keys()
                .unwrap()
                .contains(&DatumKey::for_card_id(1))
        );
        assert!(
            !state
                .store
                .reversible_datum_keys()
                .unwrap()
                .contains(&DatumKey::for_card_id(1))
        );
        assert_eq!(
            state.last_status,
            english().marked_card_non_reversible_status()
        );

        state.toggle_selected_card_reversal_exclusion().unwrap();
        state.toggle_selected_card_reversal().unwrap();

        assert!(
            state
                .store
                .reversible_datum_keys()
                .unwrap()
                .contains(&DatumKey::for_card_id(1))
        );
        assert_eq!(state.last_status, english().marked_card_reversible_status());
    }

    #[test]
    fn bulk_reversal_excludes_existing_reversals_duplicate_responses_and_non_reversible_cards() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "another one", " uno\n"),
            datum_with_id(3, "two", "dos"),
            datum_with_id(4, "three", "tres"),
            datum_with_id(5, "four", "cuatro"),
        ]);
        state
            .store
            .set_datum_reversible(&DatumKey::for_card_id(3), true)
            .unwrap();
        state.data[3] = state.data[3].clone().with_reversal_prohibited(true);

        assert_eq!(state.eligible_reversible_card_count().unwrap(), 1);
        assert_eq!(state.make_eligible_cards_reversible().unwrap(), 1);

        let reversible = state.store.reversible_datum_keys().unwrap();
        assert!(!reversible.contains(&DatumKey::for_card_id(1)));
        assert!(!reversible.contains(&DatumKey::for_card_id(2)));
        assert!(reversible.contains(&DatumKey::for_card_id(3)));
        assert!(!reversible.contains(&DatumKey::for_card_id(4)));
        assert!(reversible.contains(&DatumKey::for_card_id(5)));
        assert_eq!(
            state.last_status,
            english().marked_cards_reversible_status(1)
        );
    }

    #[test]
    fn bulk_reversal_menu_is_enabled_only_when_cards_are_eligible() {
        let (state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        assert!(eligible_reversal_menu_enabled(&state));

        state
            .store
            .set_datum_reversible(&DatumKey::for_card_id(1), true)
            .unwrap();
        assert!(!eligible_reversal_menu_enabled(&state));

        let (mut reviewing_state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        reviewing_state.start_cycle();
        assert!(!eligible_reversal_menu_enabled(&reviewing_state));
    }

    #[test]
    fn bulk_reversal_ui_does_not_confirm_zero_eligible_cards() {
        assert_eq!(
            bulk_reversal_prompt(
                0,
                BulkReversalBackupImpact::OverwritesLatestReviewDatabaseBackup
            ),
            BulkReversalPrompt::NoEligibleCards
        );
        assert_eq!(
            bulk_reversal_prompt(3, BulkReversalBackupImpact::NoReviewDatabaseBackup),
            BulkReversalPrompt::Confirm {
                eligible_count: 3,
                backup_impact: BulkReversalBackupImpact::NoReviewDatabaseBackup,
            }
        );
        assert_eq!(
            bulk_reversal_prompt(
                3,
                BulkReversalBackupImpact::OverwritesLatestReviewDatabaseBackup
            ),
            BulkReversalPrompt::Confirm {
                eligible_count: 3,
                backup_impact: BulkReversalBackupImpact::OverwritesLatestReviewDatabaseBackup,
            }
        );
    }

    #[test]
    fn bulk_reversal_backs_up_review_database_before_writing() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);
        state
            .store
            .record_review_at(
                &state.data[0],
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Good),
                1_800_000_000,
            )
            .unwrap();

        assert_eq!(state.make_eligible_cards_reversible().unwrap(), 2);

        let backup_path = review_db_backup_path(&state.review_db_path, &state.backup_dir);
        let backup_store = SqliteReviewStore::open(&backup_path).unwrap();
        assert_eq!(
            backup_store
                .reviews_for_datum_key(&DatumKey::for_card_id(1))
                .unwrap()
                .len(),
            1
        );
        assert!(backup_store.reversible_datum_keys().unwrap().is_empty());
        assert_eq!(state.store.reversible_datum_keys().unwrap().len(), 2);
    }

    #[test]
    fn bulk_reversal_respects_disabled_review_database_backups() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        state.settings.review_db_backup_policy = ReviewDbBackupPolicy::Off;

        assert_eq!(state.make_eligible_cards_reversible().unwrap(), 1);

        let backup_path = review_db_backup_path(&state.review_db_path, &state.backup_dir);
        assert!(!backup_path.exists());
        assert!(!review_db_backup_index_path(&state.backup_dir).exists());
    }

    #[test]
    fn bulk_reversal_stops_when_review_database_backup_fails() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        fs::create_dir_all(review_db_backup_index_path(&state.backup_dir)).unwrap();

        let error = state.make_eligible_cards_reversible().unwrap_err();

        assert!(error.starts_with("Could not back up review database"));
        assert!(error.contains(&state.review_db_path.display().to_string()));
        assert!(error.contains(&state.backup_dir.display().to_string()));
        assert!(error.contains("stopped before making changes"));
        assert!(state.store.reversible_datum_keys().unwrap().is_empty());
    }

    #[test]
    fn undo_latest_bulk_reversal_removes_only_latest_bulk_cards() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "manual", "uno"),
            datum_with_id(2, "first batch", "dos"),
            datum_with_id(3, "second batch", "tres"),
        ]);
        state.settings.review_db_backup_policy = ReviewDbBackupPolicy::Off;
        state
            .store
            .set_datum_reversible(&DatumKey::for_card_id(1), true)
            .unwrap();
        state
            .store
            .set_datums_reversible_in_bulk(std::slice::from_ref(&DatumKey::for_card_id(2)))
            .unwrap();
        state
            .store
            .set_datums_reversible_in_bulk(std::slice::from_ref(&DatumKey::for_card_id(3)))
            .unwrap();

        assert_eq!(state.latest_bulk_reversal_count().unwrap(), 1);
        assert_eq!(state.undo_latest_bulk_reversal().unwrap(), 1);

        let reversible = state.store.reversible_datum_keys().unwrap();
        assert!(reversible.contains(&DatumKey::for_card_id(1)));
        assert!(reversible.contains(&DatumKey::for_card_id(2)));
        assert!(!reversible.contains(&DatumKey::for_card_id(3)));
        assert_eq!(
            state.last_status,
            english().removed_latest_bulk_reversal_status(1)
        );
    }

    #[test]
    fn undo_latest_bulk_reversal_backs_up_before_writing() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        state
            .store
            .set_datums_reversible_in_bulk(std::slice::from_ref(&DatumKey::for_card_id(1)))
            .unwrap();

        assert_eq!(state.undo_latest_bulk_reversal().unwrap(), 1);

        let backup_path = review_db_backup_path(&state.review_db_path, &state.backup_dir);
        let backup_store = SqliteReviewStore::open(&backup_path).unwrap();
        assert!(
            backup_store
                .reversible_datum_keys()
                .unwrap()
                .contains(&DatumKey::for_card_id(1))
        );
        assert!(state.store.reversible_datum_keys().unwrap().is_empty());
    }

    #[test]
    fn undo_latest_bulk_reversal_respects_disabled_review_database_backups() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        state.settings.review_db_backup_policy = ReviewDbBackupPolicy::Off;
        state
            .store
            .set_datums_reversible_in_bulk(std::slice::from_ref(&DatumKey::for_card_id(1)))
            .unwrap();

        assert_eq!(state.undo_latest_bulk_reversal().unwrap(), 1);

        let backup_path = review_db_backup_path(&state.review_db_path, &state.backup_dir);
        assert!(!backup_path.exists());
        assert!(!review_db_backup_index_path(&state.backup_dir).exists());
    }

    #[test]
    fn undo_latest_bulk_reversal_stops_when_review_database_backup_fails() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        state
            .store
            .set_datums_reversible_in_bulk(std::slice::from_ref(&DatumKey::for_card_id(1)))
            .unwrap();
        fs::create_dir_all(review_db_backup_index_path(&state.backup_dir)).unwrap();

        let error = state.undo_latest_bulk_reversal().unwrap_err();

        assert!(error.starts_with("Could not back up review database"));
        assert!(error.contains(&state.review_db_path.display().to_string()));
        assert!(error.contains(&state.backup_dir.display().to_string()));
        assert!(error.contains("stopped before making changes"));
        assert!(
            state
                .store
                .reversible_datum_keys()
                .unwrap()
                .contains(&DatumKey::for_card_id(1))
        );
    }

    #[test]
    fn scrub_review_database_backs_up_before_writing() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        let removed_key = DatumKey::for_card_id(2);
        state
            .store
            .record_review_for_datum_key_at(
                &removed_key,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Good),
                1_800_000_000,
            )
            .unwrap();

        let removed_rows = state.scrub_review_database().unwrap();

        assert!(removed_rows > 0);
        let backup_path = review_db_backup_path(&state.review_db_path, &state.backup_dir);
        let backup_store = SqliteReviewStore::open(&backup_path).unwrap();
        assert_eq!(
            backup_store
                .reviews_for_datum_key(&removed_key)
                .unwrap()
                .len(),
            1
        );
        assert!(
            state
                .store
                .reviews_for_datum_key(&removed_key)
                .unwrap()
                .is_empty()
        );
        assert_eq!(
            state.last_status,
            english().scrubbed_review_database_status(removed_rows)
        );
    }

    #[test]
    fn scrub_review_database_preserves_next_card_id_high_watermark() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        let removed_key = DatumKey::for_card_id(3);
        state
            .store
            .record_review_for_datum_key_at(
                &removed_key,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Good),
                1_800_000_000,
            )
            .unwrap();

        assert!(state.scrub_review_database().unwrap() > 0);
        assert_eq!(state.deck_metadata.next_card_id(), Some(4));

        state.add_card(datum("two", "dos")).unwrap();

        assert_eq!(state.data[1], datum_with_id(4, "two", "dos"));
        assert!(
            state
                .store
                .reviews_for_datum_key(&removed_key)
                .unwrap()
                .is_empty()
        );
    }

    #[test]
    fn scrub_review_database_respects_disabled_review_database_backups() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        state.settings.review_db_backup_policy = ReviewDbBackupPolicy::Off;
        state
            .store
            .record_review_for_datum_key_at(
                &DatumKey::for_card_id(2),
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Good),
                1_800_000_000,
            )
            .unwrap();

        assert!(state.scrub_review_database().unwrap() > 0);

        let backup_path = review_db_backup_path(&state.review_db_path, &state.backup_dir);
        assert!(!backup_path.exists());
        assert!(!review_db_backup_index_path(&state.backup_dir).exists());
    }

    #[test]
    fn scrub_review_database_counts_expired_card_state_cleanup() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        let datum_key = DatumKey::for_card_id(1);
        state.store.defer_datum_until(&datum_key, 100).unwrap();

        let removed_rows = state.scrub_review_database().unwrap();

        assert_eq!(removed_rows, 1);
        assert_eq!(
            state
                .store
                .clear_inactive_card_states(1_800_000_000)
                .unwrap(),
            0
        );
        assert_eq!(
            state.last_status,
            english().scrubbed_review_database_status(removed_rows)
        );
    }

    #[test]
    fn reclaim_database_space_does_not_overwrite_retained_backup() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        let stale_key = DatumKey::for_card_id(99);
        state
            .store
            .record_review_for_datum_key_at(
                &stale_key,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Good),
                1_800_000_000,
            )
            .unwrap();
        backup_review_database(&state.review_db_path, &state.backup_dir, &state.store).unwrap();
        state
            .store
            .record_review_for_datum_key_at(
                &DatumKey::for_card_id(1),
                Review::new(PromptGuess::CertainlyKnow, RecallFeedback::Easy),
                1_800_000_100,
            )
            .unwrap();

        state.reclaim_database_space().unwrap();

        let backup_path = review_db_backup_path(&state.review_db_path, &state.backup_dir);
        let backup_store = SqliteReviewStore::open(&backup_path).unwrap();
        assert_eq!(
            backup_store
                .reviews_for_datum_key(&stale_key)
                .unwrap()
                .len(),
            1
        );
        assert!(
            backup_store
                .reviews_for_datum_key(&DatumKey::for_card_id(1))
                .unwrap()
                .is_empty()
        );
        assert_eq!(
            state.last_status,
            english().reclaimed_database_space_status()
        );
    }

    #[test]
    fn reclaim_database_space_respects_disabled_review_database_backups() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        state.settings.review_db_backup_policy = ReviewDbBackupPolicy::Off;

        state.reclaim_database_space().unwrap();

        let backup_path = review_db_backup_path(&state.review_db_path, &state.backup_dir);
        assert!(!backup_path.exists());
        assert!(!review_db_backup_index_path(&state.backup_dir).exists());
        assert_eq!(
            state.last_status,
            english().reclaimed_database_space_status()
        );
    }

    #[test]
    fn reclaim_database_space_clears_expired_card_states_without_backup() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        let datum_key = DatumKey::for_card_id(1);
        state.settings.review_db_backup_policy = ReviewDbBackupPolicy::OnSessionStart;
        state.store.defer_datum_until(&datum_key, 100).unwrap();
        state.store.set_datum_flagged(&datum_key, true).unwrap();
        state.store.set_datum_flagged(&datum_key, false).unwrap();

        state.reclaim_database_space().unwrap();

        assert_eq!(
            state
                .store
                .clear_inactive_card_states(1_800_000_000)
                .unwrap(),
            0
        );
        let backup_path = review_db_backup_path(&state.review_db_path, &state.backup_dir);
        assert!(!backup_path.exists());
        assert!(!review_db_backup_index_path(&state.backup_dir).exists());
    }

    #[test]
    fn deck_edits_allow_duplicate_response_when_only_one_matching_card_is_reversed() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);
        state.toggle_selected_card_reversal().unwrap();

        state.add_card(datum("another one", "uno")).unwrap();
        state.select_ready_index(2);
        state
            .edit_selected_card(datum("two changed", "uno"))
            .unwrap();

        assert_eq!(
            state.data,
            vec![
                datum_with_id(1, "one", "uno"),
                datum_with_id(2, "two changed", "uno"),
                datum_with_id(3, "another one", "uno")
            ]
        );
    }

    #[test]
    fn deck_edits_cannot_give_two_active_reverse_cards_the_same_prompt() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);
        state.toggle_selected_card_reversal().unwrap();
        state.select_ready_index(2);
        state.toggle_selected_card_reversal().unwrap();

        let edit_error = state
            .edit_selected_card(datum("two changed", "uno"))
            .unwrap_err();
        assert_eq!(edit_error, english().cannot_reverse_ambiguous_response());
        assert_eq!(
            state.data,
            vec![
                datum_with_id(1, "one", "uno"),
                datum_with_id(2, "two", "dos")
            ]
        );
    }

    #[test]
    fn reverse_card_reviews_and_schedule_do_not_affect_parent() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        let parent_key = DatumKey::for_card_id(1);
        let reverse_key = DatumKey::from_storage_key("card:1:reverse");
        state.toggle_selected_card_reversal().unwrap();
        state.session = ReviewSession::start(
            vec![1],
            state.study_items().unwrap().len(),
            true,
            0,
            1_800_000_000,
        )
        .unwrap();

        assert_eq!(state.current_datum().unwrap().prompt(), "uno");
        state.show_response(PromptGuess::CertainlyKnow);
        state.record_feedback_and_check_for_cycle_completion(RecallFeedback::Easy);

        assert!(
            state
                .store
                .reviews_for_datum_key(&parent_key)
                .unwrap()
                .is_empty()
        );
        assert!(
            state
                .store
                .schedule_for_datum_key(&parent_key)
                .unwrap()
                .is_none()
        );
        assert_eq!(
            state
                .store
                .reviews_for_datum_key(&reverse_key)
                .unwrap()
                .len(),
            1
        );
        assert!(
            state
                .store
                .schedule_for_datum_key(&reverse_key)
                .unwrap()
                .is_some()
        );
    }

    #[test]
    fn parent_card_reviews_and_schedule_do_not_affect_reverse() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        let parent_key = DatumKey::for_card_id(1);
        let reverse_key = DatumKey::from_storage_key("card:1:reverse");
        state.toggle_selected_card_reversal().unwrap();
        state.session = ReviewSession::start(
            vec![0],
            state.study_items().unwrap().len(),
            true,
            0,
            1_800_000_000,
        )
        .unwrap();

        assert_eq!(state.current_datum().unwrap().prompt(), "one");
        state.show_response(PromptGuess::CertainlyKnow);
        state.record_feedback_and_check_for_cycle_completion(RecallFeedback::Easy);

        assert_eq!(
            state
                .store
                .reviews_for_datum_key(&parent_key)
                .unwrap()
                .len(),
            1
        );
        assert!(
            state
                .store
                .schedule_for_datum_key(&parent_key)
                .unwrap()
                .is_some()
        );
        assert!(
            state
                .store
                .reviews_for_datum_key(&reverse_key)
                .unwrap()
                .is_empty()
        );
        assert!(
            state
                .store
                .schedule_for_datum_key(&reverse_key)
                .unwrap()
                .is_none()
        );
    }

    #[test]
    fn removing_selected_reverse_card_keeps_parent_card() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        let reverse_key = DatumKey::from_storage_key("card:1:reverse");
        state.toggle_selected_card_reversal().unwrap();
        state.session = ReviewSession::start(
            vec![1],
            state.study_items().unwrap().len(),
            true,
            0,
            1_800_000_000,
        )
        .unwrap();
        state.show_response(PromptGuess::CertainlyKnow);
        state.record_feedback_and_check_for_cycle_completion(RecallFeedback::Easy);
        assert_eq!(
            state
                .store
                .reviews_for_datum_key(&reverse_key)
                .unwrap()
                .len(),
            1
        );
        assert!(
            state
                .store
                .schedule_for_datum_key(&reverse_key)
                .unwrap()
                .is_some()
        );
        state.restart_cycle();
        state.select_ready_index(1);

        state.remove_selected_card().unwrap();

        assert_eq!(state.data, vec![datum_with_id(1, "one", "uno")]);
        assert!(state.store.reversible_datum_keys().unwrap().is_empty());
        assert_eq!(state.study_items().unwrap().len(), 1);
        assert_eq!(state.last_status, english().removed_reverse_card_status());

        state.select_ready_index(0);
        state.toggle_selected_card_reversal().unwrap();

        assert_eq!(state.study_items().unwrap().len(), 2);
        assert_eq!(
            state
                .store
                .reviews_for_datum_key(&reverse_key)
                .unwrap()
                .len(),
            1
        );
        assert!(
            state
                .store
                .schedule_for_datum_key(&reverse_key)
                .unwrap()
                .is_some()
        );
    }

    #[test]
    fn removing_reversible_parent_hides_reverse_data_without_reusing_card_id() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        let parent_key = DatumKey::for_card_id(1);
        let reverse_key = DatumKey::from_storage_key("card:1:reverse");
        state.toggle_selected_card_reversal().unwrap();
        state.session = ReviewSession::start(
            vec![1],
            state.study_items().unwrap().len(),
            true,
            0,
            1_800_000_000,
        )
        .unwrap();
        state.show_response(PromptGuess::CertainlyKnow);
        state.record_feedback_and_check_for_cycle_completion(RecallFeedback::Easy);
        state.restart_cycle();
        state.select_ready_index(0);

        state.remove_selected_card().unwrap();

        assert!(state.data.is_empty());
        assert!(state.study_items().unwrap().is_empty());
        assert!(
            state
                .store
                .reversible_datum_keys()
                .unwrap()
                .contains(&parent_key)
        );
        assert_eq!(
            state
                .store
                .reviews_for_datum_key(&reverse_key)
                .unwrap()
                .len(),
            1
        );
        assert!(
            state
                .store
                .schedule_for_datum_key(&reverse_key)
                .unwrap()
                .is_some()
        );

        state.add_card(datum("new", "nuevo")).unwrap();

        assert_eq!(state.data, vec![datum_with_id(2, "new", "nuevo")]);
        let items = state.study_items().unwrap();
        assert_eq!(items.len(), 1);
        assert!(!items[0].is_reverse());
    }

    #[test]
    fn edit_current_revealed_card_preserves_the_response_phase() {
        let (mut state, temp_dir) = make_state(vec![
            datum_with_id(1, "Capital of Spain", "Madrid"),
            datum_with_id(2, "Capital of France", "Paris"),
        ]);
        state.session =
            ReviewSession::start(vec![1, 0], state.data.len(), true, 0, 1_000_000).unwrap();
        state.show_response(PromptGuess::ProbablyKnow);
        let previous_stats = state.session.stats();

        state
            .edit_current_revealed_card(datum("Capital of France", "Paris, France"))
            .unwrap();

        assert!(matches!(
            state.session.phase(),
            ReviewPhase::Response {
                guess: PromptGuess::ProbablyKnow
            }
        ));
        assert_eq!(state.session.queue(), vec![1, 0]);
        assert_eq!(state.session.current_index(), 0);
        assert_eq!(state.session.stats(), previous_stats);
        assert_eq!(
            state.data[1],
            datum_with_id(2, "Capital of France", "Paris, France")
        );
        assert_eq!(
            state.current_datum(),
            Some(datum_with_id(2, "Capital of France", "Paris, France"))
        );
        assert_eq!(state.last_status, english().saved_revealed_card_status());
        assert!(
            fs::read_to_string(temp_dir.join("deck.cosimo-deck"))
                .unwrap()
                .contains("Paris, France")
        );
    }

    #[test]
    fn edit_current_revealed_reverse_card_updates_parent_in_normal_orientation() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(1, "one", "uno")]);
        state.toggle_selected_card_reversal().unwrap();
        state.session = ReviewSession::start(
            vec![1],
            state.study_items().unwrap().len(),
            true,
            0,
            1_000_000,
        )
        .unwrap();
        state.show_response(PromptGuess::ProbablyKnow);

        state
            .edit_current_revealed_card(datum("one edited", "uno edited"))
            .unwrap();

        assert!(matches!(
            state.session.phase(),
            ReviewPhase::Response {
                guess: PromptGuess::ProbablyKnow
            }
        ));
        assert_eq!(
            state.data,
            vec![datum_with_id(1, "one edited", "uno edited")]
        );
        assert_eq!(
            state.current_datum(),
            Some(datum("uno edited", "one edited"))
        );
        assert_eq!(state.last_status, english().saved_revealed_card_status());
    }

    #[test]
    fn edit_current_revealed_card_rejects_duplicate_prompt() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "Capital of Spain", "Madrid"),
            datum_with_id(2, "Capital of France", "Paris"),
        ]);
        state.session =
            ReviewSession::start(vec![1, 0], state.data.len(), true, 0, 1_000_000).unwrap();
        state.show_response(PromptGuess::ProbablyKnow);

        let error = state
            .edit_current_revealed_card(datum("Capital of Spain", "Paris, France"))
            .unwrap_err();

        assert_eq!(error, english().duplicate_card_prompt());
        assert_eq!(
            state.data,
            vec![
                datum_with_id(1, "Capital of Spain", "Madrid"),
                datum_with_id(2, "Capital of France", "Paris")
            ]
        );
    }

    #[test]
    fn edit_current_revealed_card_requires_the_response_phase() {
        let (mut state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);

        let error = state
            .edit_current_revealed_card(datum("Capital of Spain", "Madrid, Spain"))
            .unwrap_err();

        assert_eq!(error, english().choose_prompt_guess_before_feedback());
    }

    #[test]
    fn remove_selected_card_updates_the_deck_file() {
        let (mut state, temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);

        state.remove_selected_card().unwrap();

        let saved = fs::read_to_string(temp_dir.join("deck.cosimo-deck")).unwrap();
        let saved_data = parse_deck(&saved).unwrap();

        assert_eq!(saved_data, vec![datum("Capital of France", "Paris")]);
        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(state.session.current_index(), 0);
        assert_eq!(state.selected_ready_index, Some(0));
        assert_eq!(state.last_status, english().removed_card_status(1, 1));
    }

    #[test]
    fn remove_last_card_keeps_monotonic_card_id_metadata() {
        let (mut state, temp_dir) =
            make_state(vec![datum_with_id(1, "Capital of Spain", "Madrid")]);

        state.remove_selected_card().unwrap();

        let saved = fs::read_to_string(temp_dir.join("deck.cosimo-deck")).unwrap();

        assert!(state.data.is_empty());
        assert_eq!(
            saved,
            "\
deck_version=2
next_card_id=2
:::
"
        );
        assert!(matches!(state.session.phase(), ReviewPhase::ReadyToStart));
        assert_eq!(state.selected_ready_index, None);
    }

    #[test]
    fn key_to_digit_handles_zero_through_four() {
        assert_eq!(key_to_digit(Some('0' as i32), None), Some(0));
        assert_eq!(key_to_digit(Some('1' as i32), None), Some(1));
        assert_eq!(key_to_digit(Some('2' as i32), None), Some(2));
        assert_eq!(key_to_digit(Some('3' as i32), None), Some(3));
        assert_eq!(key_to_digit(Some('4' as i32), None), Some(4));
        assert_eq!(key_to_digit(None, Some(48)), Some(0));
        assert_eq!(key_to_digit(None, Some(52)), Some(4));
        assert_eq!(key_to_digit(Some('x' as i32), None), None);
    }

    #[test]
    fn card_batch_shortcuts_match_requested_keys() {
        assert!(is_control_enter_key(Some('\r' as i32), None, true));
        assert!(is_control_enter_key(None, Some(13), true));
        assert!(!is_control_enter_key(Some('\r' as i32), None, false));
        assert!(is_plain_tab_key(Some(9), false, false));
        assert!(!is_plain_tab_key(Some(9), true, false));
        assert!(!is_plain_tab_key(Some(9), false, true));
        assert!(!is_plain_tab_key(Some(13), false, false));
        assert!(is_shift_tab_key(Some(9), false, true));
        assert!(!is_shift_tab_key(Some(9), false, false));
        assert!(!is_shift_tab_key(Some(9), true, true));
        assert!(!is_shift_tab_key(Some(13), false, true));
    }

    #[test]
    fn card_batch_tab_add_requires_both_fields() {
        assert!(card_batch_fields_are_complete("Prompt", "Response"));
        assert!(!card_batch_fields_are_complete("", "Response"));
        assert!(!card_batch_fields_are_complete("Prompt", ""));
        assert!(!card_batch_fields_are_complete("   ", "Response"));
        assert!(!card_batch_fields_are_complete("Prompt", "   "));
    }

    #[test]
    fn edit_revealed_card_key_accepts_e() {
        assert!(is_edit_revealed_card_key(Some('e' as i32), None));
        assert!(is_edit_revealed_card_key(Some('E' as i32), None));
        assert!(is_edit_revealed_card_key(None, Some(69)));
        assert!(!is_edit_revealed_card_key(Some('x' as i32), None));
    }

    #[test]
    fn reveal_response_key_accepts_space() {
        assert!(is_reveal_response_key(Some(' ' as i32), None));
        assert!(is_reveal_response_key(None, Some(32)));
        assert!(!is_reveal_response_key(Some('x' as i32), None));
    }

    #[test]
    fn ready_list_delete_key_detects_delete() {
        assert!(is_ready_list_delete_key(Some(127)));
        assert!(!is_ready_list_delete_key(Some(65)));
        assert!(!is_ready_list_delete_key(None));
    }

    #[test]
    fn ready_list_toggle_suspension_key_detects_control_s_only() {
        assert!(is_ready_list_toggle_suspension_key(
            Some('s' as i32),
            None,
            true
        ));
        assert!(is_ready_list_toggle_suspension_key(
            Some('S' as i32),
            None,
            true
        ));
        assert!(is_ready_list_toggle_suspension_key(None, Some(83), true));
        assert!(is_ready_list_toggle_suspension_key(None, Some(19), true));
        assert!(!is_ready_list_toggle_suspension_key(
            Some('s' as i32),
            None,
            false
        ));
        assert!(!is_ready_list_toggle_suspension_key(None, Some(83), false));
    }

    #[test]
    fn toggle_flag_key_detects_control_f_only() {
        assert!(is_toggle_flag_key(Some('f' as i32), None, true));
        assert!(is_toggle_flag_key(Some('F' as i32), None, true));
        assert!(is_toggle_flag_key(None, Some(70), true));
        assert!(is_toggle_flag_key(None, Some(6), true));
        assert!(!is_toggle_flag_key(Some('f' as i32), None, false));
        assert!(!is_toggle_flag_key(None, Some(70), false));
    }

    #[test]
    fn toggle_deferral_key_detects_control_d_only() {
        assert!(is_toggle_deferral_key(Some('d' as i32), None, true));
        assert!(is_toggle_deferral_key(Some('D' as i32), None, true));
        assert!(is_toggle_deferral_key(None, Some(68), true));
        assert!(is_toggle_deferral_key(None, Some(4), true));
        assert!(!is_toggle_deferral_key(Some('d' as i32), None, false));
        assert!(!is_toggle_deferral_key(None, Some(68), false));
    }

    #[test]
    fn toggle_review_audio_key_detects_control_p_only() {
        assert!(is_toggle_review_audio_key(Some('p' as i32), None, true));
        assert!(is_toggle_review_audio_key(Some('P' as i32), None, true));
        assert!(is_toggle_review_audio_key(None, Some(80), true));
        assert!(is_toggle_review_audio_key(None, Some(16), true));
        assert!(!is_toggle_review_audio_key(Some('p' as i32), None, false));
        assert!(!is_toggle_review_audio_key(None, Some(80), false));
    }

    #[test]
    fn ready_screen_refresh_key_detects_f5() {
        assert!(is_ready_screen_refresh_key(Some(344)));
        assert!(is_ready_screen_refresh_key(Some(116)));
        assert!(!is_ready_screen_refresh_key(Some(115)));
        assert!(!is_ready_screen_refresh_key(None));
    }

    #[test]
    fn ready_list_activation_opens_card_details() {
        let source = include_str!("main.rs");
        let removed_char_handler = concat!("ui.ready_cards.", "on_char(");
        let removed_key_up_handler = concat!("ui.ready_cards.", "on_key_up(");
        let removed_alt_enter_handler = concat!("is_ready_list_details", "_key(");

        assert!(source.contains("ui.ready_cards.on_item_double_clicked("));
        assert!(source.contains("ui.ready_cards.on_key_down("));
        assert!(!source.contains(removed_char_handler));
        assert!(!source.contains(removed_key_up_handler));
        assert!(!source.contains(removed_alt_enter_handler));
        assert!(source.contains("open_selected_card_details_from_ui(&ui_copy, &state_copy);"));
        assert!(
            source.contains("refresh_ready_screen_without_notification(&ui_copy, &state_copy);")
        );
    }

    #[test]
    fn ready_list_context_menu_contains_card_actions() {
        let card = datum_with_id(1, "Capital of Spain", "Madrid");
        let (mut state, _temp_dir) = make_state(vec![card.clone()]);

        assert_eq!(
            ready_card_context_menu_labels(&state),
            Some(ReadyCardContextMenuLabels {
                edit: english().edit_selected_card_menu_item(),
                remove: english().remove_selected_card_menu_item(),
                details: english().selected_card_details_menu_item(),
                toggle_suspension: format!(
                    "{}\tCtrl-S",
                    english().suspend_selected_card_menu_item()
                ),
                toggle_flag: english().flag_selected_card_menu_item(),
                toggle_deferral: english().defer_selected_card_until_tomorrow_menu_item(),
                toggle_reversal: Some(english().make_selected_card_reversible_menu_item()),
                toggle_reversal_exclusion: Some(
                    english().mark_selected_card_non_reversible_menu_item()
                ),
            })
        );

        state
            .store
            .set_datum_suspended(&DatumKey::for_datum(&card), true)
            .unwrap();
        assert_eq!(
            ready_card_context_menu_labels(&state)
                .unwrap()
                .toggle_suspension,
            format!("{}\tCtrl-S", english().unsuspend_selected_card_menu_item())
        );

        state
            .store
            .set_datum_flagged(&DatumKey::for_datum(&card), true)
            .unwrap();
        assert_eq!(
            ready_card_context_menu_labels(&state).unwrap().toggle_flag,
            english().unflag_selected_card_menu_item()
        );

        state.data[0] = state.data[0].clone().with_reversal_prohibited(true);
        assert_eq!(
            ready_card_context_menu_labels(&state)
                .unwrap()
                .toggle_reversal_exclusion,
            Some(english().allow_selected_card_reversal_menu_item())
        );
        assert_eq!(
            ready_card_context_menu_labels(&state)
                .unwrap()
                .toggle_reversal,
            None
        );
    }

    #[test]
    fn reverse_card_context_menu_omits_parent_reversal_exclusion_action() {
        let card = datum_with_id(1, "one", "uno");
        let (mut state, _temp_dir) = make_state(vec![card]);
        state.toggle_selected_card_reversal().unwrap();
        state.select_ready_index(1);

        let labels = ready_card_context_menu_labels(&state).unwrap();

        assert_eq!(
            labels.toggle_reversal,
            Some(english().remove_reverse_card_menu_item())
        );
        assert_eq!(labels.toggle_reversal_exclusion, None);
    }

    fn assert_ready_card_context_menu_mnemonics_are_unique(
        scope_name: &str,
        labels: &ReadyCardContextMenuLabels,
    ) {
        let mut menu_labels = vec![
            ("edit", labels.edit),
            ("remove", labels.remove),
            ("details", labels.details),
            ("toggle suspension", labels.toggle_suspension.as_str()),
            ("toggle flag", labels.toggle_flag),
            ("toggle deferral", labels.toggle_deferral),
        ];
        if let Some(label) = labels.toggle_reversal {
            menu_labels.push(("toggle reversal", label));
        }
        if let Some(label) = labels.toggle_reversal_exclusion {
            menu_labels.push(("toggle reversal exclusion", label));
        }
        assert_unique_mnemonics(scope_name, &menu_labels);
    }

    #[test]
    fn ready_list_context_menu_mnemonics_are_unique_for_dynamic_states() {
        for language in [
            localization::Language::English,
            localization::Language::Spanish,
            localization::Language::Galician,
        ] {
            let localizer = Localizer::new(language);
            let card = datum_with_id(1, "one", "uno");
            let card_key = DatumKey::for_datum(&card);

            let (mut state, _temp_dir) = make_state(vec![card.clone()]);
            state.localizer = localizer;
            assert_ready_card_context_menu_mnemonics_are_unique(
                "ready context menu for ordinary card",
                &ready_card_context_menu_labels(&state).unwrap(),
            );

            state.store.set_datum_suspended(&card_key, true).unwrap();
            assert_ready_card_context_menu_mnemonics_are_unique(
                "ready context menu for suspended card",
                &ready_card_context_menu_labels(&state).unwrap(),
            );
            state.store.set_datum_suspended(&card_key, false).unwrap();

            state.store.set_datum_flagged(&card_key, true).unwrap();
            assert_ready_card_context_menu_mnemonics_are_unique(
                "ready context menu for flagged card",
                &ready_card_context_menu_labels(&state).unwrap(),
            );
            state.store.set_datum_flagged(&card_key, false).unwrap();

            state
                .store
                .defer_datum_until(&card_key, 4_000_000_000)
                .unwrap();
            assert_ready_card_context_menu_mnemonics_are_unique(
                "ready context menu for deferred card",
                &ready_card_context_menu_labels(&state).unwrap(),
            );
            state.store.clear_datum_deferred(&card_key).unwrap();

            state.data[0] = state.data[0].clone().with_reversal_prohibited(true);
            assert_ready_card_context_menu_mnemonics_are_unique(
                "ready context menu for non-reversible card",
                &ready_card_context_menu_labels(&state).unwrap(),
            );

            let (mut state, _temp_dir) = make_state(vec![card]);
            state.localizer = localizer;
            state.toggle_selected_card_reversal().unwrap();
            state.select_ready_index(1);
            assert_ready_card_context_menu_mnemonics_are_unique(
                "ready context menu for reverse card",
                &ready_card_context_menu_labels(&state).unwrap(),
            );
        }
    }

    #[test]
    fn reversible_menu_availability_matches_selected_card_state() {
        let card = datum_with_id(1, "one", "uno");
        let (mut state, _temp_dir) = make_state(vec![card]);

        assert!(selected_ready_card_reversal_available(&state));
        assert!(selected_ready_card_reversal_exclusion_available(&state));

        state.data[0] = state.data[0].clone().with_reversal_prohibited(true);
        assert!(!selected_ready_card_reversal_available(&state));
        assert!(selected_ready_card_reversal_exclusion_available(&state));

        state.data[0] = state.data[0].clone().with_reversal_prohibited(false);
        state.toggle_selected_card_reversal().unwrap();
        state.select_ready_index(1);
        assert!(selected_ready_card_reversal_available(&state));
        assert!(!selected_ready_card_reversal_exclusion_available(&state));
    }

    #[test]
    fn audio_generation_menu_requires_complete_voice_metadata() {
        let card = datum_with_id(1, "one", "uno");
        let (mut state, _temp_dir) = make_state(vec![card]);

        assert!(!ready_screen::audio_generation_menu_enabled(&state));

        state.deck_metadata.set("prompt_voice", "Andika");
        state.deck_metadata.set("prompt_voice_engine", "onecore");
        state.deck_metadata.set("response_voice", "David");
        assert!(!ready_screen::audio_generation_menu_enabled(&state));

        state.deck_metadata.set("response_voice_engine", "sapi");
        assert!(ready_screen::audio_generation_menu_enabled(&state));

        state.deck_metadata.set_audio_generation_disabled(true);
        assert!(!ready_screen::audio_generation_menu_enabled(&state));
    }

    #[test]
    fn deck_bundle_export_path_adds_zip_extension_when_missing() {
        assert_eq!(
            deck_bundle_export_path("deck-backup")
                .file_name()
                .and_then(|name| name.to_str()),
            Some("deck-backup.zip")
        );
        assert_eq!(
            deck_bundle_export_path("deck-backup.archive")
                .file_name()
                .and_then(|name| name.to_str()),
            Some("deck-backup.archive")
        );
    }

    #[test]
    fn imported_deck_default_file_name_uses_apkg_stem() {
        assert_eq!(
            imported_deck_default_file_name(Path::new("Indonesian.apkg")),
            "Indonesian.cosimo-deck"
        );
        assert_eq!(
            imported_deck_default_file_name(Path::new(".apkg")),
            "imported.cosimo-deck"
        );
    }

    #[test]
    fn imported_deck_destination_path_adds_cosimo_deck_extension_when_missing() {
        assert_eq!(
            imported_deck_destination_path("imported")
                .file_name()
                .and_then(|name| name.to_str()),
            Some("imported.cosimo-deck")
        );
        assert_eq!(
            imported_deck_destination_path("imported.deck")
                .file_name()
                .and_then(|name| name.to_str()),
            Some("imported.deck")
        );
    }

    #[test]
    fn audio_generation_menu_updates_after_deck_metadata_save() {
        let temp_dir = unique_temp_dir("audio-generation-menu-metadata-save");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        fs::write(
            &deck_path,
            "\
deck_version=2
:::

one
---
uno
=1=
",
        )
        .unwrap();
        let mut state = AppState::load(&deck_path, &db_path).unwrap();
        assert!(!ready_screen::audio_generation_menu_enabled(&state));

        let mut metadata = state.deck_metadata.clone();
        metadata.set("prompt_voice", "Andika");
        metadata.set("prompt_voice_engine", "onecore");
        metadata.set("response_voice", "David");
        metadata.set("response_voice_engine", "sapi");
        state.update_deck_metadata(metadata).unwrap();

        assert!(ready_screen::audio_generation_menu_enabled(&state));

        let mut metadata = state.deck_metadata.clone();
        metadata.set_audio_generation_disabled(true);
        state.update_deck_metadata(metadata).unwrap();

        assert!(!ready_screen::audio_generation_menu_enabled(&state));
        let saved = fs::read_to_string(&deck_path).unwrap();
        assert!(saved.contains("audio_generation=disabled\n"));
    }

    #[test]
    fn delete_card_button_label_does_not_claim_a_shortcut() {
        let localizer = Localizer::new(localization::Language::Spanish);

        assert_eq!(
            localizer.ready_remove_selected_button(),
            "Eliminar seleccionada"
        );
        assert!(!localizer.ready_remove_selected_button().contains("Delete"));
        assert!(!localizer.ready_remove_selected_button().contains("Supr"));
        assert!(!localizer.ready_remove_selected_button().contains('&'));
    }

    #[test]
    fn delete_card_menu_item_keeps_the_actual_delete_accelerator() {
        let localizer = Localizer::new(localization::Language::Spanish);

        assert_eq!(
            localizer.remove_selected_card_menu_item(),
            "E&liminar carta seleccionada\tDelete"
        );
    }

    #[test]
    fn menu_localization_entries_cover_all_xrc_menu_items() {
        let xrc = include_str!("../ui/main.xrc");
        let item_labels =
            localized_menu_item_labels(Localizer::new(localization::Language::Spanish));
        let dynamic_recent_items = recent_deck_menu_item_names();
        let (item_parents, submenu_parents) = xrc_menu_parents(xrc);

        assert_eq!(
            item_labels.len(),
            item_parents.len() - dynamic_recent_items.len()
        );
        for item_label in item_labels {
            assert!(
                xrc.contains(&format!("name=\"{}\"", item_label.xrc_name)),
                "missing XRC menu item {}",
                item_label.xrc_name
            );
            assert_eq!(
                item_parents.get(item_label.xrc_name).map(String::as_str),
                Some(item_label.menu_xrc_name),
                "{} should belong to {} in XRC",
                item_label.xrc_name,
                item_label.menu_xrc_name
            );
            assert!(
                item_label.label.contains('\t')
                    || matches!(
                        item_label.xrc_name,
                        "menu_options"
                            | "menu_deck_properties"
                            | "menu_edit_deck_properties"
                            | "menu_sync_status"
                            | "menu_sync_remote_decks"
                            | "menu_sync_auth"
                            | "menu_sync_register_account"
                            | "menu_sync_invalidate_token"
                            | "menu_sync_invalidate_all_tokens"
                            | "menu_sync_delete_account"
                            | "menu_sync_upload"
                            | "menu_sync_download"
                            | "menu_start_scheduled"
                            | "menu_force_start"
                            | "menu_start_exam"
                            | "menu_start_quiz"
                            | "menu_generate_audio"
                            | "menu_remove_inoperative_audio"
                            | "menu_import_anki_deck"
                            | "menu_export_deck_bundle"
                            | "menu_export_deck_bundle_with_study_history"
                            | "menu_write_deck_bundle_backup"
                            | "menu_restore_deck_bundle"
                            | "menu_scrub_review_database"
                            | "menu_reclaim_database_space"
                            | "menu_prune_backups"
                            | "menu_wipe_backup_directory"
                            | "menu_quit"
                            | "menu_add_card_batch"
                            | "menu_resolve_duplicate_prompts"
                            | "menu_flag_all_shown_cards"
                            | "menu_unflag_all_shown_cards"
                            | "menu_suspend_all_shown_cards"
                            | "menu_unsuspend_all_shown_cards"
                            | "menu_make_eligible_cards_reversible"
                            | "menu_undo_last_bulk_reversal"
                            | "menu_toggle_card_suspension"
                            | "menu_review_flagged_cards"
                            | "menu_toggle_card_deferral"
                            | "menu_toggle_card_reversal_exclusion"
                            | "menu_sort_deck_order"
                            | "menu_sort_next_scheduled"
                            | "menu_sort_last_reviewed"
                            | "menu_sort_prompt_text"
                            | "menu_sort_response_text"
                            | "menu_sort_estimated_recall"
                            | "menu_sort_ascending"
                            | "menu_sort_descending"
                            | "menu_user_guide"
                            | "menu_report_bug"
                            | "menu_project_wiki"
                            | "menu_about"
                    ),
                "menu item {} should include its accelerator label when it has one",
                item_label.xrc_name
            );
        }
        for item_name in dynamic_recent_items {
            assert!(
                xrc.contains(&format!("name=\"{}\"", item_name)),
                "missing XRC recent deck item {item_name}",
            );
            assert_eq!(
                item_parents.get(item_name).map(String::as_str),
                Some("menu_recent_decks"),
                "recent deck item {item_name} should belong to the recent deck submenu",
            );
        }
        for submenu_label in
            localized_submenu_labels(Localizer::new(localization::Language::Spanish))
        {
            assert!(
                xrc.contains(&format!("name=\"{}\"", submenu_label.xrc_name)),
                "missing XRC submenu {}",
                submenu_label.xrc_name
            );
            assert_eq!(
                submenu_parents
                    .get(submenu_label.xrc_name)
                    .map(String::as_str),
                Some(submenu_label.menu_xrc_name),
                "{} should belong to {} in XRC",
                submenu_label.xrc_name,
                submenu_label.menu_xrc_name
            );
        }
    }

    #[test]
    fn xrc_sizer_items_do_not_use_literal_zero_flags() {
        let resources = [
            (
                "ui/card_batch_dialog.xrc",
                include_str!("../ui/card_batch_dialog.xrc"),
            ),
            (
                "ui/card_details_dialog.xrc",
                include_str!("../ui/card_details_dialog.xrc"),
            ),
            (
                "ui/card_editor_dialog.xrc",
                include_str!("../ui/card_editor_dialog.xrc"),
            ),
            (
                "ui/deck_properties_editor_dialog.xrc",
                include_str!("../ui/deck_properties_editor_dialog.xrc"),
            ),
            (
                "ui/duplicate_prompt_dialog.xrc",
                include_str!("../ui/duplicate_prompt_dialog.xrc"),
            ),
            (
                "ui/exam_grade_dialog.xrc",
                include_str!("../ui/exam_grade_dialog.xrc"),
            ),
            (
                "ui/exam_question_dialog.xrc",
                include_str!("../ui/exam_question_dialog.xrc"),
            ),
            (
                "ui/exam_setup_dialog.xrc",
                include_str!("../ui/exam_setup_dialog.xrc"),
            ),
            (
                "ui/exam_summary_dialog.xrc",
                include_str!("../ui/exam_summary_dialog.xrc"),
            ),
            (
                "ui/exam_timeout_dialog.xrc",
                include_str!("../ui/exam_timeout_dialog.xrc"),
            ),
            (
                "ui/quiz_question_dialog.xrc",
                include_str!("../ui/quiz_question_dialog.xrc"),
            ),
            (
                "ui/quiz_setup_dialog.xrc",
                include_str!("../ui/quiz_setup_dialog.xrc"),
            ),
            (
                "ui/sync_remote_decks_dialog.xrc",
                include_str!("../ui/sync_remote_decks_dialog.xrc"),
            ),
            ("ui/main.xrc", include_str!("../ui/main.xrc")),
            (
                "ui/options_dialog.xrc",
                include_str!("../ui/options_dialog.xrc"),
            ),
            (
                "ui/stats_dialog.xrc",
                include_str!("../ui/stats_dialog.xrc"),
            ),
        ];

        for (path, xrc) in resources {
            assert!(
                !xrc.contains("<flag>0</flag>"),
                "{path} contains an invalid XRC sizer flag; omit the flag element instead"
            );
        }
    }

    #[test]
    fn exam_setup_xrc_contains_exact_checkbox() {
        let xrc = include_str!("../ui/exam_setup_dialog.xrc");
        let checkbox = xrc
            .find("name=\"checkbox_exam_exact\"")
            .expect("exact checkbox should be present");
        let timed_checkbox = xrc
            .find("name=\"checkbox_exam_timed\"")
            .expect("timed checkbox should be present");
        let time_limit = xrc
            .find("name=\"text_exam_time_limit\"")
            .expect("time limit field should be present");
        let start_button = xrc
            .find("name=\"button_exam_setup_start\"")
            .expect("start button should be present");

        assert!(xrc.contains("<object class=\"wxCheckBox\" name=\"checkbox_exam_exact\">"));
        assert!(xrc.contains("<object class=\"wxCheckBox\" name=\"checkbox_exam_timed\">"));
        assert!(
            checkbox < start_button,
            "exact checkbox should appear before Start in dialog tab order"
        );
        assert!(
            timed_checkbox < time_limit && time_limit < start_button,
            "timed exam controls should appear before Start in dialog tab order"
        );
    }

    #[test]
    fn exam_timeout_dialog_requires_moving_to_next_button() {
        let xrc = include_str!("../ui/exam_timeout_dialog.xrc");
        let message = xrc
            .find("name=\"text_exam_timeout_message\"")
            .expect("timeout message should be present");
        let next = xrc
            .find("name=\"button_exam_timeout_next\"")
            .expect("timeout next button should be present");

        assert!(xrc.contains("wxTE_READONLY"));
        assert!(message < next);
        assert!(
            !xrc.contains("<default>1</default>"),
            "timeout dialog should not default the Next button"
        );
    }

    #[test]
    fn card_editor_xrc_contains_non_reversible_checkbox_before_buttons() {
        let xrc = include_str!("../ui/card_editor_dialog.xrc");
        let checkbox = xrc
            .find("name=\"checkbox_card_non_reversible\"")
            .expect("non-reversible checkbox should be present");
        let cancel_button = xrc
            .find("name=\"button_card_cancel\"")
            .expect("cancel button should be present");

        assert!(
            xrc.contains("<object class=\"wxCheckBox\" name=\"checkbox_card_non_reversible\">")
        );
        assert!(
            checkbox < cancel_button,
            "non-reversible checkbox should appear before buttons in dialog tab order"
        );
    }

    #[test]
    fn deck_metadata_editor_xrc_contains_manual_audio_checkbox_before_buttons() {
        let xrc = include_str!("../ui/deck_properties_editor_dialog.xrc");
        let checkbox = xrc
            .find("name=\"checkbox_deck_properties_manual_audio\"")
            .expect("manual audio checkbox should be present");
        let independent_copy_button = xrc
            .find("name=\"button_deck_properties_make_independent_copy\"")
            .expect("make independent copy button should be present");
        let cancel_button = xrc
            .find("name=\"button_deck_properties_cancel\"")
            .expect("cancel button should be present");

        assert!(xrc.contains(
            "<object class=\"wxCheckBox\" name=\"checkbox_deck_properties_manual_audio\">"
        ));
        assert!(xrc.contains("name=\"button_deck_properties_make_independent_copy\""));
        assert!(
            checkbox < cancel_button,
            "manual audio checkbox should appear before buttons in dialog tab order"
        );
        assert!(
            independent_copy_button < cancel_button,
            "make independent copy should appear before the standard dialog buttons"
        );
    }

    #[test]
    fn exam_question_answer_field_is_not_preceded_by_generic_answer_label() {
        let xrc = include_str!("../ui/exam_question_dialog.xrc");

        assert!(xrc.contains("name=\"text_exam_answer\""));
        assert!(
            !xrc.contains("label_exam_answer_heading"),
            "the answer edit field should use the question status as its accessible name"
        );
    }

    #[test]
    fn exam_question_prompt_and_answer_are_contiguous_in_tab_order() {
        let question_xrc = include_str!("../ui/exam_question_dialog.xrc");
        let question_prompt = question_xrc.find("name=\"text_exam_prompt\"").unwrap();
        let question_audio = question_xrc
            .find("name=\"button_exam_question_audio\"")
            .unwrap();
        let question_answer = question_xrc.find("name=\"text_exam_answer\"").unwrap();

        assert!(question_prompt < question_answer);
        assert!(
            question_answer < question_audio,
            "the audio button has Ctrl+P, so prompt and answer should be adjacent for Shift+Tab reading"
        );

        let grade_xrc = include_str!("../ui/exam_grade_dialog.xrc");
        assert!(
            !grade_xrc.contains("button_exam_grade_audio"),
            "exam grading should not expose response audio yet"
        );
    }

    #[test]
    fn top_level_menu_localization_matches_xrc_menus() {
        let xrc = include_str!("../ui/main.xrc");
        let top_level_labels =
            localized_top_level_menu_labels(Localizer::new(localization::Language::Spanish));

        assert!(xrc.contains("name=\"menu_file\""));
        assert!(xrc.contains("name=\"menu_deck\""));
        assert!(xrc.contains("name=\"menu_cards\""));
        assert!(xrc.contains("name=\"menu_study\""));
        assert!(xrc.contains("name=\"menu_tools\""));
        assert!(xrc.contains("name=\"menu_view\""));
        assert!(xrc.contains("name=\"menu_help\""));
        assert_eq!(top_level_labels.len(), 7);
        assert_eq!(top_level_labels[0].label, "&Archivo");
        assert_eq!(top_level_labels[1].label, "Ma&zo");
        assert_eq!(top_level_labels[2].label, "&Carta");
        assert_eq!(top_level_labels[3].label, "Est&udio");
        assert_eq!(top_level_labels[4].label, "&Herramientas");
        assert_eq!(top_level_labels[5].label, "&Ver");
        assert_eq!(top_level_labels[6].label, "A&yuda");
    }

    #[test]
    fn card_menu_groups_selected_card_actions_before_bulk_actions() {
        let xrc = include_str!("../ui/main.xrc");
        let expected_order = [
            "menu_add_card",
            "menu_add_card_batch",
            "menu_edit_card",
            "menu_remove_card",
            "menu_card_details",
            "menu_toggle_card_flag",
            "menu_toggle_card_suspension",
            "menu_toggle_card_deferral",
            "menu_flag_all_shown_cards",
            "menu_unflag_all_shown_cards",
            "menu_suspend_all_shown_cards",
            "menu_unsuspend_all_shown_cards",
            "menu_toggle_card_reversal",
            "menu_toggle_card_reversal_exclusion",
        ];

        let mut previous_position = 0;
        for item_name in expected_order {
            let position = xrc
                .find(&format!("name=\"{item_name}\""))
                .unwrap_or_else(|| panic!("missing Card menu item {item_name}"));
            assert!(
                position > previous_position,
                "{item_name} should appear after the previous Card menu item"
            );
            previous_position = position;
        }
    }

    fn mnemonic_key(label: &str) -> Option<char> {
        let label_without_accelerator = label.split('\t').next().unwrap_or(label);
        let mut chars = label_without_accelerator.chars();
        while let Some(character) = chars.next() {
            if character == '&' {
                match chars.next() {
                    Some('&') => continue,
                    Some(mnemonic) => return Some(mnemonic.to_lowercase().next().unwrap()),
                    None => return None,
                }
            }
        }
        None
    }

    fn accelerator_key(label: &str) -> Option<String> {
        label
            .split_once('\t')
            .map(|(_, accelerator)| accelerator.to_ascii_lowercase())
    }

    fn xrc_attribute(line: &str, attribute: &str) -> Option<String> {
        let needle = format!("{attribute}=\"");
        let start = line.find(&needle)? + needle.len();
        let end = line[start..].find('"')?;
        Some(line[start..start + end].to_string())
    }

    fn xrc_menu_parents(
        xrc: &str,
    ) -> (
        std::collections::HashMap<String, String>,
        std::collections::HashMap<String, String>,
    ) {
        #[derive(Debug)]
        struct XrcObject {
            class: String,
            name: Option<String>,
        }

        let mut stack: Vec<XrcObject> = Vec::new();
        let mut item_parents = std::collections::HashMap::new();
        let mut submenu_parents = std::collections::HashMap::new();

        for line in xrc.lines() {
            let line = line.trim();
            if line.starts_with("<object ") {
                let class = xrc_attribute(line, "class").unwrap_or_default();
                let name = xrc_attribute(line, "name");
                if class == "wxMenu" {
                    if let Some(menu_name) = name.as_deref()
                        && let Some(parent_menu) = stack
                            .iter()
                            .rev()
                            .find(|object| object.class == "wxMenu")
                            .and_then(|object| object.name.as_deref())
                    {
                        submenu_parents.insert(menu_name.to_string(), parent_menu.to_string());
                    }
                } else if class == "wxMenuItem" {
                    let parent_menu = stack
                        .iter()
                        .rev()
                        .find(|object| object.class == "wxMenu")
                        .and_then(|object| object.name.as_deref())
                        .expect("menu item should be inside a wxMenu");
                    let item_name = name.as_deref().expect("menu item should have a name");
                    item_parents.insert(item_name.to_string(), parent_menu.to_string());
                }
                stack.push(XrcObject { class, name });
            } else if line == "</object>" {
                stack.pop().expect("XRC object stack should not underflow");
            }
        }

        (item_parents, submenu_parents)
    }

    fn xrc_checkbox_names(xrc: &str) -> Vec<String> {
        xrc.lines()
            .map(str::trim)
            .filter(|line| line.starts_with("<object class=\"wxCheckBox\""))
            .filter_map(|line| xrc_attribute(line, "name"))
            .collect()
    }

    #[test]
    fn every_xrc_checkbox_gets_a_user_facing_accessible_name() {
        let xrc_files = [
            include_str!("../ui/card_editor_dialog.xrc"),
            include_str!("../ui/deck_properties_editor_dialog.xrc"),
            include_str!("../ui/exam_setup_dialog.xrc"),
            include_str!("../ui/options_dialog.xrc"),
        ];
        let runtime_sources = [
            include_str!("exam_dialog.rs"),
            include_str!("options_dialog.rs"),
            include_str!("review_dialogs.rs"),
        ];
        let compact_sources: String = runtime_sources
            .join("\n")
            .chars()
            .filter(|character| !character.is_whitespace())
            .collect();

        for checkbox_name in xrc_files.iter().flat_map(|xrc| xrc_checkbox_names(xrc)) {
            let pattern = format!(".{checkbox_name}.set_name(");
            assert!(
                compact_sources.contains(&pattern),
                "{checkbox_name} should set a user-facing accessible name whenever its label is localised at runtime",
            );
        }
    }

    #[test]
    fn ready_list_control_s_does_not_clash_with_menu_accelerators() {
        for language in [
            localization::Language::English,
            localization::Language::Spanish,
            localization::Language::Galician,
        ] {
            let localizer = Localizer::new(language);
            for item_label in localized_menu_item_labels(localizer) {
                assert_ne!(
                    accelerator_key(item_label.label).as_deref(),
                    Some("ctrl-s"),
                    "{} claims Ctrl-S and would prevent ready-list suspension",
                    item_label.xrc_name
                );
            }
        }
    }

    fn assert_unique_mnemonics(scope_name: &str, labels: &[(&str, &str)]) {
        let mut seen = std::collections::HashMap::new();
        for (name, label) in labels {
            let Some(mnemonic) = mnemonic_key(label) else {
                continue;
            };
            if let Some(previous_name) = seen.insert(mnemonic, *name) {
                panic!(
                    "{scope_name} mnemonic Alt-{} is shared by {previous_name} and {name}",
                    mnemonic.to_uppercase()
                );
            }
        }
    }

    fn assert_static_menu_mnemonics_are_unique(localizer: Localizer) {
        let mut menu_labels: std::collections::BTreeMap<
            &'static str,
            Vec<(&'static str, &'static str)>,
        > = std::collections::BTreeMap::new();

        for item_label in localized_menu_item_labels(localizer) {
            menu_labels
                .entry(item_label.menu_xrc_name)
                .or_default()
                .push((item_label.xrc_name, item_label.label));
        }
        for submenu_label in localized_submenu_labels(localizer) {
            menu_labels
                .entry(submenu_label.menu_xrc_name)
                .or_default()
                .push((submenu_label.xrc_name, submenu_label.label));
        }

        for (menu_name, labels) in menu_labels {
            assert_unique_mnemonics(menu_name, &labels);
        }
    }

    fn assert_cards_menu_mnemonics_are_unique(localizer: Localizer) {
        let common_labels = [
            ("add card", localizer.add_card_menu_item()),
            ("add card batch", localizer.add_card_batch_menu_item()),
            (
                "remove selected card",
                localizer.remove_selected_card_menu_item(),
            ),
            ("card details", localizer.selected_card_details_menu_item()),
            (
                "flag all shown cards",
                localizer.flag_all_shown_cards_menu_item(),
            ),
            (
                "unflag all shown cards",
                localizer.unflag_all_shown_cards_menu_item(),
            ),
            (
                "suspend all shown cards",
                localizer.suspend_all_shown_cards_menu_item(),
            ),
            (
                "unsuspend all shown cards",
                localizer.unsuspend_all_shown_cards_menu_item(),
            ),
        ];
        let scenarios = [
            (
                "cards menu",
                localizer.edit_selected_card_menu_item(),
                localizer.suspend_selected_card_menu_item(),
                localizer.flag_selected_card_menu_item(),
                localizer.defer_selected_card_until_tomorrow_menu_item(),
                localizer.make_selected_card_reversible_menu_item(),
                localizer.mark_selected_card_non_reversible_menu_item(),
            ),
            (
                "cards menu with alternate selected-card labels",
                localizer.edit_selected_card_menu_item(),
                localizer.unsuspend_selected_card_menu_item(),
                localizer.unflag_selected_card_menu_item(),
                localizer.clear_selected_card_deferral_menu_item(),
                localizer.remove_reverse_card_menu_item(),
                localizer.allow_selected_card_reversal_menu_item(),
            ),
            (
                "cards menu while reviewing",
                localizer.edit_current_card_menu_item(),
                localizer.suspend_selected_card_menu_item(),
                localizer.flag_current_card_menu_item(),
                localizer.defer_selected_card_until_tomorrow_menu_item(),
                localizer.make_selected_card_reversible_menu_item(),
                localizer.mark_selected_card_non_reversible_menu_item(),
            ),
            (
                "cards menu while reviewing with alternate labels",
                localizer.edit_current_card_menu_item(),
                localizer.unsuspend_selected_card_menu_item(),
                localizer.unflag_current_card_menu_item(),
                localizer.clear_selected_card_deferral_menu_item(),
                localizer.remove_reverse_card_menu_item(),
                localizer.allow_selected_card_reversal_menu_item(),
            ),
        ];

        for (scope_name, edit, suspension, flag, deferral, reversal, reversal_exclusion) in
            scenarios
        {
            let mut labels = common_labels.to_vec();
            labels.extend([
                ("edit card", edit),
                ("toggle selected-card suspension", suspension),
                ("toggle selected-card flag", flag),
                ("toggle selected-card deferral", deferral),
                ("toggle selected-card reversal", reversal),
                (
                    "toggle selected-card reversal exclusion",
                    reversal_exclusion,
                ),
            ]);
            assert_unique_mnemonics(scope_name, &labels);
        }
    }

    #[test]
    fn menu_mnemonics_are_unique_within_each_menu() {
        for language in [
            localization::Language::English,
            localization::Language::Spanish,
            localization::Language::Galician,
        ] {
            let localizer = Localizer::new(language);
            let top_level_names = [
                "file menu",
                "deck menu",
                "card menu",
                "study menu",
                "tools menu",
                "view menu",
                "help menu",
            ];
            let top_level_labels = localized_top_level_menu_labels(localizer);
            let top_level_mnemonics = top_level_labels
                .iter()
                .map(|label| (top_level_names[label.menu.position()], label.label))
                .collect::<Vec<_>>();
            assert_unique_mnemonics("top-level menus", &top_level_mnemonics);

            assert_static_menu_mnemonics_are_unique(localizer);
            assert_cards_menu_mnemonics_are_unique(localizer);
        }
    }

    #[test]
    fn menu_command_ids_do_not_clash_with_dialog_button_ids() {
        let dialog_ids = [ID_OK, wxdragon::id::ID_CANCEL, ID_YES, ID_NO];
        let menu_ids = [
            ("menu_open_deck", CosimoUi::xrc_id("menu_open_deck")),
            (
                "menu_import_anki_deck",
                CosimoUi::xrc_id("menu_import_anki_deck"),
            ),
            (
                "menu_scrub_review_database",
                CosimoUi::xrc_id("menu_scrub_review_database"),
            ),
            (
                "menu_reclaim_database_space",
                CosimoUi::xrc_id("menu_reclaim_database_space"),
            ),
            ("menu_prune_backups", CosimoUi::xrc_id("menu_prune_backups")),
            (
                "menu_wipe_backup_directory",
                CosimoUi::xrc_id("menu_wipe_backup_directory"),
            ),
            (
                "menu_generate_audio",
                CosimoUi::xrc_id("menu_generate_audio"),
            ),
            (
                "menu_remove_inoperative_audio",
                CosimoUi::xrc_id("menu_remove_inoperative_audio"),
            ),
            (
                "menu_export_deck_bundle",
                CosimoUi::xrc_id("menu_export_deck_bundle"),
            ),
            (
                "menu_export_deck_bundle_with_study_history",
                CosimoUi::xrc_id("menu_export_deck_bundle_with_study_history"),
            ),
            (
                "menu_write_deck_bundle_backup",
                CosimoUi::xrc_id("menu_write_deck_bundle_backup"),
            ),
            (
                "menu_restore_deck_bundle",
                CosimoUi::xrc_id("menu_restore_deck_bundle"),
            ),
            (
                "menu_start_scheduled",
                CosimoUi::xrc_id("menu_start_scheduled"),
            ),
            ("menu_force_start", CosimoUi::xrc_id("menu_force_start")),
            ("menu_start_exam", CosimoUi::xrc_id("menu_start_exam")),
            (
                "menu_deck_properties",
                CosimoUi::xrc_id("menu_deck_properties"),
            ),
            (
                "menu_edit_deck_properties",
                CosimoUi::xrc_id("menu_edit_deck_properties"),
            ),
            ("menu_sync_status", CosimoUi::xrc_id("menu_sync_status")),
            (
                "menu_sync_remote_decks",
                CosimoUi::xrc_id("menu_sync_remote_decks"),
            ),
            ("menu_sync_auth", CosimoUi::xrc_id("menu_sync_auth")),
            (
                "menu_sync_register_account",
                CosimoUi::xrc_id("menu_sync_register_account"),
            ),
            (
                "menu_sync_invalidate_token",
                CosimoUi::xrc_id("menu_sync_invalidate_token"),
            ),
            (
                "menu_sync_invalidate_all_tokens",
                CosimoUi::xrc_id("menu_sync_invalidate_all_tokens"),
            ),
            (
                "menu_sync_delete_account",
                CosimoUi::xrc_id("menu_sync_delete_account"),
            ),
            ("menu_sync_upload", CosimoUi::xrc_id("menu_sync_upload")),
            ("menu_sync_download", CosimoUi::xrc_id("menu_sync_download")),
            ("menu_quit", CosimoUi::xrc_id("menu_quit")),
            ("menu_add_card", CosimoUi::xrc_id("menu_add_card")),
            (
                "menu_add_card_batch",
                CosimoUi::xrc_id("menu_add_card_batch"),
            ),
            ("menu_edit_card", CosimoUi::xrc_id("menu_edit_card")),
            ("menu_remove_card", CosimoUi::xrc_id("menu_remove_card")),
            ("menu_card_details", CosimoUi::xrc_id("menu_card_details")),
            (
                "menu_resolve_duplicate_prompts",
                CosimoUi::xrc_id("menu_resolve_duplicate_prompts"),
            ),
            (
                "menu_toggle_card_suspension",
                CosimoUi::xrc_id("menu_toggle_card_suspension"),
            ),
            (
                "menu_toggle_card_flag",
                CosimoUi::xrc_id("menu_toggle_card_flag"),
            ),
            (
                "menu_flag_all_shown_cards",
                CosimoUi::xrc_id("menu_flag_all_shown_cards"),
            ),
            (
                "menu_unflag_all_shown_cards",
                CosimoUi::xrc_id("menu_unflag_all_shown_cards"),
            ),
            (
                "menu_suspend_all_shown_cards",
                CosimoUi::xrc_id("menu_suspend_all_shown_cards"),
            ),
            (
                "menu_unsuspend_all_shown_cards",
                CosimoUi::xrc_id("menu_unsuspend_all_shown_cards"),
            ),
            (
                "menu_toggle_card_deferral",
                CosimoUi::xrc_id("menu_toggle_card_deferral"),
            ),
            (
                "menu_toggle_card_reversal",
                CosimoUi::xrc_id("menu_toggle_card_reversal"),
            ),
            (
                "menu_toggle_card_reversal_exclusion",
                CosimoUi::xrc_id("menu_toggle_card_reversal_exclusion"),
            ),
            (
                "menu_make_eligible_cards_reversible",
                CosimoUi::xrc_id("menu_make_eligible_cards_reversible"),
            ),
            (
                "menu_undo_last_bulk_reversal",
                CosimoUi::xrc_id("menu_undo_last_bulk_reversal"),
            ),
            ("deferred_open_deck", DeferredMenuCommand::OpenDeck.id()),
            ("deferred_options", DeferredMenuCommand::Options.id()),
            (
                "deferred_deck_properties",
                DeferredMenuCommand::DeckProperties.id(),
            ),
            (
                "deferred_edit_deck_properties",
                DeferredMenuCommand::EditDeckProperties.id(),
            ),
            ("deferred_sync_status", DeferredMenuCommand::SyncStatus.id()),
            (
                "deferred_sync_remote_decks",
                DeferredMenuCommand::SyncRemoteDecks.id(),
            ),
            ("deferred_sync_login", DeferredMenuCommand::SyncLogin.id()),
            ("deferred_sync_logout", DeferredMenuCommand::SyncLogout.id()),
            (
                "deferred_sync_register_account",
                DeferredMenuCommand::SyncRegisterAccount.id(),
            ),
            (
                "deferred_sync_invalidate_token",
                DeferredMenuCommand::SyncInvalidateToken.id(),
            ),
            (
                "deferred_sync_invalidate_all_tokens",
                DeferredMenuCommand::SyncInvalidateAllTokens.id(),
            ),
            (
                "deferred_sync_delete_account",
                DeferredMenuCommand::SyncDeleteAccount.id(),
            ),
            ("deferred_sync_upload", DeferredMenuCommand::SyncUpload.id()),
            (
                "deferred_sync_download",
                DeferredMenuCommand::SyncDownload.id(),
            ),
            (
                "deferred_learning_dashboard",
                DeferredMenuCommand::LearningDashboard.id(),
            ),
            (
                "deferred_generate_audio",
                DeferredMenuCommand::GenerateAudio.id(),
            ),
            (
                "deferred_remove_inoperative_audio",
                DeferredMenuCommand::RemoveInoperativeAudio.id(),
            ),
            (
                "deferred_export_deck_bundle",
                DeferredMenuCommand::ExportDeckBundle.id(),
            ),
            (
                "deferred_export_deck_bundle_with_study_history",
                DeferredMenuCommand::ExportDeckBundleWithStudyHistory.id(),
            ),
            (
                "deferred_write_deck_bundle_backup",
                DeferredMenuCommand::WriteDeckBundleBackup.id(),
            ),
            (
                "deferred_restore_deck_bundle",
                DeferredMenuCommand::RestoreDeckBundle.id(),
            ),
            (
                "deferred_import_anki_deck",
                DeferredMenuCommand::ImportAnkiDeck.id(),
            ),
            ("deferred_add_card", DeferredMenuCommand::AddCard.id()),
            (
                "deferred_add_card_batch",
                DeferredMenuCommand::AddCardBatch.id(),
            ),
            ("deferred_edit_card", DeferredMenuCommand::EditCard.id()),
            (
                "deferred_card_details",
                DeferredMenuCommand::CardDetails.id(),
            ),
            ("deferred_user_guide", DeferredMenuCommand::UserGuide.id()),
            ("deferred_report_bug", DeferredMenuCommand::ReportBug.id()),
            (
                "deferred_project_wiki",
                DeferredMenuCommand::ProjectWiki.id(),
            ),
            ("deferred_about", DeferredMenuCommand::About.id()),
            ("deferred_remove_card", DeferredMenuCommand::RemoveCard.id()),
            (
                "deferred_resolve_duplicate_prompts",
                DeferredMenuCommand::ResolveDuplicatePrompts.id(),
            ),
            (
                "deferred_make_eligible_reversible",
                DeferredMenuCommand::MakeEligibleCardsReversible.id(),
            ),
            (
                "deferred_undo_bulk_reversal",
                DeferredMenuCommand::UndoBulkReversal.id(),
            ),
            (
                "deferred_flag_all_shown_cards",
                DeferredMenuCommand::FlagAllShownCards.id(),
            ),
            (
                "deferred_unflag_all_shown_cards",
                DeferredMenuCommand::UnflagAllShownCards.id(),
            ),
            (
                "deferred_suspend_all_shown_cards",
                DeferredMenuCommand::SuspendAllShownCards.id(),
            ),
            (
                "deferred_unsuspend_all_shown_cards",
                DeferredMenuCommand::UnsuspendAllShownCards.id(),
            ),
            (
                "deferred_scrub_database",
                DeferredMenuCommand::ScrubDatabase.id(),
            ),
            (
                "deferred_reclaim_database_space",
                DeferredMenuCommand::ReclaimDatabaseSpace.id(),
            ),
            (
                "deferred_prune_backups",
                DeferredMenuCommand::PruneBackups.id(),
            ),
            (
                "deferred_wipe_backup_directory",
                DeferredMenuCommand::WipeBackupDirectory.id(),
            ),
            (
                "deferred_recent_deck_base",
                DeferredMenuCommand::RecentDeck(RecentDeckSlot::new(0).unwrap()).id(),
            ),
            (
                "deferred_recent_deck_last",
                DeferredMenuCommand::RecentDeck(RecentDeckSlot::new(MAX_RECENT_DECKS - 1).unwrap())
                    .id(),
            ),
        ];

        for (name, id) in menu_ids {
            assert!(
                !dialog_ids.contains(&id),
                "{name} uses dialog button id {id}"
            );
        }
    }

    #[test]
    fn user_guide_file_urls_escape_spaces_and_accents() {
        assert_eq!(
            file_url_from_path(Path::new("/tmp/Cosimo Guide Á.html")),
            "file:///tmp/Cosimo%20Guide%20%C3%81.html"
        );
    }

    #[test]
    fn recent_deck_menu_labels_escape_ampersands() {
        assert_eq!(
            recent_deck_menu_item_label(
                RecentDeckSlot::new(0).unwrap(),
                Path::new("/tmp/one & two.cosimo-deck"),
            ),
            "1. /tmp/one && two.cosimo-deck"
        );
        assert_eq!(
            single_recent_deck_menu_item_label(Path::new("/tmp/one & two.cosimo-deck")),
            "/tmp/one && two.cosimo-deck"
        );
    }

    #[test]
    fn recent_deck_menu_slots_hide_unavailable_entries() {
        let slots = recent_deck_menu_slots(&[]);

        assert!(slots.iter().all(|slot| slot == &RecentDeckMenuSlot::Hide));
    }

    #[test]
    fn recent_deck_menu_slots_show_only_available_entries() {
        let slots = recent_deck_menu_slots(&[
            PathBuf::from("/tmp/one.cosimo-deck"),
            PathBuf::from("/tmp/two.cosimo-deck"),
        ]);

        assert_eq!(
            slots[0],
            RecentDeckMenuSlot::Show("1. /tmp/one.cosimo-deck".to_string())
        );
        assert_eq!(
            slots[1],
            RecentDeckMenuSlot::Show("2. /tmp/two.cosimo-deck".to_string())
        );
        assert!(
            slots[2..]
                .iter()
                .all(|slot| slot == &RecentDeckMenuSlot::Hide)
        );
    }

    #[test]
    fn recent_deck_menu_layout_uses_direct_item_for_one_recent_deck() {
        assert_eq!(
            recent_deck_menu_layout(&[PathBuf::from("/tmp/one.cosimo-deck")]),
            RecentDeckMenuLayout::DirectItem("/tmp/one.cosimo-deck".to_string())
        );
        assert!(matches!(
            recent_deck_menu_layout(&[
                PathBuf::from("/tmp/one.cosimo-deck"),
                PathBuf::from("/tmp/two.cosimo-deck"),
            ]),
            RecentDeckMenuLayout::Submenu(_)
        ));
    }

    #[test]
    fn recent_deck_paths_for_menu_omits_the_current_deck() {
        let config = AppConfig {
            recent_deck_paths: vec![
                PathBuf::from("/tmp/current.cosimo-deck"),
                PathBuf::from("/tmp/other.cosimo-deck"),
            ],
            last_open_deck_path: Some(PathBuf::from("/tmp/current.cosimo-deck")),
            ..AppConfig::default()
        };

        assert_eq!(
            recent_deck_paths_for_menu(&config, None),
            vec![PathBuf::from("/tmp/other.cosimo-deck")]
        );
        assert_eq!(
            recent_deck_paths_for_menu(&config, Some(Path::new("/tmp/other.cosimo-deck"))),
            vec![PathBuf::from("/tmp/current.cosimo-deck")]
        );
    }

    #[test]
    fn storage_error_messages_include_paths_and_recovery_guidance() {
        let deck_path = Path::new("/tmp/cosimo/deck.cosimo-deck");
        let review_db_path = Path::new("/tmp/cosimo/deck.reviews.sqlite3");
        let localizer = english();

        let read_error = localizer.could_not_read_deck_file(deck_path, "permission denied");
        assert!(read_error.contains("/tmp/cosimo/deck.cosimo-deck"));
        assert!(read_error.contains("No deck data were loaded"));
        assert!(read_error.contains("permission"));

        let parse_error = localizer.could_not_parse_deck(deck_path, "missing separator");
        assert!(parse_error.contains("/tmp/cosimo/deck.cosimo-deck"));
        assert!(parse_error.contains("The deck file was not changed"));
        assert!(parse_error.contains("separators"));

        let database_error =
            localizer.could_not_open_review_database(review_db_path, "database is locked");
        assert!(database_error.contains("/tmp/cosimo/deck.reviews.sqlite3"));
        assert!(database_error.contains("The deck file was not changed"));
        assert!(database_error.contains("locked"));
        assert!(database_error.contains("writable"));

        let write_error = localizer.could_not_write_path(deck_path, "access denied");
        assert!(write_error.contains("/tmp/cosimo/deck.cosimo-deck"));
        assert!(write_error.contains("The requested change was not saved"));
        assert!(write_error.contains("disk space"));
    }

    #[test]
    fn startup_load_error_reports_no_open_deck_and_recovery_paths() {
        let message = english().startup_state_load_error(
            "Could not open review database",
            Path::new("/tmp/cosimo/deck.cosimo-deck"),
            Path::new("/tmp/cosimo/deck.reviews.sqlite3"),
        );

        assert!(message.contains("No deck is currently open"));
        assert!(message.contains("Deck file: /tmp/cosimo/deck.cosimo-deck"));
        assert!(message.contains("Review database: /tmp/cosimo/deck.reviews.sqlite3"));
        assert!(message.contains("open another deck"));

        let recovery_message = english().startup_recovery_message(&message);
        assert!(recovery_message.contains("Could not open review database"));
        assert!(recovery_message.contains("open a deck"));
        assert!(recovery_message.contains("quit Cosimo"));
    }

    #[test]
    fn startup_failure_offers_open_deck_or_quit_recovery() {
        let source = include_str!("main.rs")
            .split("\nmod tests {")
            .next()
            .expect("runtime source before tests");
        assert!(source.contains("fn load_startup_state_or_recover("));
        assert!(source.contains("fn recover_startup_state_after_error("));
        assert!(
            source.contains("recover_startup_state_after_error(&ui, localizer, settings, error)")
        );
        assert!(source.contains("trace_debug_event(\"main: startup recovery shell shown\");"));
        assert!(source.contains("localizer.startup_recovery_message(&error)"));
        assert!(source.contains("show_yes_no_dialog_with_parent("));
        assert!(source.contains("fn open_startup_recovery_deck("));
        assert!(source.contains("show_open_deck_dialog(ui.frame, localizer"));
        assert!(source.contains("confirm_create_deck_dialog(ui.frame, localizer"));
        assert!(!source.contains("startup continued after migration failure"));
    }

    #[test]
    fn startup_recovery_prompt_is_localized_for_canonical_languages() {
        let english_message = Localizer::new(localization::Language::English)
            .startup_recovery_message("startup failed");
        assert!(english_message.contains("Choose Yes to open a deck"));

        let spanish_message = Localizer::new(localization::Language::Spanish)
            .startup_recovery_message("fallo de inicio");
        assert!(spanish_message.contains("abrir un mazo"));
        assert!(spanish_message.contains("salir de Cosimo"));

        let galician_message = Localizer::new(localization::Language::Galician)
            .startup_recovery_message("fallo de inicio");
        assert!(galician_message.contains("abrir un mazo"));
        assert!(galician_message.contains("saír de Cosimo"));
    }

    #[test]
    fn ready_screen_mnemonics_do_not_clash_with_top_level_menus() {
        for language in [
            localization::Language::English,
            localization::Language::Spanish,
            localization::Language::Galician,
        ] {
            let localizer = Localizer::new(language);
            assert_unique_mnemonics(
                "ready screen",
                &[
                    ("file menu", localizer.file_menu()),
                    ("deck menu", localizer.deck_menu()),
                    ("cards menu", localizer.cards_menu()),
                    ("study menu", localizer.study_menu()),
                    ("tools menu", localizer.tools_menu()),
                    ("view menu", localizer.view_menu()),
                    ("help menu", localizer.help_menu()),
                    ("filter text", localizer.ready_card_text_filter_label()),
                    ("add card", localizer.ready_add_card_button()),
                    (
                        "edit selected",
                        &format!("&{}", localizer.ready_edit_selected_button()),
                    ),
                    ("remove selected", localizer.ready_remove_selected_button()),
                    ("start", localizer.start_button()),
                    ("force start", localizer.force_start_button()),
                    ("review flagged", localizer.review_flagged_cards_button()),
                    ("start exam", localizer.start_exam_button()),
                    ("start quiz", localizer.start_quiz_button()),
                    ("quit", localizer.quit_button()),
                ],
            );
        }
    }

    #[test]
    fn ready_focus_policy_distinguishes_startup_from_continue() {
        let main_source = include_str!("main.rs");
        let ready_screen_source = include_str!("ready_screen.rs");
        let review_ui_source = include_str!("review_ui.rs");

        assert!(main_source.contains("ui.focus_ready_summary();"));
        assert!(ready_screen_source.contains("ReadyCardListFocusAvailability::for_has_cards"));
        assert!(review_ui_source.contains(
            "fn focus_ready_card_list(&self, availability: ReadyCardListFocusAvailability)"
        ));
        let old_helper_name = concat!("focus_ready_", "screen(");
        assert!(!main_source.contains(old_helper_name));
    }

    #[test]
    fn database_maintenance_preserves_ready_screen_focus() {
        let main_source = include_str!("main.rs");
        let ready_card_actions_source = include_str!("ready_card_actions.rs");
        let ready_screen_source = include_str!("ready_screen.rs");
        let review_ui_source = include_str!("review_ui.rs");

        assert!(review_ui_source.contains("enum ReadyScreenFocus"));
        assert!(review_ui_source.contains("fn current_ready_screen_focus("));
        assert!(review_ui_source.contains("fn restore_ready_screen_focus("));
        assert!(ready_screen_source.contains("fn render_ready_screen_after_maintenance("));
        assert!(main_source.contains("let previous_focus = ui.current_ready_screen_focus();"));
        assert!(
            ready_card_actions_source
                .contains("render_ready_screen_after_maintenance(ui, state, previous_focus)")
        );

        let scrub_start = main_source
            .find("fn scrub_review_database_from_ui(")
            .unwrap();
        let reclaim_start = main_source
            .find("fn reclaim_database_space_from_ui(")
            .unwrap();
        let dialog_helper_start = main_source.find("fn show_error_dialog(").unwrap();
        let scrub_source = &main_source[scrub_start..reclaim_start];
        let reclaim_source = &main_source[reclaim_start..dialog_helper_start];

        assert!(!scrub_source.contains("render_ready_screen_after_card_change"));
        assert!(!reclaim_source.contains("render_ready_screen_after_card_change"));
    }

    #[test]
    fn editing_deck_metadata_resyncs_menu_state_after_save() {
        let source = include_str!("deck_properties_actions.rs");
        let edit_start = source.find("fn edit_deck_properties_from_ui(").unwrap();
        let next_fn_start = source[edit_start + 1..]
            .find("\nfn ")
            .map(|offset| edit_start + 1 + offset)
            .unwrap_or(source.len());
        let edit_source = &source[edit_start..next_fn_start];

        assert!(edit_source.contains(
            "let update_result = { state.borrow_mut().update_deck_metadata(metadata) };"
        ));
        assert!(edit_source.contains("match update_result"));
        assert!(
            edit_source
                .contains("render_ready_screen_after_maintenance(ui, state, previous_focus);")
        );
        assert!(edit_source.contains("forget_local_deck_sync_state_for_uuid("));
        assert!(edit_source.contains("sync_cards_menu_state(ui, &state.borrow());"));
    }

    #[test]
    fn opening_a_menu_refreshes_dynamic_menu_state() {
        let main_source = include_str!("main.rs");
        let wire_start = main_source.find("fn wire_events(").unwrap();
        let next_fn_start = main_source[wire_start + 1..]
            .find("\nfn ")
            .map(|offset| wire_start + 1 + offset)
            .unwrap();
        let wire_source = &main_source[wire_start..next_fn_start];

        assert!(wire_source.contains("ui.frame.on_menu_opened(move |event|"));
        assert!(wire_source.contains("let state_ref = state_copy.borrow();"));
        assert!(wire_source.contains("sync_cards_menu_state(&ui_copy, &state_ref);"));
        assert!(wire_source.contains("sync_auth_menu_state(&ui_copy"));
        assert!(wire_source.contains("event.skip(true);"));
    }

    #[test]
    fn sync_menu_commands_are_deferred_out_of_menu_events() {
        let main_source = include_str!("main.rs");
        let installer_start = main_source
            .find("fn install_deferred_menu_posters(")
            .unwrap();
        let next_fn_start = main_source[installer_start + 1..]
            .find("\nfn ")
            .map(|offset| installer_start + 1 + offset)
            .unwrap();
        let installer_source = &main_source[installer_start..next_fn_start];

        assert!(installer_source.contains("ui.menu_sync_status"));
        assert!(installer_source.contains("DeferredMenuCommand::SyncStatus"));
        assert!(installer_source.contains("ui.menu_sync_remote_decks"));
        assert!(installer_source.contains("DeferredMenuCommand::SyncRemoteDecks"));
        assert!(installer_source.contains("ui.menu_sync_auth.on_click(move |event|"));
        assert!(installer_source.contains("current_sync_auth_menu_action().command().id()"));
        assert!(installer_source.contains("ui.menu_sync_register_account"));
        assert!(installer_source.contains("DeferredMenuCommand::SyncRegisterAccount"));
        assert!(installer_source.contains("ui.menu_sync_invalidate_token"));
        assert!(installer_source.contains("DeferredMenuCommand::SyncInvalidateToken"));
        assert!(installer_source.contains("ui.menu_sync_invalidate_all_tokens"));
        assert!(installer_source.contains("DeferredMenuCommand::SyncInvalidateAllTokens"));
        assert!(installer_source.contains("ui.menu_sync_delete_account"));
        assert!(installer_source.contains("DeferredMenuCommand::SyncDeleteAccount"));
        assert!(installer_source.contains("ui.menu_sync_upload"));
        assert!(installer_source.contains("DeferredMenuCommand::SyncUpload"));
        assert!(installer_source.contains("ui.menu_sync_download"));
        assert!(installer_source.contains("DeferredMenuCommand::SyncDownload"));
    }

    #[test]
    fn sync_logout_only_clears_local_auth_session() {
        let source = include_str!("sync_actions.rs");
        let function_start = source
            .find("pub(crate) fn log_out_of_sync_server_from_ui")
            .unwrap();
        let function_end = source[function_start + 1..]
            .find("\npub(crate) fn ")
            .map(|offset| function_start + 1 + offset)
            .unwrap();
        let function_source = &source[function_start..function_end];

        assert!(function_source.contains("clear_sync_auth_session"));
        assert!(!function_source.contains("revoke_auth_session"));
        assert!(!function_source.contains("revoke_all_auth_sessions"));
    }

    #[test]
    fn sync_status_clears_local_auth_after_invalid_saved_token() {
        let source = include_str!("sync_actions.rs");
        let function_start = source
            .find("pub(crate) fn open_sync_status_from_ui")
            .unwrap();
        let function_end = source[function_start + 1..]
            .find("\npub(crate) fn ")
            .map(|offset| function_start + 1 + offset)
            .unwrap();
        let function_source = &source[function_start..function_end];

        assert!(function_source.contains("remote_status_authentication_failed"));
        assert!(function_source.contains("quota_status_authentication_failed"));
        assert!(function_source.contains("clear_sync_auth_session"));
        assert!(function_source.contains("sync_auth_menu_state"));
    }

    #[test]
    fn sync_remote_decks_clears_local_auth_after_invalid_saved_token() {
        let source = include_str!("sync_actions.rs");
        let function_start = source
            .find("pub(crate) fn open_sync_remote_decks_from_ui")
            .unwrap();
        let function_end = source[function_start + 1..]
            .find("\npub(crate) fn ")
            .map(|offset| function_start + 1 + offset)
            .unwrap();
        let function_source = &source[function_start..function_end];

        assert!(function_source.contains("remote_decks.authentication_failed"));
        assert!(function_source.contains("clear_sync_auth_session"));
        assert!(function_source.contains("sync_auth_menu_state"));
    }

    #[test]
    fn sync_auth_menu_action_matches_stored_auth_state() {
        assert_eq!(
            sync_auth_menu_action_for_config(&AppConfig::default()),
            SyncAuthMenuAction::LogIn
        );
        assert_eq!(
            sync_auth_menu_action_for_config(&AppConfig {
                sync_owner_identity: Some("acct:auth@example.invalid".to_string()),
                ..AppConfig::default()
            }),
            SyncAuthMenuAction::LogIn
        );
        assert_eq!(
            sync_auth_menu_action_for_config(&AppConfig {
                sync_bearer_token: Some("cosimo_token".to_string()),
                ..AppConfig::default()
            }),
            SyncAuthMenuAction::LogIn
        );
        assert_eq!(
            sync_auth_menu_action_for_config(&AppConfig {
                sync_server_url: Some("http://127.0.0.1:8080/cosimo-sync.cgi".to_string()),
                sync_owner_identity: Some("acct:auth@example.invalid".to_string()),
                sync_bearer_token: Some("cosimo_token".to_string()),
                ..AppConfig::default()
            }),
            SyncAuthMenuAction::LogOut
        );
    }

    #[test]
    fn sync_runtime_uses_authenticated_owner_and_token() {
        let deck_uuid: cosimo::SyncUuid = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
        let config = AppConfig {
            sync_owner_identity: Some("acct:auth@example.invalid".to_string()),
            sync_bearer_token: Some("cosimo_token".to_string()),
            deck_sync_states: vec![
                LocalDeckSyncState::from_parts(
                    deck_uuid.clone(),
                    "http://sync.example.invalid",
                    Some("acct:deck@example.invalid"),
                    None,
                    None,
                    None,
                )
                .unwrap(),
            ],
            ..AppConfig::default()
        };

        assert_eq!(
            sync_owner_and_token_for_config(&config),
            Some((
                "acct:auth@example.invalid".to_string(),
                Some("cosimo_token".to_string())
            ))
        );
    }

    #[test]
    fn sync_runtime_rejects_deck_owner_without_auth_session() {
        let deck_uuid: cosimo::SyncUuid = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
        let config = AppConfig {
            deck_sync_states: vec![
                LocalDeckSyncState::from_parts(
                    deck_uuid.clone(),
                    "http://sync.example.invalid",
                    Some("acct:deck@example.invalid"),
                    None,
                    None,
                    None,
                )
                .unwrap(),
            ],
            ..AppConfig::default()
        };

        assert_eq!(sync_owner_and_token_for_config(&config), None);
    }

    #[test]
    fn sync_auth_menu_requires_complete_http_settings() {
        let token_without_server = AppConfig {
            sync_server_url: None,
            sync_owner_identity: Some("acct:auth@example.invalid".to_string()),
            sync_bearer_token: Some("cosimo_token".to_string()),
            ..AppConfig::default()
        };
        assert_eq!(
            sync_auth_menu_action_for_config(&token_without_server),
            SyncAuthMenuAction::LogIn
        );

        let complete = AppConfig {
            sync_server_url: Some("http://127.0.0.1:8080/cosimo-sync.cgi".to_string()),
            sync_owner_identity: Some("acct:auth@example.invalid".to_string()),
            sync_bearer_token: Some("cosimo_token".to_string()),
            ..AppConfig::default()
        };
        assert_eq!(
            sync_auth_menu_action_for_config(&complete),
            SyncAuthMenuAction::LogOut
        );
    }

    #[test]
    fn sync_runtime_settings_report_missing_login_separately() {
        let temp_dir = unique_temp_dir("sync-runtime-missing-login");
        let config_path = temp_dir.join("cosimo.ini");
        let config = AppConfig {
            sync_server_url: Some("http://127.0.0.1:8080/cosimo-sync.cgi".to_string()),
            ..AppConfig::default()
        };

        let error = match http_sync_runtime_settings_from_config(&config_path, &config, english()) {
            Ok(_) => panic!("missing login should reject sync runtime settings"),
            Err(error) => error,
        };

        assert_eq!(error, english().sync_login_missing_message().to_string());
    }

    #[test]
    fn sync_runtime_settings_do_not_require_deck_uuid() {
        let temp_dir = unique_temp_dir("sync-runtime-no-deck-uuid");
        let config_path = temp_dir.join("cosimo.ini");
        let config = AppConfig {
            sync_server_url: Some("http://127.0.0.1:8080/cosimo-sync.cgi".to_string()),
            sync_owner_identity: Some("acct:auth@example.invalid".to_string()),
            sync_bearer_token: Some("cosimo_token".to_string()),
            ..AppConfig::default()
        };

        let settings =
            http_sync_runtime_settings_from_config(&config_path, &config, english()).unwrap();

        assert_eq!(settings.server_url, "http://127.0.0.1:8080/cosimo-sync.cgi");
        assert_eq!(settings.owner_identity, "acct:auth@example.invalid");
        assert_eq!(settings.bearer_token.as_deref(), Some("cosimo_token"));
    }

    #[test]
    fn shared_remote_status_maps_to_sync_status_without_owner_history() {
        let deck_uuid: cosimo::SyncUuid = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
        let shared = SyncSharedDeckStatus::from_parts(
            deck_uuid.clone(),
            "17",
            cosimo::DeckVisibility::Public,
            "acct:owner@example.invalid",
            "sha256:content",
        )
        .unwrap();

        match remote_status_from_shared_result(Ok(shared)) {
            RemoteSyncStatusOverride::Found(status) => {
                assert_eq!(status.deck_uuid(), &deck_uuid);
                assert_eq!(status.revision(), "17");
                assert_eq!(status.owner_identity(), "acct:owner@example.invalid");
                assert!(!status.has_study_history());
                assert_eq!(status.last_upload_device_name(), None);
            }
            other => panic!("expected found shared status, got {other:?}"),
        }

        match remote_status_from_shared_result(Err(HttpSyncClientError::Download(
            SyncDownloadError::NotFound,
        ))) {
            RemoteSyncStatusOverride::NotFound => {}
            other => panic!("expected not found status, got {other:?}"),
        }
    }

    #[test]
    fn owner_read_error_keeps_shared_status_when_shared_inspect_succeeds() {
        let deck_uuid: cosimo::SyncUuid = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
        let shared = SyncSharedDeckStatus::from_parts(
            deck_uuid.clone(),
            "17",
            cosimo::DeckVisibility::Unlisted,
            "acct:owner@example.invalid",
            "sha256:content",
        )
        .unwrap();
        let shared_status = remote_status_from_shared_result(Ok(shared));

        match remote_status_after_owner_read_error("request timed out".to_string(), shared_status) {
            RemoteSyncStatusOverride::FoundWithOwnerReadError {
                status,
                owner_error,
            } => {
                assert_eq!(status.deck_uuid(), &deck_uuid);
                assert_eq!(status.revision(), "17");
                assert_eq!(status.owner_identity(), "acct:owner@example.invalid");
                assert_eq!(owner_error, "request timed out");
            }
            other => panic!("expected shared status with owner read error, got {other:?}"),
        }
    }

    #[test]
    fn owner_authentication_failure_keeps_shared_status_when_shared_inspect_succeeds() {
        let deck_uuid: cosimo::SyncUuid = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
        let shared = SyncSharedDeckStatus::from_parts(
            deck_uuid.clone(),
            "17",
            cosimo::DeckVisibility::Unlisted,
            "acct:owner@example.invalid",
            "sha256:content",
        )
        .unwrap();
        let shared_status = remote_status_from_shared_result(Ok(shared));

        match remote_status_after_owner_authentication_failure(shared_status) {
            RemoteSyncStatusOverride::FoundWithOwnerAuthenticationFailed { status } => {
                assert_eq!(status.deck_uuid(), &deck_uuid);
                assert_eq!(status.revision(), "17");
                assert_eq!(status.owner_identity(), "acct:owner@example.invalid");
            }
            other => {
                panic!("expected shared status with owner authentication error, got {other:?}")
            }
        }

        match remote_status_after_owner_authentication_failure(RemoteSyncStatusOverride::NotFound) {
            RemoteSyncStatusOverride::AuthenticationFailed => {}
            other => panic!("expected authentication failed status, got {other:?}"),
        }
    }

    #[test]
    fn current_deck_download_uses_shared_content_for_non_owner_decks() {
        let deck_uuid: cosimo::SyncUuid = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
        let owner_status = sync_remote_status_for_test(
            deck_uuid.clone(),
            "acct:owner@example.invalid",
            Some("sha256:history"),
        );
        let owner_plan = current_deck_sync_download_plan(
            english(),
            Some(RemoteSyncStatusOverride::Found(Box::new(owner_status))),
            Some("acct:owner@example.invalid"),
        )
        .unwrap();
        assert_eq!(
            owner_plan,
            CurrentDeckSyncDownloadPlan {
                download_kind: SyncDownloadKind::OwnerStudyHistory,
                requester_owner_identity: Some("acct:owner@example.invalid".to_string())
            }
        );

        let shared_status =
            sync_remote_status_for_test(deck_uuid.clone(), "acct:other@example.invalid", None);
        let shared_plan = current_deck_sync_download_plan(
            english(),
            Some(RemoteSyncStatusOverride::Found(Box::new(shared_status))),
            Some("acct:owner@example.invalid"),
        )
        .unwrap();
        assert_eq!(
            shared_plan,
            CurrentDeckSyncDownloadPlan {
                download_kind: SyncDownloadKind::SharedContent,
                requester_owner_identity: None
            }
        );

        let shared_with_owner_error =
            sync_remote_status_for_test(deck_uuid, "acct:owner@example.invalid", None);
        let shared_fallback_plan = current_deck_sync_download_plan(
            english(),
            Some(RemoteSyncStatusOverride::FoundWithOwnerReadError {
                status: Box::new(shared_with_owner_error),
                owner_error: "request timed out".to_string(),
            }),
            Some("acct:owner@example.invalid"),
        )
        .unwrap();
        assert_eq!(
            shared_fallback_plan,
            CurrentDeckSyncDownloadPlan {
                download_kind: SyncDownloadKind::SharedContent,
                requester_owner_identity: None
            }
        );
    }

    #[test]
    fn sync_upload_preflight_rejects_remote_owner_mismatch_before_bundling() {
        let deck_uuid: cosimo::SyncUuid = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
        let other_owner =
            sync_remote_status_for_test(deck_uuid.clone(), "acct:other@example.invalid", None);
        let error = sync_upload_preflight_for_remote_owner(
            english(),
            Some(RemoteSyncStatusOverride::Found(Box::new(other_owner))),
            None,
            "acct:current@example.invalid",
        )
        .unwrap_err();
        assert!(error.contains("@other@example.invalid"));
        assert!(error.contains("@current@example.invalid"));

        let current_owner =
            sync_remote_status_for_test(deck_uuid.clone(), "acct:current@example.invalid", None);
        assert_eq!(
            sync_upload_preflight_for_remote_owner(
                english(),
                Some(RemoteSyncStatusOverride::Found(Box::new(current_owner))),
                Some("17"),
                "acct:current@example.invalid",
            )
            .unwrap(),
            SyncUploadPreflight::Ready
        );
        assert_eq!(
            sync_upload_preflight_for_remote_owner(
                english(),
                Some(RemoteSyncStatusOverride::NotFound),
                Some("17"),
                "acct:current@example.invalid",
            )
            .unwrap(),
            SyncUploadPreflight::Ready
        );

        let shared_with_owner_error =
            sync_remote_status_for_test(deck_uuid.clone(), "acct:current@example.invalid", None);
        let error = sync_upload_preflight_for_remote_owner(
            english(),
            Some(RemoteSyncStatusOverride::FoundWithOwnerReadError {
                status: Box::new(shared_with_owner_error),
                owner_error: "request timed out".to_string(),
            }),
            Some("17"),
            "acct:current@example.invalid",
        )
        .unwrap_err();
        assert!(error.contains("request timed out"));

        let shared_with_owner_authentication_failure =
            sync_remote_status_for_test(deck_uuid, "acct:current@example.invalid", None);
        let error = sync_upload_preflight_for_remote_owner(
            english(),
            Some(
                RemoteSyncStatusOverride::FoundWithOwnerAuthenticationFailed {
                    status: Box::new(shared_with_owner_authentication_failure),
                },
            ),
            Some("17"),
            "acct:current@example.invalid",
        )
        .unwrap_err();
        assert!(error.contains("Saved sync login is no longer valid"));
    }

    #[test]
    fn sync_upload_preflight_reports_stale_remote_revision_conflict() {
        let deck_uuid: cosimo::SyncUuid = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
        let current_owner =
            sync_remote_status_for_test(deck_uuid, "acct:current@example.invalid", None);

        assert_eq!(
            sync_upload_preflight_for_remote_owner(
                english(),
                Some(RemoteSyncStatusOverride::Found(Box::new(current_owner))),
                Some("12"),
                "acct:current@example.invalid",
            )
            .unwrap(),
            SyncUploadPreflight::Conflict(SyncUploadConflict {
                local_expected_revision: Some("12".to_string()),
                remote_revision: Some("17".to_string())
            })
        );
    }

    #[test]
    fn local_sync_revision_for_upload_reads_matching_owner_slot() {
        let deck_uuid: cosimo::SyncUuid = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
        let matching_state = LocalDeckSyncState::from_parts(
            deck_uuid.clone(),
            "https://sync.example.invalid",
            Some("acct:current@example.invalid"),
            Some("17"),
            Some("sha256:local"),
            None,
        )
        .unwrap();
        let other_owner_state = LocalDeckSyncState::from_parts(
            deck_uuid.clone(),
            "https://sync.example.invalid",
            Some("acct:other@example.invalid"),
            Some("23"),
            Some("sha256:other"),
            None,
        )
        .unwrap();
        let config = AppConfig {
            deck_sync_states: vec![other_owner_state, matching_state],
            ..AppConfig::default()
        };

        assert_eq!(
            local_sync_revision_for_upload(
                &config,
                &deck_uuid,
                "https://sync.example.invalid",
                "acct:current@example.invalid",
            )
            .as_deref(),
            Some("17")
        );
    }

    fn sync_remote_status_for_test(
        deck_uuid: cosimo::SyncUuid,
        owner_identity: &str,
        study_history_hash: Option<&str>,
    ) -> SyncRemoteDeckStatus {
        let shared = SyncSharedDeckStatus::from_parts(
            deck_uuid,
            "17",
            cosimo::DeckVisibility::Public,
            owner_identity,
            "sha256:content",
        )
        .unwrap();
        SyncRemoteDeckStatus::from_shared_status(shared, study_history_hash, None).unwrap()
    }

    #[test]
    fn ready_summary_render_uses_non_destructive_text_sync() {
        let source = include_str!("ready_screen.rs");

        assert!(source.contains("let ready_summary = ready_summary_render_model("));
        assert!(source.contains("sync_cycle_summary_text(ui, &ready_summary.text);"));
        let destructive_update = concat!(
            "ui.cycle_summary.set_value(",
            "&ready_to_start_summary_text("
        );
        assert!(!source.contains(destructive_update));
        assert!(
            source
                .contains("let insertion_point = ui\n        .cycle_summary\n        .has_focus()")
        );
    }

    #[test]
    fn text_control_value_comparison_tolerates_windows_line_endings() {
        assert!(text_control_value_matches("one\ntwo", "one\ntwo"));
        assert!(text_control_value_matches("one\r\ntwo", "one\ntwo"));
        assert!(!text_control_value_matches("one\nthree", "one\ntwo"));
    }

    #[test]
    fn ready_screen_refresh_timer_is_installed() {
        let main_source = include_str!("main.rs");
        let ready_screen_source = include_str!("ready_screen.rs");

        assert!(ready_screen_source.contains("const READY_REFRESH_INTERVAL_MS: i32 = 60_000;"));
        assert!(main_source.contains(
            "install_ready_refresh_timer(&ui, Rc::clone(&state), Rc::clone(&resident_tray));"
        ));
        assert!(ready_screen_source.contains("Rc<RefCell<Option<Timer<Panel>>>>"));
        assert!(ready_screen_source.contains("let timer = Timer::new(&ui.root_panel);"));
        assert!(ready_screen_source.contains("timer.on_tick(move |_| {"));
        assert!(ready_screen_source.contains("refresh_ready_screen_after_timed_refresh("));
        assert!(ready_screen_source.contains("if !state.borrow().session.is_ready()"));
        assert!(
            ready_screen_source
                .contains("state.borrow_mut().normalize_ready_selection_for_filter();")
        );
    }

    #[test]
    fn ready_screen_refresh_key_is_bound_to_ready_controls() {
        let source = include_str!("ready_screen.rs");

        assert!(source.contains("fn install_ready_refresh_key_handlers"));
        assert!(source.contains("ready_refresh_key_disposition("));
        assert!(source.contains("refresh_ready_screen_without_notification(&ui, &state);"));
        assert!(source.contains("bind_ready_refresh_key_handler(ui.cycle_summary"));
        assert!(!source.contains("bind_ready_refresh_key_handler(ui.ready_cards"));
        assert!(source.contains("bind_ready_refresh_key_handler(ui.ready_card_text_filter"));
        assert!(source.contains("bind_ready_refresh_key_handler(ui.ready_card_filter_choice"));
        assert!(source.contains("bind_ready_refresh_key_handler(ui.ready_add_card_button"));
        assert!(source.contains("bind_ready_refresh_key_handler(ui.ready_edit_card_button"));
        assert!(source.contains("bind_ready_refresh_key_handler(ui.ready_remove_card_button"));
        assert!(source.contains("bind_ready_refresh_key_handler(ui.ready_audio_button"));
        assert!(source.contains("bind_ready_refresh_key_handler(ui.start_cycle_button"));
        assert!(source.contains("bind_ready_refresh_key_handler(ui.force_start_cycle_button"));
        assert!(source.contains("bind_ready_refresh_key_handler(ui.review_flagged_cards_button"));
        assert!(source.contains("bind_ready_refresh_key_handler(ui.start_exam_button"));
        assert!(source.contains("bind_ready_refresh_key_handler(ui.start_quiz_button"));
        assert!(source.contains("bind_ready_refresh_key_handler(ui.start_quit_button"));
    }

    #[test]
    fn destructive_menu_confirmations_are_deferred_out_of_menu_events() {
        for command in [
            DeferredMenuCommand::OpenDeck,
            DeferredMenuCommand::Options,
            DeferredMenuCommand::DeckProperties,
            DeferredMenuCommand::EditDeckProperties,
            DeferredMenuCommand::LearningDashboard,
            DeferredMenuCommand::AddCard,
            DeferredMenuCommand::AddCardBatch,
            DeferredMenuCommand::EditCard,
            DeferredMenuCommand::RestoreDeckBundle,
            DeferredMenuCommand::UserGuide,
            DeferredMenuCommand::ReportBug,
            DeferredMenuCommand::ProjectWiki,
            DeferredMenuCommand::About,
            DeferredMenuCommand::RemoveCard,
            DeferredMenuCommand::ResolveDuplicatePrompts,
            DeferredMenuCommand::MakeEligibleCardsReversible,
            DeferredMenuCommand::UndoBulkReversal,
            DeferredMenuCommand::FlagAllShownCards,
            DeferredMenuCommand::UnflagAllShownCards,
            DeferredMenuCommand::SuspendAllShownCards,
            DeferredMenuCommand::UnsuspendAllShownCards,
            DeferredMenuCommand::RemoveInoperativeAudio,
            DeferredMenuCommand::ScrubDatabase,
            DeferredMenuCommand::ReclaimDatabaseSpace,
            DeferredMenuCommand::WipeBackupDirectory,
        ] {
            assert_eq!(DeferredMenuCommand::from_id(command.id()), Some(command));
            assert_eq!(
                crate::menu_dispatch::deferred_menu_event_disposition(command.id()),
                crate::menu_dispatch::DeferredMenuEventDisposition::Dispatch(command)
            );
        }
        for index in 0..MAX_RECENT_DECKS {
            let command = DeferredMenuCommand::RecentDeck(RecentDeckSlot::new(index).unwrap());
            assert_eq!(DeferredMenuCommand::from_id(command.id()), Some(command));
            assert_eq!(
                crate::menu_dispatch::deferred_menu_event_disposition(command.id()),
                crate::menu_dispatch::DeferredMenuEventDisposition::Dispatch(command)
            );
        }
        assert_eq!(
            crate::menu_dispatch::deferred_menu_event_disposition(32_999),
            crate::menu_dispatch::DeferredMenuEventDisposition::Skip
        );
    }

    #[test]
    fn ready_list_keeps_native_label_and_user_facing_accessible_name() {
        let ui_source = include_str!("review_ui.rs");
        let ready_screen_source = include_str!("ready_screen.rs");
        let xrc = include_str!("../ui/main.xrc");

        assert!(xrc.contains("name=\"label_ready_cards_heading\""));
        assert!(ui_source.contains("ready_cards_heading: xrc.label_ready_cards_heading"));
        assert!(ui_source.contains(
            "self.ready_cards_heading\n            .set_label(localizer.ready_cards_heading());"
        ));
        assert!(ui_source.contains("self.ready_cards.set_name(localizer.ready_cards_heading());"));
        assert!(ready_screen_source.contains("ready_cards_heading_visible: false,"));
        assert!(
            ready_screen_source.contains(
                "ui.ready_cards_heading\n        .show(model.ready_cards_heading_visible);"
            )
        );
    }

    #[test]
    fn cards_menu_edit_item_is_contextual() {
        let ready_action = EditCardMenuAction::for_phase(ReviewPhase::ReadyToStart);
        assert_eq!(ready_action, EditCardMenuAction::SelectedReadyCard);
        assert_eq!(
            ready_action.label(english()),
            english().edit_selected_card_menu_item()
        );
        assert!(ready_action.is_enabled(true));
        assert!(!ready_action.is_enabled(false));

        let prompt_action = EditCardMenuAction::for_phase(ReviewPhase::Prompt);
        assert_eq!(prompt_action, EditCardMenuAction::DisabledDuringPrompt);
        assert_eq!(
            prompt_action.label(english()),
            english().edit_current_card_menu_item()
        );
        assert!(!prompt_action.is_enabled(true));

        let response_action = EditCardMenuAction::for_phase(ReviewPhase::Response {
            guess: PromptGuess::ProbablyKnow,
        });
        assert_eq!(response_action, EditCardMenuAction::RevealedReviewCard);
        assert_eq!(
            response_action.label(english()),
            english().edit_current_card_menu_item()
        );
        assert!(response_action.is_enabled(false));
    }

    #[test]
    fn card_flag_labels_are_contextual() {
        let card = datum_with_id(1, "Capital of Spain", "Madrid");
        let (mut state, _temp_dir) = make_state(vec![card.clone()]);

        assert_eq!(
            current_or_selected_card_flag_menu_label(&state),
            english().flag_selected_card_menu_item()
        );
        state
            .store
            .set_datum_flagged(&DatumKey::for_datum(&card), true)
            .unwrap();
        assert_eq!(
            current_or_selected_card_flag_menu_label(&state),
            english().unflag_selected_card_menu_item()
        );

        state.start_cycle();
        assert_eq!(
            current_or_selected_card_flag_menu_label(&state),
            english().unflag_current_card_menu_item()
        );
        assert_eq!(
            current_card_flag_button_label(&state),
            english().unflag_card_button_label()
        );
    }

    #[test]
    fn revealed_card_edit_restores_previous_focus() {
        let main_source = include_str!("main.rs");
        let ready_card_actions_source = include_str!("ready_card_actions.rs");
        let review_ui_source = include_str!("review_ui.rs");

        assert!(review_ui_source.contains("enum RevealedCardFocus"));
        assert!(review_ui_source.contains("fn current_revealed_card_focus("));
        assert!(review_ui_source.contains("fn restore_revealed_card_focus("));
        assert!(
            ready_card_actions_source
                .contains("let previous_focus = ui.current_revealed_card_focus();")
        );
        assert!(
            ready_card_actions_source.contains("ui.restore_revealed_card_focus(previous_focus);")
        );
        let old_helper_name = concat!("focus_feedback_", "flow(");
        assert!(!main_source.contains(old_helper_name));
    }

    #[test]
    fn review_flag_buttons_follow_ratings_in_tab_order() {
        let xrc = include_str!("../ui/main.xrc");

        let last_guess = xrc.find("name=\"button_guess_4\"").unwrap();
        let guess_flag_button = xrc.find("name=\"button_guess_flag_card\"").unwrap();
        let last_rating = xrc.find("name=\"button_feedback_4\"").unwrap();
        let feedback_flag_button = xrc.find("name=\"button_feedback_flag_card\"").unwrap();
        let edit_button = xrc.find("name=\"button_feedback_edit_card\"").unwrap();

        assert!(last_guess < guess_flag_button);
        assert!(last_rating < feedback_flag_button);
        assert!(feedback_flag_button < edit_button);
    }

    #[test]
    fn visual_review_layout_groups_ratings_without_changing_tab_order() {
        let xrc = include_str!("../ui/main.xrc");
        let guess_end = xrc.find("name=\"button_guess_0\"").unwrap();
        let guess_1 = xrc.find("name=\"button_guess_1\"").unwrap();
        let guess_2 = xrc.find("name=\"button_guess_2\"").unwrap();
        let guess_3 = xrc.find("name=\"button_guess_3\"").unwrap();
        let guess_4 = xrc.find("name=\"button_guess_4\"").unwrap();
        let guess_flag = xrc.find("name=\"button_guess_flag_card\"").unwrap();
        let guess_defer = xrc.find("name=\"button_guess_defer_card\"").unwrap();
        let response_text = xrc.find("name=\"text_response\"").unwrap();
        let audio_button = xrc.find("name=\"button_review_audio\"").unwrap();
        let feedback_end = xrc.find("name=\"button_feedback_0\"").unwrap();
        let feedback_1 = xrc.find("name=\"button_feedback_1\"").unwrap();
        let feedback_2 = xrc.find("name=\"button_feedback_2\"").unwrap();
        let feedback_3 = xrc.find("name=\"button_feedback_3\"").unwrap();
        let feedback_4 = xrc.find("name=\"button_feedback_4\"").unwrap();
        let feedback_flag = xrc.find("name=\"button_feedback_flag_card\"").unwrap();
        let feedback_edit = xrc.find("name=\"button_feedback_edit_card\"").unwrap();

        assert!(
            guess_end < guess_1
                && guess_1 < guess_2
                && guess_2 < guess_3
                && guess_3 < guess_4
                && guess_4 < guess_flag
                && guess_flag < guess_defer
        );
        assert!(
            feedback_end < feedback_1
                && feedback_1 < feedback_2
                && feedback_2 < feedback_3
                && feedback_3 < feedback_4
                && feedback_4 < feedback_flag
                && feedback_flag < feedback_edit
        );
        assert!(response_text < audio_button && audio_button < feedback_end);
        assert!(
            xrc.match_indices("<object class=\"wxGridBagSizer\">")
                .count()
                >= 2
        );
        assert!(!xrc.contains("<growablecols>0,1,2</growablecols>"));
        assert!(xrc.match_indices("<cellpos>0,0</cellpos>").count() >= 2);
        assert!(xrc.match_indices("<cellpos>1,2</cellpos>").count() >= 1);
        assert!(xrc.match_indices("<cellspan>1,2</cellspan>").count() >= 1);
    }

    #[test]
    fn visual_accessibility_keeps_native_colours_and_enlarges_core_controls() {
        let xrc = include_str!("../ui/main.xrc");
        let stats_xrc = include_str!("../ui/stats_dialog.xrc");
        let card_details_xrc = include_str!("../ui/card_details_dialog.xrc");
        let source = include_str!("main.rs");
        let review_dialogs = include_str!("review_dialogs.rs");

        assert!(xrc.match_indices("<size>-1,150</size>").count() >= 2);
        assert!(!xrc.contains("<fg>"));
        assert!(!xrc.contains("<bg>"));
        assert!(!stats_xrc.contains("<fg>"));
        assert!(!stats_xrc.contains("<bg>"));
        assert!(!card_details_xrc.contains("<fg>"));
        assert!(!card_details_xrc.contains("<bg>"));
        assert!(stats_xrc.contains("<size>820,560</size>"));
        assert!(card_details_xrc.contains("<size>900,680</size>"));
        assert!(source.contains("ui.apply_visual_accessibility_style();"));
        assert!(source.contains("increase_font_to_at_least(self.ready_cards, 12);"));
        assert!(source.contains("increase_font_to_at_least(text, 12);"));
        assert!(source.contains("button.set_min_size(Size::new(-1, 40));"));
        assert!(source.contains("button.set_min_size(Size::new(250, 40));"));
        assert!(review_dialogs.contains("fn apply_text_report_visual_style("));
        assert!(review_dialogs.contains("increase_font_to_at_least(text, 12);"));
        assert!(review_dialogs.contains("button.set_min_size(Size::new(120, 40));"));
    }

    #[test]
    fn windows_build_resource_keeps_version_info_and_manifest() {
        let build_script = include_str!("../build.rs");
        let windows_block = build_script
            .split("#[cfg(windows)]")
            .nth(1)
            .expect("build script has a Windows resource block");

        assert!(windows_block.contains("winres::WindowsResource::new()"));
        assert!(windows_block.contains("set_manifest_file(\"app.manifest\")"));
        assert!(windows_block.contains("set(\"ProductName\", \"Cosimo\")"));
        assert!(windows_block.contains("set(\"OriginalFilename\", \"cosimo.exe\")"));
        assert!(
            !windows_block.contains("set_resource_file("),
            "set_resource_file replaces winres-generated version metadata"
        );
    }

    #[test]
    fn loaded_ready_status_stays_short_for_low_vision_layout() {
        let status = english().loaded_cards_status(17, "Ready.");

        assert_eq!(status, "Loaded 17 cards. Ready.");
        assert!(!status.contains("Deck:"));
        assert!(!status.contains("Database:"));
    }

    #[test]
    fn ready_list_update_appends_new_cards_without_rebuilding() {
        let current = vec!["Capital of Spain".to_string()];
        let expected = vec![
            "Capital of Spain".to_string(),
            "Capital of France".to_string(),
        ];

        assert_eq!(
            ready_list_update(&current, &expected),
            ReadyListUpdate::Append("Capital of France".to_string())
        );
    }

    #[test]
    fn ready_list_update_deletes_removed_cards_without_rebuilding() {
        let current = vec![
            "Capital of Spain".to_string(),
            "Capital of France".to_string(),
        ];
        let expected = vec!["Capital of France".to_string()];

        assert_eq!(
            ready_list_update(&current, &expected),
            ReadyListUpdate::Delete(ready_list::ReadyListRow::new(0))
        );
    }

    #[test]
    fn ready_list_update_rebuilds_for_prompt_edits() {
        let current = vec!["Capital of Spain".to_string()];
        let expected = vec!["Capital of Spain and Aragon".to_string()];

        assert_eq!(
            ready_list_update(&current, &expected),
            ReadyListUpdate::Rebuild(expected)
        );
    }

    #[test]
    fn review_db_path_for_default_deck_lives_in_decks_directory() {
        assert_eq!(
            review_db_path_for_deck(Path::new(DEFAULT_DECK_PATH)),
            PathBuf::from("decks/deck.reviews.sqlite3")
        );
    }

    #[test]
    fn review_db_path_for_other_decks_uses_a_neighbor_database_file() {
        assert_eq!(
            review_db_path_for_deck(Path::new("/tmp/history/spanish.cosimo-deck")),
            PathBuf::from("/tmp/history/spanish.reviews.sqlite3")
        );
    }

    #[test]
    fn load_deck_uses_the_deck_specific_review_database_path() {
        let temp_dir = unique_temp_dir("load-deck");
        let deck_path = temp_dir.join("spanish.cosimo-deck");
        fs::write(&deck_path, "Capital of Spain\n---\nMadrid\n===\n").unwrap();

        let state = AppState::load_deck(&deck_path).unwrap();

        assert_eq!(state.deck_path, deck_path);
        assert_eq!(
            state.review_db_path,
            temp_dir.join("spanish.reviews.sqlite3")
        );
        assert_eq!(
            state.data,
            vec![datum_with_id(1, "Capital of Spain", "Madrid")]
        );
        assert_eq!(state.selected_ready_index, Some(0));
        assert_eq!(
            fs::read_to_string(&deck_path).unwrap(),
            "\
deck_version=2
next_card_id=2
:::

Capital of Spain
---
Madrid
=1=
"
        );
        assert_eq!(
            fs::read_to_string(temp_dir.join("backups/000001-spanish.cosimo-deck")).unwrap(),
            "Capital of Spain\n---\nMadrid\n===\n"
        );
        assert_eq!(
            fs::read_to_string(temp_dir.join("backups/index.txt")).unwrap(),
            format!(
                "000001-spanish.cosimo-deck\t{}\n",
                fs::canonicalize(&deck_path).unwrap().display()
            )
        );
    }

    #[test]
    fn load_deck_rejects_active_reversible_duplicate_responses() {
        let temp_dir = unique_temp_dir("load-deck-duplicate-reverse");
        let deck_path = temp_dir.join("spanish.cosimo-deck");
        let db_path = temp_dir.join("spanish.reviews.sqlite3");
        fs::write(
            &deck_path,
            "one\n---\nuno\n=1=\n\nanother one\n---\nuno\n=2=\n",
        )
        .unwrap();
        let store = SqliteReviewStore::open(&db_path).unwrap();
        store
            .set_datum_reversible(&DatumKey::for_card_id(1), true)
            .unwrap();
        store
            .set_datum_reversible(&DatumKey::for_card_id(2), true)
            .unwrap();
        drop(store);

        let error = match AppState::load(&deck_path, &db_path) {
            Ok(_) => panic!("ambiguous active reverse card should be rejected"),
            Err(error) => error,
        };

        assert!(error.contains("consistency check"));
        assert!(error.contains("duplicate response"));
    }

    #[test]
    fn load_deck_allows_duplicate_response_when_only_one_card_is_reversed() {
        let temp_dir = unique_temp_dir("load-deck-one-reverse-duplicate-response");
        let deck_path = temp_dir.join("spanish.cosimo-deck");
        let db_path = temp_dir.join("spanish.reviews.sqlite3");
        fs::write(
            &deck_path,
            "one\n---\nuno\n=1=\n\nanother one\n---\nuno\n=2=\n",
        )
        .unwrap();
        let store = SqliteReviewStore::open(&db_path).unwrap();
        store
            .set_datum_reversible(&DatumKey::for_card_id(1), true)
            .unwrap();
        drop(store);

        let state = AppState::load(&deck_path, &db_path).unwrap();

        assert_eq!(state.data.len(), 2);
    }

    #[test]
    fn loading_deck_with_duplicate_prompts_warns_without_blocking_repair() {
        let temp_dir = unique_temp_dir("load-duplicate-prompts");
        let deck_path = temp_dir.join("duplicates.cosimo-deck");
        fs::write(
            &deck_path,
            "\
deck_version=2
:::

same prompt
---
one
=1=

 same prompt\r
---
two
=2=
",
        )
        .unwrap();

        let state = AppState::load_deck(&deck_path).unwrap();

        assert_eq!(state.last_status, english().duplicate_prompts_warning(2));
        assert_eq!(
            ready_summary_warning_text(&state, None),
            Some(english().duplicate_prompts_warning(2))
        );
        assert_eq!(state.data.len(), 2);
    }

    #[test]
    fn resolving_duplicate_prompt_group_keeps_chosen_card_and_removes_siblings() {
        let (mut state, temp_dir) = make_state(vec![
            datum_with_id(1, "same", "one"),
            datum_with_id(2, "unique", "answer"),
            datum_with_id(3, " same\r\n", "two"),
        ]);
        let groups = state.duplicate_prompt_groups();
        assert_eq!(groups.len(), 1);
        assert_eq!(groups[0].candidates.len(), 2);

        let removed = state
            .resolve_duplicate_prompt_group(&groups[0].normalized_prompt, &DatumKey::for_card_id(3))
            .unwrap();

        assert_eq!(removed, 1);
        assert_eq!(
            state.data,
            vec![
                datum_with_id(2, "unique", "answer"),
                datum_with_id(3, " same\r\n", "two")
            ]
        );
        assert_eq!(state.duplicate_prompt_groups(), Vec::new());
        assert_eq!(ready_summary_warning_text(&state, None), None);
        assert_selected_ready_key(&state, DatumKey::for_card_id(3));
        assert_eq!(
            state.last_status,
            english().resolved_duplicate_prompts_status(1)
        );
        assert_eq!(
            parse_deck(&fs::read_to_string(temp_dir.join("deck.cosimo-deck")).unwrap()).unwrap(),
            vec![
                datum_with_id(2, "unique", "answer"),
                datum_with_id(3, " same", "two")
            ]
        );
    }

    #[test]
    fn duplicate_prompt_resolution_skip_advances_to_next_group() {
        let (state, _temp_dir) = make_state(vec![
            datum_with_id(1, "first", "one"),
            datum_with_id(2, "first", "two"),
            datum_with_id(3, "second", "three"),
            datum_with_id(4, "second", "four"),
        ]);
        let first_group =
            next_unskipped_duplicate_prompt_group(&state, &HashSet::new()).expect("first group");
        let mut skipped = HashSet::new();
        skipped.insert(first_group.normalized_prompt);

        let next_group =
            next_unskipped_duplicate_prompt_group(&state, &skipped).expect("second group");

        assert_eq!(next_group.prompt, "second");
        skipped.insert(next_group.normalized_prompt);
        assert!(next_unskipped_duplicate_prompt_group(&state, &skipped).is_none());
    }

    #[test]
    fn resolving_duplicate_prompts_rejects_unrelated_keep_card() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "same", "one"),
            datum_with_id(2, "same", "two"),
            datum_with_id(3, "unique", "answer"),
        ]);
        let group = state.duplicate_prompt_groups().remove(0);

        let error = state
            .resolve_duplicate_prompt_group(&group.normalized_prompt, &DatumKey::for_card_id(3))
            .unwrap_err();

        assert_eq!(error, english().no_selected_card_to_edit());
        assert_eq!(state.data.len(), 3);
    }

    #[test]
    fn loading_legacy_deck_assigns_ids_after_used_database_ids() {
        let temp_dir = unique_temp_dir("load-legacy-after-used");
        let deck_path = temp_dir.join("spanish.cosimo-deck");
        let db_path = temp_dir.join("spanish.reviews.sqlite3");
        fs::write(
            &deck_path,
            "Capital of Spain\n---\nMadrid\n===\nCapital of France\n---\nParis\n===\n",
        )
        .unwrap();
        let store = SqliteReviewStore::open(&db_path).unwrap();
        let old_card = datum_with_id(1, "Old card", "Old response");
        store
            .record_review_at(
                &old_card,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                100,
            )
            .unwrap();

        let state = AppState::load(&deck_path, &db_path).unwrap();

        assert_eq!(state.data[0].card_id(), Some(2));
        assert_eq!(state.data[1].card_id(), Some(3));
        assert_eq!(
            fs::read_to_string(&deck_path).unwrap(),
            "\
deck_version=2
next_card_id=4
:::

Capital of Spain
---
Madrid
=2=

Capital of France
---
Paris
=3=
"
        );
    }

    #[test]
    fn loading_legacy_deck_migrates_content_history_to_card_id() {
        let temp_dir = unique_temp_dir("load-legacy-migrates-history");
        let deck_path = temp_dir.join("spanish.cosimo-deck");
        let db_path = temp_dir.join("spanish.reviews.sqlite3");
        fs::write(&deck_path, "Capital of Spain\n---\nMadrid\n===\n").unwrap();
        let legacy_datum = datum("Capital of Spain", "Madrid");
        let store = SqliteReviewStore::open(&db_path).unwrap();
        store
            .record_review_at(
                &legacy_datum,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                100,
            )
            .unwrap();

        let state = AppState::load(&deck_path, &db_path).unwrap();

        assert_eq!(state.data[0].card_id(), Some(1));
        assert!(
            state
                .store
                .reviews_for_datum(&legacy_datum)
                .unwrap()
                .is_empty()
        );
        assert_eq!(
            state.store.reviews_for_datum(&state.data[0]).unwrap().len(),
            1
        );
    }

    #[test]
    fn enumerating_legacy_deck_cards_does_not_move_review_history_before_save() {
        let temp_dir = unique_temp_dir("enumerate-legacy-no-db-mutation");
        let db_path = temp_dir.join("reviews.sqlite3");
        let store = SqliteReviewStore::open(&db_path).unwrap();
        let legacy_datum = datum("Capital of Spain", "Madrid");
        store
            .record_review_at(
                &legacy_datum,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                100,
            )
            .unwrap();

        let mut metadata = DeckMetadata::v2();
        let (data, changed) =
            enumerate_deck_cards(vec![legacy_datum.clone()], &store, &mut metadata).unwrap();

        assert!(changed);
        assert_eq!(data, vec![datum_with_id(1, "Capital of Spain", "Madrid")]);
        assert_eq!(metadata.next_card_id(), Some(2));
        assert_eq!(store.reviews_for_datum(&legacy_datum).unwrap().len(), 1);
        assert!(store.reviews_for_datum(&data[0]).unwrap().is_empty());
    }

    #[test]
    fn loading_v2_deck_retries_pending_legacy_content_history_migration() {
        let temp_dir = unique_temp_dir("load-v2-retries-legacy-history");
        let deck_path = temp_dir.join("spanish.cosimo-deck");
        let db_path = temp_dir.join("spanish.reviews.sqlite3");
        fs::write(
            &deck_path,
            "\
deck_version=2
:::

Capital of Spain
---
Madrid
=1=
",
        )
        .unwrap();
        let legacy_datum = datum("Capital of Spain", "Madrid");
        let store = SqliteReviewStore::open(&db_path).unwrap();
        store
            .record_review_at(
                &legacy_datum,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                100,
            )
            .unwrap();
        drop(store);

        let state = AppState::load(&deck_path, &db_path).unwrap();

        assert_eq!(
            fs::read_to_string(&deck_path).unwrap(),
            "\
deck_version=2
:::

Capital of Spain
---
Madrid
=1=
"
        );
        assert!(
            state
                .store
                .reviews_for_datum(&legacy_datum)
                .unwrap()
                .is_empty()
        );
        assert_eq!(
            state
                .store
                .reviews_for_datum(&datum_with_id(1, "Capital of Spain", "Madrid"))
                .unwrap()
                .len(),
            1
        );
    }

    #[test]
    fn loading_pre_v2_deck_with_card_ids_adds_v2_header_and_backup() {
        let temp_dir = unique_temp_dir("load-pre-v2-adds-header");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let original_deck = "one\n---\nuno\n=1=\n\ntwo\n---\ndos\n=2=\n";
        fs::write(&deck_path, original_deck).unwrap();

        let state = AppState::load(&deck_path, &db_path).unwrap();

        assert!(state.deck_metadata.has_header());
        assert_eq!(
            fs::read_to_string(&deck_path).unwrap(),
            "\
deck_version=2
:::

one
---
uno
=1=

two
---
dos
=2=
"
        );
        assert_eq!(
            fs::read_to_string(temp_dir.join("backups/000001-deck.cosimo-deck")).unwrap(),
            original_deck
        );
    }

    #[test]
    fn loading_v2_deck_with_legacy_separator_assigns_card_id() {
        let temp_dir = unique_temp_dir("load-v2-legacy-separator");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let original_deck = "\
deck_version=2
title=Numbers
:::

one
---
uno
===
";
        fs::write(&deck_path, original_deck).unwrap();

        let state = AppState::load(&deck_path, &db_path).unwrap();

        assert_eq!(state.deck_metadata.get("title"), Some("Numbers"));
        assert_eq!(state.data[0].card_id(), Some(1));
        assert_eq!(
            fs::read_to_string(&deck_path).unwrap(),
            "\
deck_version=2
next_card_id=2
title=Numbers
:::

one
---
uno
=1=
"
        );
        assert_eq!(
            fs::read_to_string(temp_dir.join("backups/000001-deck.cosimo-deck")).unwrap(),
            original_deck
        );
    }

    #[test]
    fn loading_v2_deck_with_idless_non_reversible_separator_assigns_card_id() {
        let temp_dir = unique_temp_dir("load-v2-idless-non-reversible-separator");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let original_deck = "\
deck_version=2
title=Numbers
:::

one
---
uno
=i=
";
        fs::write(&deck_path, original_deck).unwrap();

        let state = AppState::load(&deck_path, &db_path).unwrap();

        assert_eq!(state.deck_metadata.get("title"), Some("Numbers"));
        assert_eq!(state.data[0].card_id(), Some(1));
        assert!(state.data[0].is_reversal_prohibited());
        assert_eq!(
            fs::read_to_string(&deck_path).unwrap(),
            "\
deck_version=2
next_card_id=2
title=Numbers
:::

one
---
uno
=1i=
"
        );
        assert_eq!(
            fs::read_to_string(temp_dir.join("backups/000001-deck.cosimo-deck")).unwrap(),
            original_deck
        );
    }

    #[test]
    fn loading_v2_deck_does_not_rewrite_or_backup() {
        let temp_dir = unique_temp_dir("load-v2-no-rewrite");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let original_deck = "\
deck_version=2
title=Numbers
:::

one
---
uno
=1=
";
        fs::write(&deck_path, original_deck).unwrap();

        let state = AppState::load(&deck_path, &db_path).unwrap();

        assert!(state.deck_metadata.has_header());
        assert_eq!(fs::read_to_string(&deck_path).unwrap(), original_deck);
        assert!(!temp_dir.join("backups/000001-deck.cosimo-deck").exists());
    }

    #[test]
    fn loading_v2_deck_repairs_stale_next_card_id_metadata() {
        let temp_dir = unique_temp_dir("load-v2-stale-next-card-id");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let original_deck = "\
deck_version=2
next_card_id=2
title=Numbers
:::

one
---
uno
=7=
";
        fs::write(&deck_path, original_deck).unwrap();

        let state = AppState::load(&deck_path, &db_path).unwrap();

        assert_eq!(state.deck_metadata.next_card_id(), Some(8));
        assert_eq!(
            fs::read_to_string(&deck_path).unwrap(),
            "\
deck_version=2
next_card_id=8
title=Numbers
:::

one
---
uno
=7=
"
        );
        assert_eq!(
            fs::read_to_string(temp_dir.join("backups/000001-deck.cosimo-deck")).unwrap(),
            original_deck
        );
    }

    #[test]
    fn loading_deck_migrates_legacy_reversal_exclusions_to_deck_markers() {
        let temp_dir = unique_temp_dir("load-legacy-reversal-exclusions");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        fs::write(&deck_path, "one\n---\nuno\n=1=\n\ntwo\n---\ndos\n=2=\n").unwrap();
        let connection = Connection::open(&db_path).unwrap();
        connection
            .execute_batch(
                "
                CREATE TABLE cosimo_schema_version (
                    version INTEGER PRIMARY KEY
                );
                INSERT INTO cosimo_schema_version (version) VALUES (9);
                CREATE TABLE card_reversal_exclusions (
                    datum_key TEXT PRIMARY KEY
                );
                INSERT INTO card_reversal_exclusions (datum_key) VALUES ('card:1');
                ",
            )
            .unwrap();
        drop(connection);

        let state = AppState::load(&deck_path, &db_path).unwrap();

        assert!(state.data[0].is_reversal_prohibited());
        assert!(!state.data[1].is_reversal_prohibited());
        assert_eq!(
            fs::read_to_string(&deck_path).unwrap(),
            "\
deck_version=2
:::

one
---
uno
=1i=

two
---
dos
=2=
"
        );
        assert!(
            state
                .store
                .legacy_reversal_excluded_datum_keys()
                .unwrap()
                .is_empty()
        );
    }

    #[test]
    fn loading_legacy_deck_matches_reversal_exclusions_by_content_key() {
        let temp_dir = unique_temp_dir("load-legacy-content-reversal-exclusion");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let legacy_datum = datum("one", "uno");
        fs::write(&deck_path, "one\n---\nuno\n===\n").unwrap();
        let connection = Connection::open(&db_path).unwrap();
        connection
            .execute_batch(
                "
                CREATE TABLE cosimo_schema_version (
                    version INTEGER PRIMARY KEY
                );
                INSERT INTO cosimo_schema_version (version) VALUES (9);
                CREATE TABLE card_reversal_exclusions (
                    datum_key TEXT PRIMARY KEY
                );
                ",
            )
            .unwrap();
        connection
            .execute(
                "INSERT INTO card_reversal_exclusions (datum_key) VALUES (?1)",
                [DatumKey::for_legacy_content(&legacy_datum).as_str()],
            )
            .unwrap();
        drop(connection);

        let state = AppState::load(&deck_path, &db_path).unwrap();

        assert_eq!(state.data[0].card_id(), Some(1));
        assert!(state.data[0].is_reversal_prohibited());
        assert_eq!(
            fs::read_to_string(&deck_path).unwrap(),
            "\
deck_version=2
next_card_id=2
:::

one
---
uno
=1i=
"
        );
        assert!(
            state
                .store
                .legacy_reversal_excluded_datum_keys()
                .unwrap()
                .is_empty()
        );
    }

    #[test]
    fn loading_non_reversible_deck_removes_active_generated_reverse() {
        let temp_dir = unique_temp_dir("load-non-reversible-removes-reverse");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        fs::write(
            &deck_path,
            "\
deck_version=2
:::

one
---
uno
=1i=
",
        )
        .unwrap();
        let store = SqliteReviewStore::open(&db_path).unwrap();
        store
            .set_datum_reversible(&DatumKey::for_card_id(1), true)
            .unwrap();
        drop(store);

        let state = AppState::load(&deck_path, &db_path).unwrap();

        assert!(state.store.reversible_datum_keys().unwrap().is_empty());
        assert_eq!(state.study_items().unwrap().len(), 1);
    }

    #[test]
    fn loading_deck_clears_expired_card_states() {
        let temp_dir = unique_temp_dir("load-clears-expired-card-states");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        fs::write(&deck_path, "Capital of Spain\n---\nMadrid\n=1=\n").unwrap();
        let datum_key = DatumKey::for_card_id(1);
        let store = SqliteReviewStore::open(&db_path).unwrap();
        store.defer_datum_until(&datum_key, 100).unwrap();
        store.set_datum_suspended(&datum_key, true).unwrap();
        store.set_datum_suspended(&datum_key, false).unwrap();
        drop(store);

        let state = AppState::load(&deck_path, &db_path).unwrap();

        assert_eq!(
            state
                .store
                .clear_inactive_card_states(1_800_000_000)
                .unwrap(),
            0
        );
    }

    #[test]
    fn editing_loaded_card_preserves_card_id() {
        let temp_dir = unique_temp_dir("edit-loaded-preserves-id");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let original_deck = "\
deck_version=2
:::

Capital of Spain
---
Madrid
=7=
";
        fs::write(&deck_path, original_deck).unwrap();
        let mut state = AppState::load(&deck_path, &db_path).unwrap();
        state.select_ready_index(0);

        state
            .edit_selected_card(datum("Capital of Spain", "Madrid, Spain"))
            .unwrap();

        let saved = fs::read_to_string(&deck_path).unwrap();
        assert_eq!(
            saved,
            "\
deck_version=2
next_card_id=8
:::

Capital of Spain
---
Madrid, Spain
=7=
"
        );
        assert_eq!(
            fs::read_to_string(temp_dir.join("backups/000001-deck.cosimo-deck")).unwrap(),
            original_deck
        );
        assert_eq!(state.data[0].card_id(), Some(7));
    }

    #[test]
    fn updating_deck_metadata_persists_known_fields_and_preserves_cards() {
        let temp_dir = unique_temp_dir("update-deck-metadata");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let original_deck = "\
deck_version=2
license=CC0
:::

one
---
uno
=1=
";
        fs::write(&deck_path, original_deck).unwrap();
        let mut state = AppState::load(&deck_path, &db_path).unwrap();
        let mut metadata = state.deck_metadata.clone();
        metadata.set("title", "Numbers");
        metadata.set("author", "Modulus");
        metadata.set("description", "Short Spanish number deck");
        metadata.set("url", "https://example.invalid/deck");
        metadata.set("prompt_voice", "Andika");
        metadata.set("prompt_voice_engine", "onecore");
        metadata.set("response_voice", "David");
        metadata.set("response_voice_engine", "sapi");

        state.update_deck_metadata(metadata).unwrap();

        let saved = fs::read_to_string(&deck_path).unwrap();
        assert!(saved.contains("deck_version=2\n"));
        assert!(saved.contains("license=CC0\n"));
        assert!(saved.contains("title=Numbers\n"));
        assert!(saved.contains("author=Modulus\n"));
        assert!(saved.contains("description=Short Spanish number deck\n"));
        assert!(saved.contains("url=https://example.invalid/deck\n"));
        assert!(saved.contains("prompt_voice=Andika\n"));
        assert!(saved.contains("prompt_voice_engine=onecore\n"));
        assert!(saved.contains("response_voice=David\n"));
        assert!(saved.contains("response_voice_engine=sapi\n"));
        assert!(saved.contains("one\n---\nuno\n=1=\n"));
        assert_eq!(
            fs::read_to_string(temp_dir.join("backups/000001-deck.cosimo-deck")).unwrap(),
            original_deck
        );
        assert_eq!(state.last_status, english().saved_deck_properties_status());
    }

    #[test]
    fn updating_deck_metadata_removes_cleared_visible_fields() {
        let temp_dir = unique_temp_dir("clear-deck-metadata");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let original_deck = "\
deck_version=2
license=CC0
title=Numbers
:::

one
---
uno
=1=
";
        fs::write(&deck_path, original_deck).unwrap();
        let mut state = AppState::load(&deck_path, &db_path).unwrap();
        let mut metadata = state.deck_metadata.clone();
        metadata.remove("title");

        state.update_deck_metadata(metadata).unwrap();

        let saved = fs::read_to_string(&deck_path).unwrap();
        assert!(!saved.contains("title="));
        assert!(saved.contains("license=CC0\n"));
        assert!(saved.contains("one\n---\nuno\n=1=\n"));
    }

    #[test]
    fn session_start_backs_up_review_database_once() {
        let temp_dir = unique_temp_dir("review-db-backup");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let backup_dir = temp_dir.join("backups");
        fs::write(&deck_path, "Capital of Spain\n---\nMadrid\n=1=\n").unwrap();
        let datum = datum_with_id(1, "Capital of Spain", "Madrid");
        let store = SqliteReviewStore::open(&db_path).unwrap();
        store
            .record_review_at(
                &datum,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                100,
            )
            .unwrap();
        drop(store);

        let mut state = AppState::load_with_backup_dir(
            &deck_path,
            &db_path,
            &backup_dir,
            english(),
            AppSettings::default(),
        )
        .unwrap();
        state.start_cycle();
        drop(state);

        let backup_path = review_db_backup_path(&db_path, &backup_dir);
        let backup_store = SqliteReviewStore::open(&backup_path).unwrap();
        assert_eq!(backup_store.reviews_for_datum(&datum).unwrap().len(), 1);
        assert_eq!(
            fs::read_to_string(review_db_backup_index_path(&backup_dir)).unwrap(),
            format!(
                "{}\t{}\n",
                backup_path.file_name().unwrap().to_string_lossy(),
                fs::canonicalize(&db_path).unwrap().display()
            )
        );
    }

    #[test]
    fn session_start_reports_review_database_backup_failure_without_blocking_pass() {
        let temp_dir = unique_temp_dir("review-db-backup-failure-status");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let backup_dir = temp_dir.join("backups");
        fs::write(&deck_path, "Capital of Spain\n---\nMadrid\n=1=\n").unwrap();
        let datum = datum_with_id(1, "Capital of Spain", "Madrid");
        let store = SqliteReviewStore::open(&db_path).unwrap();
        store
            .record_review_at(
                &datum,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                100,
            )
            .unwrap();
        drop(store);
        fs::create_dir_all(review_db_backup_index_path(&backup_dir)).unwrap();

        let mut state = AppState::load_with_backup_dir(
            &deck_path,
            &db_path,
            &backup_dir,
            english(),
            AppSettings::default(),
        )
        .unwrap();

        state.start_cycle();

        assert!(matches!(state.session.phase(), ReviewPhase::Prompt));
        assert!(state.last_status.starts_with(&english().started_pass(1)));
        assert!(
            state
                .last_status
                .contains("Warning: could not back up review database")
        );
        assert!(state.last_status.contains(&db_path.display().to_string()));
        assert!(
            state
                .last_status
                .contains(&backup_dir.display().to_string())
        );
        assert!(state.last_status.contains("backup was not refreshed"));
        let pending_warning = state.take_pending_warning_dialog().unwrap();
        assert!(pending_warning.contains("Warning: could not back up review database"));
        assert!(state.take_pending_warning_dialog().is_none());
    }

    #[test]
    fn session_start_replaces_existing_review_database_backup() {
        let temp_dir = unique_temp_dir("review-db-backup-replace");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let backup_dir = temp_dir.join("backups");
        fs::write(&deck_path, "Capital of Spain\n---\nMadrid\n=1=\n").unwrap();
        let datum = datum_with_id(1, "Capital of Spain", "Madrid");
        let store = SqliteReviewStore::open(&db_path).unwrap();
        store
            .record_review_at(
                &datum,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                100,
            )
            .unwrap();
        drop(store);

        let mut state = AppState::load_with_backup_dir(
            &deck_path,
            &db_path,
            &backup_dir,
            english(),
            AppSettings::default(),
        )
        .unwrap();
        state.start_cycle();
        drop(state);

        let store = SqliteReviewStore::open(&db_path).unwrap();
        store
            .record_review_at(
                &datum,
                Review::new(PromptGuess::CertainlyKnow, RecallFeedback::Easy),
                200,
            )
            .unwrap();
        drop(store);

        let mut state = AppState::load_with_backup_dir(
            &deck_path,
            &db_path,
            &backup_dir,
            english(),
            AppSettings::default(),
        )
        .unwrap();
        state.start_cycle();

        let backup_path = review_db_backup_path(&db_path, &backup_dir);
        let backup_store = SqliteReviewStore::open(&backup_path).unwrap();
        assert_eq!(backup_store.reviews_for_datum(&datum).unwrap().len(), 2);
        let review_db_backups = fs::read_dir(&backup_dir)
            .unwrap()
            .filter_map(Result::ok)
            .filter(|entry| {
                let file_name = entry.file_name().to_string_lossy().to_string();
                file_name.starts_with("review-db-") && file_name != "review-db-index.txt"
            })
            .count();
        assert_eq!(review_db_backups, 1);
    }

    #[test]
    fn opening_deck_without_existing_review_database_does_not_create_database_backup() {
        let temp_dir = unique_temp_dir("review-db-backup-new");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let backup_dir = temp_dir.join("backups");
        fs::write(&deck_path, "Capital of Spain\n---\nMadrid\n=1=\n").unwrap();

        AppState::load_with_backup_dir(
            &deck_path,
            &db_path,
            &backup_dir,
            english(),
            AppSettings::default(),
        )
        .unwrap();

        assert!(!review_db_backup_path(&db_path, &backup_dir).exists());
        assert!(!review_db_backup_index_path(&backup_dir).exists());
    }

    #[test]
    fn loading_deck_writes_automatic_full_backup_when_enabled() {
        let temp_dir = unique_temp_dir("automatic-full-backup-load");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let backup_dir = temp_dir.join("backups");
        fs::write(&deck_path, "Capital of Spain\n---\nMadrid\n=1=\n").unwrap();
        let store = SqliteReviewStore::open(&db_path).unwrap();
        store
            .record_review_at(
                &datum_with_id(1, "Capital of Spain", "Madrid"),
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                100,
            )
            .unwrap();
        drop(store);

        let state = AppState::load_with_backup_dir(
            &deck_path,
            &db_path,
            &backup_dir,
            english(),
            AppSettings {
                automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Daily,
                ..AppSettings::default()
            },
        )
        .unwrap();

        assert!(state.last_status.is_empty());
        assert!(automatic_deck_bundle_backup_path(&deck_path, &backup_dir).exists());
        let index =
            fs::read_to_string(automatic_deck_bundle_backup_index_path(&backup_dir)).unwrap();
        assert!(index.contains("automatic-full-deck-"));
        assert!(index.contains("deck.cosimo-deck"));
    }

    #[test]
    fn automatic_full_backup_failure_is_visible_in_ready_summary() {
        let temp_dir = unique_temp_dir("automatic-full-backup-warning");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let backup_dir = temp_dir.join("backups");
        let datum = datum_with_id(1, "Capital of Spain", "Madrid");
        fs::write(
            &deck_path,
            format_deck(std::slice::from_ref(&datum)).unwrap(),
        )
        .unwrap();
        SqliteReviewStore::open(&db_path).unwrap();
        fs::write(&backup_dir, "not a directory").unwrap();

        let state = AppState::load_with_backup_dir(
            &deck_path,
            &db_path,
            &backup_dir,
            english(),
            AppSettings {
                deck_backup_policy: DeckBackupPolicy::None,
                automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Daily,
                ..AppSettings::default()
            },
        )
        .unwrap();

        let warning = ready_summary_warning_text(&state, None).unwrap();
        assert!(warning.contains("Warning: could not write the automatic full deck backup"));
        assert!(warning.contains(&deck_path.display().to_string()));
        assert!(warning.contains(&backup_dir.display().to_string()));
    }

    #[test]
    fn session_start_respects_disabled_review_database_backups() {
        let temp_dir = unique_temp_dir("review-db-backup-off");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let backup_dir = temp_dir.join("backups");
        fs::write(&deck_path, "Capital of Spain\n---\nMadrid\n=1=\n").unwrap();
        let datum = datum_with_id(1, "Capital of Spain", "Madrid");
        let store = SqliteReviewStore::open(&db_path).unwrap();
        store
            .record_review_at(
                &datum,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                100,
            )
            .unwrap();
        drop(store);

        let mut state = AppState::load_with_backup_dir(
            &deck_path,
            &db_path,
            &backup_dir,
            english(),
            AppSettings {
                review_db_backup_policy: ReviewDbBackupPolicy::Off,
                ..AppSettings::default()
            },
        )
        .unwrap();
        state.start_cycle();

        assert!(!review_db_backup_path(&db_path, &backup_dir).exists());
        assert!(!review_db_backup_index_path(&backup_dir).exists());
    }

    #[test]
    fn force_start_backs_up_review_database() {
        let temp_dir = unique_temp_dir("review-db-backup-force");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let backup_dir = temp_dir.join("backups");
        fs::write(&deck_path, "Capital of Spain\n---\nMadrid\n=1=\n").unwrap();
        let datum = datum_with_id(1, "Capital of Spain", "Madrid");
        let store = SqliteReviewStore::open(&db_path).unwrap();
        store
            .record_review_at(
                &datum,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                100,
            )
            .unwrap();
        drop(store);

        let mut state = AppState::load_with_backup_dir(
            &deck_path,
            &db_path,
            &backup_dir,
            english(),
            AppSettings::default(),
        )
        .unwrap();
        state.force_start_cycle_with_seed(31, 1_000_000);

        let backup_path = review_db_backup_path(&db_path, &backup_dir);
        let backup_store = SqliteReviewStore::open(&backup_path).unwrap();
        assert_eq!(backup_store.reviews_for_datum(&datum).unwrap().len(), 1);
    }

    #[test]
    fn deck_backups_use_one_global_sequence_number() {
        let temp_dir = unique_temp_dir("deck-backup-sequence");
        let backup_dir = temp_dir.join("backups");
        let first_deck_path = temp_dir.join("deck.cosimo-deck");
        let second_deck_path = temp_dir.join("other/place/deck.cosimo-deck");
        fs::create_dir_all(second_deck_path.parent().unwrap()).unwrap();
        fs::write(&first_deck_path, "First\n---\nAnswer\n=1=\n").unwrap();
        fs::write(&second_deck_path, "Other\n---\nAnswer\n=1=\n").unwrap();

        write_deck_file_durably(
            &first_deck_path,
            &backup_dir,
            DeckBackupPolicy::Full,
            "Second\n---\nAnswer\n=1=\n",
        )
        .unwrap();
        write_deck_file_durably(
            &second_deck_path,
            &backup_dir,
            DeckBackupPolicy::Full,
            "Other updated\n---\nAnswer\n=1=\n",
        )
        .unwrap();
        write_deck_file_durably(
            &first_deck_path,
            &backup_dir,
            DeckBackupPolicy::Full,
            "Third\n---\nAnswer\n=1=\n",
        )
        .unwrap();

        assert_eq!(
            fs::read_to_string(temp_dir.join("backups/000001-deck.cosimo-deck")).unwrap(),
            "First\n---\nAnswer\n=1=\n"
        );
        assert_eq!(
            fs::read_to_string(temp_dir.join("backups/000002-deck.cosimo-deck")).unwrap(),
            "Other\n---\nAnswer\n=1=\n"
        );
        assert_eq!(
            fs::read_to_string(temp_dir.join("backups/000003-deck.cosimo-deck")).unwrap(),
            "Second\n---\nAnswer\n=1=\n"
        );
        assert_eq!(
            fs::read_to_string(temp_dir.join("backups/index.txt")).unwrap(),
            format!(
                "000001-deck.cosimo-deck\t{}\n000002-deck.cosimo-deck\t{}\n000003-deck.cosimo-deck\t{}\n",
                fs::canonicalize(&first_deck_path).unwrap().display(),
                fs::canonicalize(&second_deck_path).unwrap().display(),
                fs::canonicalize(&first_deck_path).unwrap().display()
            )
        );
        assert_eq!(
            fs::read_to_string(&first_deck_path).unwrap(),
            "Third\n---\nAnswer\n=1=\n"
        );
    }

    #[test]
    fn last_only_deck_backup_policy_keeps_only_the_latest_backup() {
        let temp_dir = unique_temp_dir("deck-backup-last-only");
        let backup_dir = temp_dir.join("backups");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        fs::write(&deck_path, "Original\n---\nAnswer\n=1=\n").unwrap();

        write_deck_file_durably(
            &deck_path,
            &backup_dir,
            DeckBackupPolicy::LastOnly,
            "First update\n---\nAnswer\n=1=\n",
        )
        .unwrap();
        write_deck_file_durably(
            &deck_path,
            &backup_dir,
            DeckBackupPolicy::LastOnly,
            "Second update\n---\nAnswer\n=1=\n",
        )
        .unwrap();

        assert_eq!(
            fs::read_to_string(backup_dir.join("000001-deck.cosimo-deck")).unwrap(),
            "First update\n---\nAnswer\n=1=\n"
        );
        assert!(!backup_dir.join("000002-deck.cosimo-deck").exists());
        assert_eq!(
            fs::read_to_string(backup_dir.join("index.txt")).unwrap(),
            format!(
                "000001-deck.cosimo-deck\t{}\n",
                fs::canonicalize(&deck_path).unwrap().display()
            )
        );
        assert_eq!(
            fs::read_to_string(&deck_path).unwrap(),
            "Second update\n---\nAnswer\n=1=\n"
        );
    }

    #[test]
    fn no_deck_backup_policy_writes_without_creating_backups() {
        let temp_dir = unique_temp_dir("deck-backup-none");
        let backup_dir = temp_dir.join("backups");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        fs::write(&deck_path, "Original\n---\nAnswer\n=1=\n").unwrap();

        write_deck_file_durably(
            &deck_path,
            &backup_dir,
            DeckBackupPolicy::None,
            "Updated\n---\nAnswer\n=1=\n",
        )
        .unwrap();

        assert_eq!(
            fs::read_to_string(&deck_path).unwrap(),
            "Updated\n---\nAnswer\n=1=\n"
        );
        assert!(!backup_dir.exists());
    }

    #[test]
    fn pruning_deck_backups_keeps_latest_per_original_path_and_renumbers() {
        let temp_dir = unique_temp_dir("prune-deck-backups");
        let backup_dir = temp_dir.join("backups");
        let first_deck_path = temp_dir.join("deck.cosimo-deck");
        let second_deck_path = temp_dir.join("other/deck.cosimo-deck");
        fs::create_dir_all(second_deck_path.parent().unwrap()).unwrap();
        fs::write(&first_deck_path, "First one\n---\nAnswer\n=1=\n").unwrap();
        fs::write(&second_deck_path, "Second one\n---\nAnswer\n=1=\n").unwrap();

        write_deck_file_durably(
            &first_deck_path,
            &backup_dir,
            DeckBackupPolicy::Full,
            "First two\n---\nAnswer\n=1=\n",
        )
        .unwrap();
        write_deck_file_durably(
            &second_deck_path,
            &backup_dir,
            DeckBackupPolicy::Full,
            "Second two\n---\nAnswer\n=1=\n",
        )
        .unwrap();
        write_deck_file_durably(
            &first_deck_path,
            &backup_dir,
            DeckBackupPolicy::Full,
            "First three\n---\nAnswer\n=1=\n",
        )
        .unwrap();

        let summary = prune_obsolete_deck_backups(&backup_dir).unwrap();

        assert_eq!(
            summary,
            PruneBackupsSummary {
                removed_count: 1,
                kept_count: 2
            }
        );
        assert_eq!(
            fs::read_to_string(backup_dir.join("000001-deck.cosimo-deck")).unwrap(),
            "First two\n---\nAnswer\n=1=\n"
        );
        assert_eq!(
            fs::read_to_string(backup_dir.join("000002-deck.cosimo-deck")).unwrap(),
            "Second one\n---\nAnswer\n=1=\n"
        );
        assert_eq!(
            fs::read_to_string(backup_dir.join("index.txt")).unwrap(),
            format!(
                "000001-deck.cosimo-deck\t{}\n000002-deck.cosimo-deck\t{}\n",
                fs::canonicalize(&first_deck_path).unwrap().display(),
                fs::canonicalize(&second_deck_path).unwrap().display()
            )
        );
    }

    #[test]
    fn deck_write_does_not_overwrite_when_backup_index_cannot_be_written() {
        let temp_dir = unique_temp_dir("backup-index-failure");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let backup_dir = temp_dir.join("backups");
        fs::create_dir_all(backup_dir.join("index.txt")).unwrap();
        fs::write(&deck_path, "Original\n---\nAnswer\n=1=\n").unwrap();

        let error = write_deck_file_durably(
            &deck_path,
            &backup_dir,
            DeckBackupPolicy::Full,
            "Updated\n---\nAnswer\n=1=\n",
        )
        .unwrap_err();

        assert!(matches!(
            error.kind(),
            io::ErrorKind::IsADirectory | io::ErrorKind::PermissionDenied | io::ErrorKind::Other
        ));
        assert_eq!(
            fs::read_to_string(&deck_path).unwrap(),
            "Original\n---\nAnswer\n=1=\n"
        );
    }

    #[test]
    fn add_card_failure_does_not_change_deck_or_in_memory_cards() {
        let temp_dir = unique_temp_dir("add-card-backup-failure");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let backup_dir = temp_dir.join("backups");
        fs::create_dir_all(backup_dir.join("index.txt")).unwrap();
        let original_deck = "\
deck_version=2
:::

Capital of Spain
---
Madrid
=1=
";
        fs::write(&deck_path, original_deck).unwrap();
        let mut state = AppState::load_with_backup_dir(
            &deck_path,
            &db_path,
            &backup_dir,
            english(),
            AppSettings::default(),
        )
        .unwrap();

        let error = state
            .add_card(datum("Capital of France", "Paris"))
            .unwrap_err();

        assert!(error.contains("Is a directory") || error.contains("denied") || !error.is_empty());
        assert_eq!(
            state.data,
            vec![datum_with_id(1, "Capital of Spain", "Madrid")]
        );
        assert_eq!(fs::read_to_string(&deck_path).unwrap(), original_deck);
    }

    #[test]
    fn legacy_migration_failure_before_backup_leaves_deck_and_history_unchanged() {
        let temp_dir = unique_temp_dir("legacy-migration-backup-failure");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let backup_dir = temp_dir.join("backups");
        fs::create_dir_all(backup_dir.join("index.txt")).unwrap();
        fs::write(&deck_path, "Capital of Spain\n---\nMadrid\n===\n").unwrap();
        let legacy_datum = datum("Capital of Spain", "Madrid");
        let store = SqliteReviewStore::open(&db_path).unwrap();
        store
            .record_review_at(
                &legacy_datum,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                100,
            )
            .unwrap();
        drop(store);

        let error = match AppState::load_with_backup_dir(
            &deck_path,
            &db_path,
            &backup_dir,
            english(),
            AppSettings::default(),
        ) {
            Ok(_) => panic!("legacy migration should fail when backup index cannot be written"),
            Err(error) => error,
        };

        assert!(error.contains("Is a directory") || error.contains("denied") || !error.is_empty());
        assert_eq!(
            fs::read_to_string(&deck_path).unwrap(),
            "Capital of Spain\n---\nMadrid\n===\n"
        );
        let store = SqliteReviewStore::open(&db_path).unwrap();
        assert_eq!(store.reviews_for_datum(&legacy_datum).unwrap().len(), 1);
        assert_eq!(
            store
                .reviews_for_datum(&datum_with_id(1, "Capital of Spain", "Madrid"))
                .unwrap()
                .len(),
            0
        );
    }

    #[test]
    fn corrupt_review_database_blocks_deck_migration_without_writing_backup() {
        let temp_dir = unique_temp_dir("corrupt-review-db-load");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        let backup_dir = temp_dir.join("backups");
        let original_deck = "Capital of Spain\n---\nMadrid\n===\n";
        fs::write(&deck_path, original_deck).unwrap();
        fs::write(&db_path, b"not a sqlite database").unwrap();

        let error = match AppState::load_with_backup_dir(
            &deck_path,
            &db_path,
            &backup_dir,
            english(),
            AppSettings::default(),
        ) {
            Ok(_) => panic!("corrupt review database should block deck loading"),
            Err(error) => error,
        };

        assert!(error.contains("integrity check") || error.contains("database"));
        assert_eq!(fs::read_to_string(&deck_path).unwrap(), original_deck);
        assert!(!backup_dir.exists());
    }

    #[test]
    fn pruning_without_an_index_leaves_existing_backup_files_alone() {
        let temp_dir = unique_temp_dir("prune-no-index");
        let backup_dir = temp_dir.join("backups");
        fs::create_dir_all(&backup_dir).unwrap();
        fs::write(
            backup_dir.join("000001-deck.cosimo-deck"),
            "Backup\n---\nAnswer\n=1=\n",
        )
        .unwrap();

        let summary = prune_obsolete_deck_backups(&backup_dir).unwrap();

        assert_eq!(
            summary,
            PruneBackupsSummary {
                removed_count: 0,
                kept_count: 0
            }
        );
        assert_eq!(
            fs::read_to_string(backup_dir.join("000001-deck.cosimo-deck")).unwrap(),
            "Backup\n---\nAnswer\n=1=\n"
        );
    }

    #[test]
    fn pruning_refuses_to_overwrite_unindexed_backup_files() {
        let temp_dir = unique_temp_dir("prune-unindexed-collision");
        let backup_dir = temp_dir.join("backups");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        fs::create_dir_all(&backup_dir).unwrap();
        fs::write(&deck_path, "Current\n---\nAnswer\n=1=\n").unwrap();
        fs::write(
            backup_dir.join("000001-deck.cosimo-deck"),
            "Unindexed\n---\nAnswer\n=1=\n",
        )
        .unwrap();
        fs::write(
            backup_dir.join("000002-deck.cosimo-deck"),
            "Indexed\n---\nAnswer\n=1=\n",
        )
        .unwrap();
        fs::write(
            backup_dir.join("index.txt"),
            format!(
                "000002-deck.cosimo-deck\t{}\n",
                fs::canonicalize(&deck_path).unwrap().display()
            ),
        )
        .unwrap();

        let error = prune_obsolete_deck_backups(&backup_dir).unwrap_err();

        assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
        assert_eq!(
            fs::read_to_string(backup_dir.join("000001-deck.cosimo-deck")).unwrap(),
            "Unindexed\n---\nAnswer\n=1=\n"
        );
        assert_eq!(
            fs::read_to_string(backup_dir.join("000002-deck.cosimo-deck")).unwrap(),
            "Indexed\n---\nAnswer\n=1=\n"
        );
        assert_eq!(
            fs::read_to_string(backup_dir.join("index.txt")).unwrap(),
            format!(
                "000002-deck.cosimo-deck\t{}\n",
                fs::canonicalize(&deck_path).unwrap().display()
            )
        );
    }

    #[test]
    fn adding_loaded_card_uses_next_monotonic_card_id() {
        let temp_dir = unique_temp_dir("add-loaded-monotonic-id");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let db_path = temp_dir.join("reviews.sqlite3");
        fs::write(&deck_path, "Capital of Spain\n---\nMadrid\n=2=\n").unwrap();
        let mut state = AppState::load(&deck_path, &db_path).unwrap();

        state.add_card(datum("Capital of France", "Paris")).unwrap();

        assert_eq!(
            fs::read_to_string(&deck_path).unwrap(),
            "\
deck_version=2
next_card_id=4
:::

Capital of Spain
---
Madrid
=2=

Capital of France
---
Paris
=3=
"
        );
        assert_eq!(state.data[1].card_id(), Some(3));
    }

    #[test]
    fn startup_deck_path_uses_the_persisted_deck_when_it_exists() {
        let temp_dir = unique_temp_dir("startup-path-existing");
        let config_path = temp_dir.join("cosimo.ini");
        let deck_path = temp_dir.join("spanish.cosimo-deck");
        fs::write(&deck_path, "").unwrap();
        fs::write(
            &config_path,
            format!("[state]\nlast_open_deck={}\n", deck_path.display()),
        )
        .unwrap();

        assert_eq!(startup_deck_path(&config_path), deck_path);
    }

    #[test]
    fn startup_deck_path_falls_back_to_the_default_deck_when_state_is_missing() {
        let temp_dir = unique_temp_dir("startup-path-missing");
        let config_path = temp_dir.join("cosimo.ini");

        assert_eq!(startup_deck_path(&config_path), default_deck_path());
    }

    #[test]
    fn startup_deck_path_falls_back_to_the_default_deck_when_saved_deck_is_missing() {
        let temp_dir = unique_temp_dir("startup-path-missing-deck");
        let config_path = temp_dir.join("cosimo.ini");
        let missing_deck_path = temp_dir.join("missing.cosimo-deck");
        fs::write(
            &config_path,
            format!("[state]\nlast_open_deck={}\n", missing_deck_path.display()),
        )
        .unwrap();

        assert_eq!(startup_deck_path(&config_path), default_deck_path());
    }

    #[test]
    fn persist_open_deck_path_writes_the_selected_deck_path_to_config() {
        let temp_dir = unique_temp_dir("persist-deck-path");
        let config_path = temp_dir.join("cosimo.ini");
        let deck_path = temp_dir.join("spanish.cosimo-deck");

        persist_open_deck_path(&config_path, &deck_path, english()).unwrap();

        assert_eq!(
            fs::read_to_string(&config_path).unwrap(),
            format!(
                "[settings]\nminimum_cards_per_session=0\nnew_cards_per_day=20\ndeck_backup_policy=full\nreview_db_backup_policy=on-session-start\nautomatic_full_deck_backup_policy=off\nsound_notification_policy=never\nreview_audio_policy=off\nreview_audio_cues=false\naudio_generation_format=opus\nexam_post_answer_audio_policy=off\nquiz_audio_policy=off\ncalibration_recall_rule=lenient\nminimize_to_tray=false\n\n[state]\nlast_open_deck={}\nrecent_deck={}\nready_card_filter=all\nready_card_sort=deck-order\nready_card_sort_direction=ascending\n\n[sync]\nsync_server_url={DEFAULT_SYNC_SERVER_URL}\n",
                deck_path.display(),
                deck_path.display()
            )
        );
    }

    #[test]
    fn persist_open_deck_path_skips_write_when_state_is_unchanged() {
        let temp_dir = unique_temp_dir("persist-deck-path-unchanged");
        let config_path = temp_dir.join("cosimo.ini");
        let deck_path = temp_dir.join("spanish.cosimo-deck");
        let config_text = format!(
            "[settings]\nminimum_cards_per_session=0\nnew_cards_per_day=20\ndeck_backup_policy=full\nreview_db_backup_policy=on-session-start\nautomatic_full_deck_backup_policy=off\nsound_notification_policy=never\nreview_audio_policy=off\ncalibration_recall_rule=lenient\nminimize_to_tray=false\n\n[state]\nlast_open_deck={}\nrecent_deck={}\nready_card_filter=all\nready_card_sort=deck-order\nready_card_sort_direction=ascending\n; keep this comment\n\n[sync]\nsync_server_url={DEFAULT_SYNC_SERVER_URL}\n",
            deck_path.display(),
            deck_path.display()
        );
        fs::write(&config_path, &config_text).unwrap();

        persist_open_deck_path(&config_path, &deck_path, english()).unwrap();

        assert_eq!(fs::read_to_string(&config_path).unwrap(), config_text);
    }

    #[test]
    fn parse_app_settings_reads_the_language_setting() {
        let settings = parse_app_settings("language=en\n");

        assert_eq!(
            settings,
            AppSettings {
                language: localization::Language::English,
                minimum_cards_per_session: 0,
                new_cards_per_day: 20,
                deck_backup_policy: DeckBackupPolicy::Full,
                review_db_backup_policy: ReviewDbBackupPolicy::OnSessionStart,
                automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Off,
                sound_notification_policy: SoundNotificationPolicy::Never,
                review_audio_policy: ReviewAudioPolicy::Off,
                review_audio_cues: false,
                audio_generation_format: AudioGenerationFormat::Opus,
                exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy::Off,
                quiz_audio_policy: QuizAudioPolicy::Off,
                calibration_recall_rule: CalibrationRecallRule::Lenient,
                minimize_to_tray: false,
            }
        );
    }

    #[test]
    fn parse_app_settings_reads_the_spanish_language_setting() {
        let settings = parse_app_settings("language=es\n");

        assert_eq!(
            settings,
            AppSettings {
                language: localization::Language::Spanish,
                minimum_cards_per_session: 0,
                new_cards_per_day: 20,
                deck_backup_policy: DeckBackupPolicy::Full,
                review_db_backup_policy: ReviewDbBackupPolicy::OnSessionStart,
                automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Off,
                sound_notification_policy: SoundNotificationPolicy::Never,
                review_audio_policy: ReviewAudioPolicy::Off,
                review_audio_cues: false,
                audio_generation_format: AudioGenerationFormat::Opus,
                exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy::Off,
                quiz_audio_policy: QuizAudioPolicy::Off,
                calibration_recall_rule: CalibrationRecallRule::Lenient,
                minimize_to_tray: false,
            }
        );
    }

    #[test]
    fn parse_app_settings_reads_the_minimum_cards_per_session() {
        let settings = parse_app_settings("minimum_cards_per_session=12\nnew_cards_per_day=20\n");

        assert_eq!(
            settings,
            AppSettings {
                language: localization::Language::English,
                minimum_cards_per_session: 12,
                new_cards_per_day: 20,
                deck_backup_policy: DeckBackupPolicy::Full,
                review_db_backup_policy: ReviewDbBackupPolicy::OnSessionStart,
                automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Off,
                sound_notification_policy: SoundNotificationPolicy::Never,
                review_audio_policy: ReviewAudioPolicy::Off,
                review_audio_cues: false,
                audio_generation_format: AudioGenerationFormat::Opus,
                exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy::Off,
                quiz_audio_policy: QuizAudioPolicy::Off,
                calibration_recall_rule: CalibrationRecallRule::Lenient,
                minimize_to_tray: false,
            }
        );
    }

    #[test]
    fn parse_app_settings_reads_the_deck_backup_policy() {
        let settings = parse_app_settings("deck_backup_policy=last-only\n");

        assert_eq!(
            settings,
            AppSettings {
                language: localization::Language::English,
                minimum_cards_per_session: 0,
                new_cards_per_day: 20,
                deck_backup_policy: DeckBackupPolicy::LastOnly,
                review_db_backup_policy: ReviewDbBackupPolicy::OnSessionStart,
                automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Off,
                sound_notification_policy: SoundNotificationPolicy::Never,
                review_audio_policy: ReviewAudioPolicy::Off,
                review_audio_cues: false,
                audio_generation_format: AudioGenerationFormat::Opus,
                exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy::Off,
                quiz_audio_policy: QuizAudioPolicy::Off,
                calibration_recall_rule: CalibrationRecallRule::Lenient,
                minimize_to_tray: false,
            }
        );
    }

    #[test]
    fn parse_app_settings_reads_the_review_database_backup_policy() {
        let settings = parse_app_settings("review_db_backup_policy=off\n");

        assert_eq!(
            settings,
            AppSettings {
                language: localization::Language::English,
                minimum_cards_per_session: 0,
                new_cards_per_day: 20,
                deck_backup_policy: DeckBackupPolicy::Full,
                review_db_backup_policy: ReviewDbBackupPolicy::Off,
                automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Off,
                sound_notification_policy: SoundNotificationPolicy::Never,
                review_audio_policy: ReviewAudioPolicy::Off,
                review_audio_cues: false,
                audio_generation_format: AudioGenerationFormat::Opus,
                exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy::Off,
                quiz_audio_policy: QuizAudioPolicy::Off,
                calibration_recall_rule: CalibrationRecallRule::Lenient,
                minimize_to_tray: false,
            }
        );
    }

    #[test]
    fn parse_app_settings_reads_automatic_full_deck_backup_policy() {
        let settings = parse_app_settings("automatic_full_deck_backup_policy=daily\n");

        assert_eq!(
            settings.automatic_full_deck_backup_policy,
            AutomaticFullDeckBackupPolicy::Daily
        );
    }

    #[test]
    fn parse_app_settings_reads_sound_notification_policy() {
        for (code, policy) in [
            ("always", SoundNotificationPolicy::Always),
            ("when-visible", SoundNotificationPolicy::WhenVisible),
            ("when-in-tray", SoundNotificationPolicy::WhenInTray),
            ("never", SoundNotificationPolicy::Never),
        ] {
            let settings = parse_app_settings(&format!("sound_notification_policy={code}\n"));

            assert_eq!(settings.sound_notification_policy, policy);
        }
    }

    #[test]
    fn parse_app_settings_reads_review_audio_policy() {
        for (code, policy) in [
            ("off", ReviewAudioPolicy::Off),
            ("manual", ReviewAudioPolicy::Manual),
            ("automatic", ReviewAudioPolicy::Automatic),
        ] {
            let settings = parse_app_settings(&format!("review_audio_policy={code}\n"));

            assert_eq!(settings.review_audio_policy, policy);
        }
    }

    #[test]
    fn parse_app_settings_reads_review_audio_cues() {
        assert!(parse_app_settings("review_audio_cues=true\n").review_audio_cues);
        assert!(!parse_app_settings("review_audio_cues=false\n").review_audio_cues);
    }

    #[test]
    fn parse_app_settings_reads_audio_generation_format() {
        assert_eq!(
            parse_app_settings("audio_generation_format=wav\n").audio_generation_format,
            AudioGenerationFormat::Wav
        );
        assert_eq!(
            parse_app_settings("audio_generation_format=opus\n").audio_generation_format,
            AudioGenerationFormat::Opus
        );
    }

    #[test]
    fn parse_app_settings_reads_exam_post_answer_audio_policy() {
        for (code, policy) in [
            ("off", ExamPostAnswerAudioPolicy::Off),
            ("question", ExamPostAnswerAudioPolicy::Question),
            ("answer", ExamPostAnswerAudioPolicy::Answer),
            ("both", ExamPostAnswerAudioPolicy::Both),
        ] {
            let settings = parse_app_settings(&format!("exam_post_answer_audio_policy={code}\n"));

            assert_eq!(settings.exam_post_answer_audio_policy, policy);
        }
    }

    #[test]
    fn parse_app_settings_reads_quiz_audio_policy() {
        for (code, policy) in [
            ("off", QuizAudioPolicy::Off),
            ("prompt", QuizAudioPolicy::Prompt),
            ("choices", QuizAudioPolicy::Choices),
            ("both", QuizAudioPolicy::Both),
        ] {
            let settings = parse_app_settings(&format!("quiz_audio_policy={code}\n"));

            assert_eq!(settings.quiz_audio_policy, policy);
        }
    }

    #[test]
    fn parse_app_settings_reads_calibration_recall_rule() {
        for (code, rule) in [
            ("lenient", CalibrationRecallRule::Lenient),
            ("strict", CalibrationRecallRule::Strict),
        ] {
            let settings = parse_app_settings(&format!("calibration_recall_rule={code}\n"));

            assert_eq!(settings.calibration_recall_rule, rule);
        }
    }

    #[test]
    fn parse_app_settings_migrates_legacy_sound_notification_bool() {
        assert_eq!(
            parse_app_settings("sound_notifications=true\n").sound_notification_policy,
            SoundNotificationPolicy::Always
        );
        assert_eq!(
            parse_app_settings("sound_notifications=false\n").sound_notification_policy,
            SoundNotificationPolicy::Never
        );
    }

    #[test]
    fn parse_app_settings_reads_minimize_to_tray() {
        let settings = parse_app_settings("minimize_to_tray=true\n");

        assert_eq!(
            settings,
            AppSettings {
                language: localization::Language::English,
                minimum_cards_per_session: 0,
                new_cards_per_day: 20,
                deck_backup_policy: DeckBackupPolicy::Full,
                review_db_backup_policy: ReviewDbBackupPolicy::OnSessionStart,
                automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Off,
                sound_notification_policy: SoundNotificationPolicy::Never,
                review_audio_policy: ReviewAudioPolicy::Off,
                review_audio_cues: false,
                audio_generation_format: AudioGenerationFormat::Opus,
                exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy::Off,
                quiz_audio_policy: QuizAudioPolicy::Off,
                calibration_recall_rule: CalibrationRecallRule::Lenient,
                minimize_to_tray: true,
            }
        );
    }

    #[test]
    fn parse_app_settings_ignores_unknown_and_invalid_lines() {
        let settings = parse_app_settings(
            "deck=spanish.cosimo-deck\nlanguage=unknown\nminimum_cards_per_session=nope\nnew_cards_per_day=20\n",
        );

        assert_eq!(settings, AppSettings::default());
    }

    #[test]
    fn effective_settings_use_system_language_when_no_language_is_saved() {
        let settings = effective_app_settings_from(
            parse_app_settings_without_default_language(
                "minimum_cards_per_session=3\nnew_cards_per_day=20\n",
            ),
            localization::Language::Spanish,
        );

        assert_eq!(
            settings,
            AppSettings {
                language: localization::Language::Spanish,
                minimum_cards_per_session: 3,
                new_cards_per_day: 20,
                deck_backup_policy: DeckBackupPolicy::Full,
                review_db_backup_policy: ReviewDbBackupPolicy::OnSessionStart,
                automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Off,
                sound_notification_policy: SoundNotificationPolicy::Never,
                review_audio_policy: ReviewAudioPolicy::Off,
                review_audio_cues: false,
                audio_generation_format: AudioGenerationFormat::Opus,
                exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy::Off,
                quiz_audio_policy: QuizAudioPolicy::Off,
                calibration_recall_rule: CalibrationRecallRule::Lenient,
                minimize_to_tray: false,
            }
        );
    }

    #[test]
    fn effective_settings_keep_saved_language_over_system_language() {
        let settings = effective_app_settings_from(
            parse_app_settings_without_default_language("language=en\n"),
            localization::Language::Spanish,
        );

        assert_eq!(
            settings,
            AppSettings {
                language: localization::Language::English,
                minimum_cards_per_session: 0,
                new_cards_per_day: 20,
                deck_backup_policy: DeckBackupPolicy::Full,
                review_db_backup_policy: ReviewDbBackupPolicy::OnSessionStart,
                automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Off,
                sound_notification_policy: SoundNotificationPolicy::Never,
                review_audio_policy: ReviewAudioPolicy::Off,
                review_audio_cues: false,
                audio_generation_format: AudioGenerationFormat::Opus,
                exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy::Off,
                quiz_audio_policy: QuizAudioPolicy::Off,
                calibration_recall_rule: CalibrationRecallRule::Lenient,
                minimize_to_tray: false,
            }
        );
    }

    #[test]
    fn locale_language_detection_uses_supported_languages_and_falls_back_to_english() {
        assert_eq!(
            language_for_locale_codes(["es-ES".to_string()]),
            localization::Language::Spanish
        );
        assert_eq!(
            language_for_locale_codes(["es_MX.UTF-8".to_string()]),
            localization::Language::Spanish
        );
        assert_eq!(
            language_for_locale_codes(["gl-ES".to_string()]),
            localization::Language::Galician
        );
        assert_eq!(
            language_for_locale_codes(["fr-FR".to_string(), "C".to_string()]),
            localization::Language::English
        );
    }

    #[test]
    fn format_app_settings_writes_the_language_code_and_minimum_cards() {
        assert_eq!(
            format_app_settings(AppSettings {
                language: localization::Language::English,
                minimum_cards_per_session: 7,
                new_cards_per_day: 20,
                deck_backup_policy: DeckBackupPolicy::None,
                review_db_backup_policy: ReviewDbBackupPolicy::Off,
                automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Off,
                sound_notification_policy: SoundNotificationPolicy::Never,
                review_audio_policy: ReviewAudioPolicy::Off,
                review_audio_cues: false,
                audio_generation_format: AudioGenerationFormat::Opus,
                exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy::Off,
                quiz_audio_policy: QuizAudioPolicy::Off,
                calibration_recall_rule: CalibrationRecallRule::Lenient,
                minimize_to_tray: false,
            }),
            "language=en\nminimum_cards_per_session=7\nnew_cards_per_day=20\ndeck_backup_policy=none\nreview_db_backup_policy=off\nautomatic_full_deck_backup_policy=off\nsound_notification_policy=never\nreview_audio_policy=off\nreview_audio_cues=false\naudio_generation_format=opus\nexam_post_answer_audio_policy=off\nquiz_audio_policy=off\ncalibration_recall_rule=lenient\nminimize_to_tray=false\n"
        );
    }

    #[test]
    fn parse_app_config_reads_settings_and_last_open_deck() {
        let config = parse_app_config(
            "[settings]\nlanguage=es\nminimum_cards_per_session=4\nnew_cards_per_day=20\ndeck_backup_policy=last-only\nreview_db_backup_policy=off\nautomatic_full_deck_backup_policy=off\nsound_notification_policy=always\nreview_audio_policy=off\ncalibration_recall_rule=lenient\nminimize_to_tray=false\n\n[state]\nlast_open_deck=/tmp/spanish.cosimo-deck\nready_card_filter=due\nready_card_sort=last-reviewed\nready_card_sort_direction=descending\n",
        );

        assert_eq!(
            config,
            AppConfig {
                settings: ParsedAppSettings {
                    language: Some(localization::Language::Spanish),
                    minimum_cards_per_session: 4,
                    new_cards_per_day: 20,
                    deck_backup_policy: DeckBackupPolicy::LastOnly,
                    review_db_backup_policy: ReviewDbBackupPolicy::Off,
                    automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Off,
                    sound_notification_policy: SoundNotificationPolicy::Always,
                    review_audio_policy: ReviewAudioPolicy::Off,
                    review_audio_cues: false,
                    audio_generation_format: AudioGenerationFormat::Opus,
                    exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy::Off,
                    quiz_audio_policy: QuizAudioPolicy::Off,
                    calibration_recall_rule: CalibrationRecallRule::Lenient,
                    minimize_to_tray: false,
                },
                last_open_deck_path: Some(PathBuf::from("/tmp/spanish.cosimo-deck")),
                recent_deck_paths: vec![PathBuf::from("/tmp/spanish.cosimo-deck")],
                ready_card_filter: ReadyCardFilter::Due,
                ready_card_sort: ReadyCardSort::LastReviewed,
                ready_card_sort_direction: ReadyCardSortDirection::Descending,
                sync_server_url: Some(DEFAULT_SYNC_SERVER_URL.to_string()),
                sync_server_url_disabled: false,
                local_device_name: None,
                sync_owner_identity: None,
                sync_bearer_token: None,
                deck_sync_states: Vec::new(),
            }
        );
    }

    #[test]
    fn parse_app_config_defaults_sort_direction_for_old_configs() {
        let config =
            parse_app_config("[state]\nready_card_filter=all\nready_card_sort=last-reviewed\n");

        assert_eq!(config.ready_card_sort, ReadyCardSort::LastReviewed);
        assert_eq!(
            config.ready_card_sort_direction,
            ReadyCardSortDirection::Descending
        );
    }

    #[test]
    fn parse_app_config_reads_recent_decks_most_recent_first() {
        let config = parse_app_config(
            "[state]\nlast_open_deck=/tmp/a.cosimo-deck\nrecent_deck=/tmp/a.cosimo-deck\nrecent_deck=/tmp/b.cosimo-deck\nrecent_deck=/tmp/a.cosimo-deck\n",
        );

        assert_eq!(
            config.recent_deck_paths,
            vec![
                PathBuf::from("/tmp/a.cosimo-deck"),
                PathBuf::from("/tmp/b.cosimo-deck"),
            ]
        );
    }

    #[test]
    fn parse_app_config_preserves_stored_recent_deck_order() {
        let config = parse_app_config(
            "[state]\nrecent_deck=/tmp/a.cosimo-deck\nrecent_deck=/tmp/b.cosimo-deck\n",
        );

        assert_eq!(
            config.recent_deck_paths,
            vec![
                PathBuf::from("/tmp/a.cosimo-deck"),
                PathBuf::from("/tmp/b.cosimo-deck"),
            ]
        );
    }

    #[test]
    fn recent_decks_are_limited_to_eight() {
        let mut recent_deck_paths = Vec::new();

        for index in 0..10 {
            push_recent_deck_path(
                &mut recent_deck_paths,
                PathBuf::from(format!("/tmp/{index}.cosimo-deck")),
            );
        }

        assert_eq!(recent_deck_paths.len(), MAX_RECENT_DECKS);
        assert_eq!(recent_deck_paths[0], PathBuf::from("/tmp/9.cosimo-deck"));
        assert_eq!(recent_deck_paths[7], PathBuf::from("/tmp/2.cosimo-deck"));
    }

    #[test]
    fn format_app_config_writes_settings_and_last_open_deck() {
        assert_eq!(
            format_app_config(
                AppSettings {
                    language: localization::Language::English,
                    minimum_cards_per_session: 7,
                    new_cards_per_day: 20,
                    deck_backup_policy: DeckBackupPolicy::None,
                    review_db_backup_policy: ReviewDbBackupPolicy::Off,
                    automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Off,
                    sound_notification_policy: SoundNotificationPolicy::Never,
                    review_audio_policy: ReviewAudioPolicy::Off,
                    review_audio_cues: false,
                    audio_generation_format: AudioGenerationFormat::Opus,
                    exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy::Off,
                    quiz_audio_policy: QuizAudioPolicy::Off,
                    calibration_recall_rule: CalibrationRecallRule::Lenient,
                    minimize_to_tray: false,
                },
                Some(Path::new("/tmp/spanish.cosimo-deck")),
            ),
            format!(
                "[settings]\nlanguage=en\nminimum_cards_per_session=7\nnew_cards_per_day=20\ndeck_backup_policy=none\nreview_db_backup_policy=off\nautomatic_full_deck_backup_policy=off\nsound_notification_policy=never\nreview_audio_policy=off\nreview_audio_cues=false\naudio_generation_format=opus\nexam_post_answer_audio_policy=off\nquiz_audio_policy=off\ncalibration_recall_rule=lenient\nminimize_to_tray=false\n\n[state]\nlast_open_deck=/tmp/spanish.cosimo-deck\nready_card_filter=all\nready_card_sort=deck-order\nready_card_sort_direction=ascending\n\n[sync]\nsync_server_url={DEFAULT_SYNC_SERVER_URL}\n"
            )
        );
    }

    #[test]
    fn load_app_settings_returns_defaults_when_missing() {
        let temp_dir = unique_temp_dir("load-settings-missing");
        let settings_path = temp_dir.join("cosimo.ini");

        assert_eq!(
            load_app_settings(&settings_path).unwrap(),
            AppSettings::default()
        );
    }

    #[test]
    fn persist_app_settings_writes_the_settings_file() {
        let temp_dir = unique_temp_dir("persist-settings");
        let settings_path = temp_dir.join("cosimo.ini");
        fs::write(
            &settings_path,
            "[state]\nlast_open_deck=/tmp/spanish.cosimo-deck\n",
        )
        .unwrap();
        let settings = AppSettings {
            language: localization::Language::English,
            minimum_cards_per_session: 9,
            new_cards_per_day: 20,
            deck_backup_policy: DeckBackupPolicy::LastOnly,
            review_db_backup_policy: ReviewDbBackupPolicy::Off,
            automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Off,
            sound_notification_policy: SoundNotificationPolicy::Always,
            review_audio_policy: ReviewAudioPolicy::Off,
            review_audio_cues: false,
            audio_generation_format: AudioGenerationFormat::Opus,
            exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy::Off,
            quiz_audio_policy: QuizAudioPolicy::Off,
            calibration_recall_rule: CalibrationRecallRule::Lenient,
            minimize_to_tray: false,
        };

        persist_app_settings(&settings_path, settings, english()).unwrap();

        assert_eq!(
            fs::read_to_string(settings_path).unwrap(),
            format!(
                "[settings]\nlanguage=en\nminimum_cards_per_session=9\nnew_cards_per_day=20\ndeck_backup_policy=last-only\nreview_db_backup_policy=off\nautomatic_full_deck_backup_policy=off\nsound_notification_policy=always\nreview_audio_policy=off\nreview_audio_cues=false\naudio_generation_format=opus\nexam_post_answer_audio_policy=off\nquiz_audio_policy=off\ncalibration_recall_rule=lenient\nminimize_to_tray=false\n\n[state]\nlast_open_deck=/tmp/spanish.cosimo-deck\nrecent_deck=/tmp/spanish.cosimo-deck\nready_card_filter=all\nready_card_sort=deck-order\nready_card_sort_direction=ascending\n\n[sync]\nsync_server_url={DEFAULT_SYNC_SERVER_URL}\n"
            )
        );
    }

    #[test]
    fn persist_app_settings_skips_write_when_settings_are_unchanged() {
        let temp_dir = unique_temp_dir("persist-settings-unchanged");
        let settings_path = temp_dir.join("cosimo.ini");
        let config_text = format!(
            "[settings]\nlanguage=en\nminimum_cards_per_session=9\nnew_cards_per_day=20\ndeck_backup_policy=last-only\nreview_db_backup_policy=off\nautomatic_full_deck_backup_policy=off\nsound_notification_policy=never\nreview_audio_policy=off\ncalibration_recall_rule=lenient\nminimize_to_tray=false\n\n[state]\nlast_open_deck=/tmp/spanish.cosimo-deck\nrecent_deck=/tmp/spanish.cosimo-deck\nready_card_filter=all\nready_card_sort=deck-order\nready_card_sort_direction=ascending\n; keep this comment\n\n[sync]\nsync_server_url={DEFAULT_SYNC_SERVER_URL}\n"
        );
        fs::write(&settings_path, &config_text).unwrap();
        let settings = AppSettings {
            language: localization::Language::English,
            minimum_cards_per_session: 9,
            new_cards_per_day: 20,
            deck_backup_policy: DeckBackupPolicy::LastOnly,
            review_db_backup_policy: ReviewDbBackupPolicy::Off,
            automatic_full_deck_backup_policy: AutomaticFullDeckBackupPolicy::Off,
            sound_notification_policy: SoundNotificationPolicy::Never,
            review_audio_policy: ReviewAudioPolicy::Off,
            review_audio_cues: false,
            audio_generation_format: AudioGenerationFormat::Opus,
            exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy::Off,
            quiz_audio_policy: QuizAudioPolicy::Off,
            calibration_recall_rule: CalibrationRecallRule::Lenient,
            minimize_to_tray: false,
        };

        persist_app_settings(&settings_path, settings, english()).unwrap();

        assert_eq!(fs::read_to_string(&settings_path).unwrap(), config_text);
    }

    #[test]
    fn migrating_legacy_portable_config_writes_ini_and_removes_old_files() {
        let temp_dir = unique_temp_dir("migrate-config");
        let config_path = temp_dir.join("cosimo.ini");
        let legacy_settings_path = temp_dir.join("cosimo-settings.txt");
        let legacy_last_open_deck_path = temp_dir.join("cosimo-last-open-deck.txt");
        let deck_path = temp_dir.join("spanish.cosimo-deck");
        fs::write(
            &legacy_settings_path,
            "language=es\nminimum_cards_per_session=6\nnew_cards_per_day=20\ndeck_backup_policy=last-only\nreview_db_backup_policy=off\nautomatic_full_deck_backup_policy=off\nsound_notification_policy=never\nreview_audio_policy=off\ncalibration_recall_rule=lenient\nminimize_to_tray=false\n",
        )
        .unwrap();
        fs::write(&legacy_last_open_deck_path, deck_path.display().to_string()).unwrap();

        migrate_legacy_portable_config(
            &config_path,
            &legacy_settings_path,
            &legacy_last_open_deck_path,
        )
        .unwrap();

        assert_eq!(
            fs::read_to_string(&config_path).unwrap(),
            format!(
                "[settings]\nlanguage=es\nminimum_cards_per_session=6\nnew_cards_per_day=20\ndeck_backup_policy=last-only\nreview_db_backup_policy=off\nautomatic_full_deck_backup_policy=off\nsound_notification_policy=never\nreview_audio_policy=off\nreview_audio_cues=false\naudio_generation_format=opus\nexam_post_answer_audio_policy=off\nquiz_audio_policy=off\ncalibration_recall_rule=lenient\nminimize_to_tray=false\n\n[state]\nlast_open_deck={}\nrecent_deck={}\nready_card_filter=all\nready_card_sort=deck-order\nready_card_sort_direction=ascending\n\n[sync]\nsync_server_url={DEFAULT_SYNC_SERVER_URL}\n",
                deck_path.display(),
                deck_path.display()
            )
        );
        assert!(!legacy_settings_path.exists());
        assert!(!legacy_last_open_deck_path.exists());
    }

    #[test]
    fn migrating_only_last_open_deck_does_not_pin_default_language() {
        let temp_dir = unique_temp_dir("migrate-last-open-only");
        let config_path = temp_dir.join("cosimo.ini");
        let legacy_settings_path = temp_dir.join("cosimo-settings.txt");
        let legacy_last_open_deck_path = temp_dir.join("cosimo-last-open-deck.txt");
        let deck_path = temp_dir.join("spanish.cosimo-deck");
        fs::write(&legacy_last_open_deck_path, deck_path.display().to_string()).unwrap();

        migrate_legacy_portable_config(
            &config_path,
            &legacy_settings_path,
            &legacy_last_open_deck_path,
        )
        .unwrap();

        assert_eq!(
            fs::read_to_string(&config_path).unwrap(),
            format!(
                "[settings]\nminimum_cards_per_session=0\nnew_cards_per_day=20\ndeck_backup_policy=full\nreview_db_backup_policy=on-session-start\nautomatic_full_deck_backup_policy=off\nsound_notification_policy=never\nreview_audio_policy=off\nreview_audio_cues=false\naudio_generation_format=opus\nexam_post_answer_audio_policy=off\nquiz_audio_policy=off\ncalibration_recall_rule=lenient\nminimize_to_tray=false\n\n[state]\nlast_open_deck={}\nrecent_deck={}\nready_card_filter=all\nready_card_sort=deck-order\nready_card_sort_direction=ascending\n\n[sync]\nsync_server_url={DEFAULT_SYNC_SERVER_URL}\n",
                deck_path.display(),
                deck_path.display()
            )
        );
        let config = load_app_config(&config_path).unwrap();
        assert_eq!(config.settings.language, None);
    }

    #[test]
    fn persist_ready_view_state_preserves_other_ini_state() {
        let temp_dir = unique_temp_dir("persist-ready-view-state");
        let config_path = temp_dir.join("cosimo.ini");
        fs::write(
            &config_path,
            "[settings]\nlanguage=es\nminimum_cards_per_session=5\nnew_cards_per_day=20\ndeck_backup_policy=last-only\nreview_db_backup_policy=off\nautomatic_full_deck_backup_policy=off\nsound_notification_policy=never\nreview_audio_policy=off\ncalibration_recall_rule=lenient\nminimize_to_tray=false\n\n[state]\nlast_open_deck=/tmp/deck.cosimo-deck\nrecent_deck=/tmp/deck.cosimo-deck\n",
        )
        .unwrap();

        persist_ready_view_state(
            &config_path,
            ReadyCardFilter::NotDue,
            ReadyCardSort::LastReviewed,
            ReadyCardSortDirection::Descending,
            english(),
        )
        .unwrap();

        assert_eq!(
            fs::read_to_string(&config_path).unwrap(),
            format!(
                "[settings]\nlanguage=es\nminimum_cards_per_session=5\nnew_cards_per_day=20\ndeck_backup_policy=last-only\nreview_db_backup_policy=off\nautomatic_full_deck_backup_policy=off\nsound_notification_policy=never\nreview_audio_policy=off\nreview_audio_cues=false\naudio_generation_format=opus\nexam_post_answer_audio_policy=off\nquiz_audio_policy=off\ncalibration_recall_rule=lenient\nminimize_to_tray=false\n\n[state]\nlast_open_deck=/tmp/deck.cosimo-deck\nrecent_deck=/tmp/deck.cosimo-deck\nready_card_filter=not-due\nready_card_sort=last-reviewed\nready_card_sort_direction=descending\n\n[sync]\nsync_server_url={DEFAULT_SYNC_SERVER_URL}\n"
            )
        );
    }

    #[test]
    fn persist_ready_view_state_skips_write_when_view_state_is_unchanged() {
        let temp_dir = unique_temp_dir("persist-ready-view-state-unchanged");
        let config_path = temp_dir.join("cosimo.ini");
        let config_text = format!(
            "[settings]\nlanguage=es\nminimum_cards_per_session=5\nnew_cards_per_day=20\ndeck_backup_policy=last-only\nreview_db_backup_policy=off\nautomatic_full_deck_backup_policy=off\nsound_notification_policy=never\nreview_audio_policy=off\ncalibration_recall_rule=lenient\nminimize_to_tray=false\n\n[state]\nlast_open_deck=/tmp/deck.cosimo-deck\nrecent_deck=/tmp/deck.cosimo-deck\nready_card_filter=not-due\nready_card_sort=next-scheduled-review\nready_card_sort_direction=ascending\n; keep this comment\n\n[sync]\nsync_server_url={DEFAULT_SYNC_SERVER_URL}\n"
        );
        fs::write(&config_path, &config_text).unwrap();

        persist_ready_view_state(
            &config_path,
            ReadyCardFilter::NotDue,
            ReadyCardSort::NextScheduledReview,
            ReadyCardSortDirection::Ascending,
            english(),
        )
        .unwrap();

        assert_eq!(fs::read_to_string(&config_path).unwrap(), config_text);
    }

    #[test]
    fn format_count_and_percentage_handles_zero_and_non_zero_totals() {
        assert_eq!(format_count_and_percentage(0, 0), "0 (0.0%)");
        assert_eq!(format_count_and_percentage(1, 4), "1 (25.0%)");
        assert_eq!(format_count_and_percentage(2, 3), "2 (66.7%)");
    }

    #[test]
    fn summary_text_includes_percentages() {
        let mut stats = ReviewCycleStats::default();
        stats.record(
            Review::new(PromptGuess::NotAnswered, RecallFeedback::Good),
            true,
        );
        stats.record(
            Review::new(PromptGuess::DontKnow, RecallFeedback::Again),
            true,
        );
        stats.record(
            Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
            false,
        );

        let summary = stats.summary_text(english(), 4, 125, true, true);

        assert!(summary.contains("Unique cards studied: 2"));
        assert!(summary.contains("Questions answered: 3 of 4"));
        assert!(summary.contains("Total time: 2 minutes, 5 seconds"));
        assert!(summary.contains("Time per question: 42 seconds"));
        assert!(summary.contains("Time per unique card: 1 minute, 3 seconds"));
        assert!(summary.contains("Not answered: 1 (33.3%)"));
        assert!(summary.contains("1. Don't know: 1 (33.3%)"));
        assert!(summary.contains("3. Probably know: 1 (33.3%)"));
        assert!(summary.contains("1. Again: 1 (33.3%)"));
        assert!(summary.contains("3. Good: 1 (33.3%)"));
        assert!(summary.contains("4. Easy: 1 (33.3%)"));
        assert!(summary.contains("Ended a partial scheduled study session."));
        assert!(!summary.contains("Choose Continue"));
    }

    #[test]
    fn summary_text_uses_compact_counts_when_no_cards_repeat() {
        let mut stats = ReviewCycleStats::default();
        stats.record(
            Review::new(PromptGuess::NotAnswered, RecallFeedback::Good),
            true,
        );
        stats.record(
            Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
            true,
        );

        let summary = stats.summary_text(english(), 2, 61, false, true);

        assert!(summary.contains("Cards studied: 2 of 2"));
        assert!(summary.contains("Total time: 1 minute, 1 second"));
        assert!(summary.contains("Time per card: 31 seconds"));
        assert!(!summary.contains("Unique cards studied:"));
        assert!(!summary.contains("Questions answered:"));
        assert!(!summary.contains("Time per question:"));
        assert!(!summary.contains("Time per unique card:"));
    }

    #[test]
    fn summary_text_distinguishes_unscheduled_completion() {
        let mut stats = ReviewCycleStats::default();
        stats.record(
            Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
            true,
        );

        let summary = stats.summary_text(english(), 1, 0, false, false);

        assert!(summary.contains("Completed one unscheduled study session."));
        assert!(!summary.contains("Completed one scheduled study session."));
    }

    #[test]
    fn summary_text_distinguishes_unscheduled_early_end() {
        let mut stats = ReviewCycleStats::default();
        stats.record(
            Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
            true,
        );

        let summary = stats.summary_text(english(), 2, 0, true, false);

        assert!(summary.contains("Ended a partial unscheduled study session."));
        assert!(!summary.contains("Ended a partial scheduled study session."));
    }

    #[test]
    fn ready_to_start_summary_text_uses_the_current_card_count() {
        let review_db_path = Path::new("reviews.sqlite3");

        assert!(
            ready_to_start_summary_text(english(), 2, review_db_path, 0, 20, Some(1), Some(1))
                .contains("Deck: 2 cards")
        );
        assert!(
            ready_to_start_summary_text(english(), 3, review_db_path, 4, 20, Some(4), Some(4))
                .contains("Deck: 3 cards")
        );
        assert!(
            ready_to_start_summary_text(english(), 3, review_db_path, 4, 20, Some(4), Some(4))
                .contains("Minimum per session: 4")
        );
        assert!(
            ready_to_start_summary_text(english(), 3, review_db_path, 4, 20, Some(4), Some(4))
                .contains("Scheduled now: 4")
        );
        assert!(
            ready_to_start_summary_text(english(), 3, review_db_path, 0, 20, Some(0), Some(0))
                .contains("No cards are due")
        );
        assert!(
            ready_to_start_summary_text(english(), 3, review_db_path, 4, 20, Some(4), Some(2))
                .contains("Scheduled now: 4 (2 due)")
        );
        assert!(
            ready_to_start_summary_text(
                Localizer::new(localization::Language::Spanish),
                3,
                review_db_path,
                4,
                20,
                Some(4),
                Some(2)
            )
            .contains("Programadas ahora: 4 (2 pendientes)")
        );
        assert!(
            ready_to_start_summary_text(
                Localizer::new(localization::Language::Spanish),
                3,
                review_db_path,
                4,
                20,
                Some(4),
                Some(1)
            )
            .contains("Programadas ahora: 4 (1 pendiente)")
        );
    }

    #[test]
    fn relative_duration_uses_speech_friendly_largest_units() {
        assert_eq!(format_relative_duration_rounded(english(), 59), "1 minute");
        assert_eq!(
            format_relative_duration_rounded(english(), 61 * 60),
            "2 hours"
        );
        assert_eq!(
            format_relative_duration_rounded(english(), 25 * 60 * 60),
            "2 days"
        );
        assert_eq!(
            format_relative_duration_rounded(english(), 23 * 24 * 60 * 60),
            "4 weeks"
        );
        assert_eq!(
            format_relative_duration_rounded(english(), 56 * 24 * 60 * 60),
            "2 months"
        );
        assert_eq!(
            format_relative_duration_rounded(
                Localizer::new(localization::Language::Spanish),
                25 * 60 * 60,
            ),
            "2 días"
        );
    }

    #[test]
    fn complete_relative_duration_includes_all_non_zero_units() {
        assert_eq!(
            format_relative_duration_complete(english(), 90_061),
            "1 day, 1 hour, 1 minute, 1 second"
        );
        assert_eq!(
            format_relative_duration_complete(Localizer::new(localization::Language::Spanish), 61),
            "1 minuto, 1 segundo"
        );
    }

    #[test]
    fn main_status_bar_reports_due_now_and_next_review() {
        let now = 1_000_000;
        let (empty_state, _temp_dir) = make_state(vec![]);
        assert_eq!(
            main_status_bar_text(&empty_state, now).unwrap(),
            "Cards: 0 total; no review scheduled."
        );

        let new_card = datum("Capital of Spain", "Madrid");
        let (new_state, _temp_dir) = make_state(vec![new_card]);
        assert_eq!(
            main_status_bar_text(&new_state, now).unwrap(),
            "Cards: 1 total; due: 1; cards due now."
        );

        let not_due_card = datum("Capital of France", "Paris");
        let (not_due_state, _temp_dir) = make_state(vec![not_due_card.clone()]);
        record_review_at(
            &not_due_state,
            &not_due_card,
            now,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );

        assert_eq!(
            main_status_bar_text(&not_due_state, now).unwrap(),
            "Cards: 1 total; due: 0; next scheduled review in 2 weeks."
        );

        let suspended_due_card = datum("Capital of Portugal", "Lisbon");
        let active_not_due_card = datum("Capital of France", "Paris");
        let (mixed_state, _temp_dir) = make_state(vec![
            suspended_due_card.clone(),
            active_not_due_card.clone(),
        ]);
        mixed_state
            .store
            .set_datum_suspended(&DatumKey::for_datum(&suspended_due_card), true)
            .unwrap();
        record_review_at(
            &mixed_state,
            &active_not_due_card,
            now,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );

        assert_eq!(
            main_status_bar_text(&mixed_state, now).unwrap(),
            "Cards: 2 total; due: 0; next scheduled review in 2 weeks."
        );
    }

    #[test]
    fn main_status_bar_reports_session_type_and_progress_while_running() {
        let now = 1_000_000;
        let (mut state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);

        state.start_cycle_with_config(SchedulerConfig::new(0, now, 17));

        assert_eq!(
            main_status_bar_text(&state, now).unwrap(),
            "Scheduled study session: 0 of 2 studied."
        );

        state.show_response(PromptGuess::ProbablyKnow);
        assert!(
            state
                .record_feedback_and_check_for_cycle_completion(RecallFeedback::Good)
                .is_none()
        );

        assert_eq!(
            main_status_bar_text(&state, now).unwrap(),
            "Scheduled study session: 1 of 2 studied."
        );

        let (mut unscheduled_state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);
        unscheduled_state.force_start_cycle_with_seed(17, now);
        assert_eq!(
            main_status_bar_text(&unscheduled_state, now).unwrap(),
            "Unscheduled study session: 0 of 2 studied."
        );

        let (mut exam_state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);
        let exam_request = crate::exam_mode::ExamSizeRequest::new(
            crate::exam_mode::ExamSizeMode::QuestionCount,
            2,
        );
        exam_state
            .start_exam_pass_with_request(exam_request, 17, now)
            .expect("exam pass");
        assert_eq!(
            main_status_bar_text(&exam_state, now).unwrap(),
            "Exam: 0 of 2 answered."
        );

        let (mut quiz_state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
            datum("Capital of Italy", "Rome"),
            datum("Capital of Portugal", "Lisbon"),
        ]);
        let quiz_request = crate::quiz_mode::QuizSizeRequest::new(
            crate::quiz_mode::QuizSizeMode::QuestionCount,
            2,
        );
        quiz_state
            .start_quiz_pass_with_request(quiz_request, 17, now)
            .expect("quiz pass");
        assert_eq!(
            main_status_bar_text(&quiz_state, now).unwrap(),
            "Quiz: 0 of 2 answered."
        );
    }

    #[test]
    fn main_status_bar_reflects_suspension_toggles() {
        let now = 1_000_000;
        let reviewed_card = datum("Capital of Spain", "Madrid");
        let (mut state, _temp_dir) = make_state(vec![reviewed_card.clone()]);
        record_review_at(
            &state,
            &reviewed_card,
            now,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );

        assert_eq!(
            main_status_bar_text(&state, now).unwrap(),
            "Cards: 1 total; due: 0; next scheduled review in 2 weeks."
        );

        state.toggle_selected_card_suspension().unwrap();
        assert_eq!(
            main_status_bar_text(&state, now).unwrap(),
            "Cards: 1 total; no review scheduled."
        );

        state.toggle_selected_card_suspension().unwrap();
        assert_eq!(
            main_status_bar_text(&state, now).unwrap(),
            "Cards: 1 total; due: 0; next scheduled review in 2 weeks."
        );
    }

    #[test]
    fn main_status_bar_ignores_currently_deferred_cards() {
        let now = 1_000_000;
        let reviewed_card = datum("Capital of Spain", "Madrid");
        let (mut state, _temp_dir) = make_state(vec![reviewed_card.clone()]);
        record_review_at(
            &state,
            &reviewed_card,
            now,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );

        assert_eq!(
            main_status_bar_text(&state, now).unwrap(),
            "Cards: 1 total; due: 0; next scheduled review in 2 weeks."
        );

        state.toggle_selected_card_deferral().unwrap();
        assert_eq!(
            main_status_bar_text(&state, now).unwrap(),
            "Cards: 1 total; no review scheduled."
        );
    }

    #[test]
    fn language_selection_index_maps_known_languages() {
        assert_eq!(
            language_selection_index(localization::Language::English),
            Some(0)
        );
        assert_eq!(
            language_selection_index(localization::Language::Spanish),
            Some(1)
        );
        assert_eq!(
            language_selection_index(localization::Language::Galician),
            Some(2)
        );
    }

    #[test]
    fn deck_backup_policy_selection_index_maps_known_policies() {
        assert_eq!(
            deck_backup_policy_selection_index(DeckBackupPolicy::Full),
            Some(0)
        );
        assert_eq!(
            deck_backup_policy_selection_index(DeckBackupPolicy::LastOnly),
            Some(1)
        );
        assert_eq!(
            deck_backup_policy_selection_index(DeckBackupPolicy::None),
            Some(2)
        );
    }

    #[test]
    fn review_db_backup_policy_selection_index_maps_known_policies() {
        assert_eq!(
            review_db_backup_policy_selection_index(ReviewDbBackupPolicy::OnSessionStart),
            Some(0)
        );
        assert_eq!(
            review_db_backup_policy_selection_index(ReviewDbBackupPolicy::Off),
            Some(1)
        );
    }

    #[test]
    fn automatic_full_deck_backup_policy_selection_index_maps_known_policies() {
        assert_eq!(
            automatic_full_deck_backup_policy_selection_index(AutomaticFullDeckBackupPolicy::Off),
            Some(0)
        );
        assert_eq!(
            automatic_full_deck_backup_policy_selection_index(AutomaticFullDeckBackupPolicy::Daily),
            Some(1)
        );
    }

    #[test]
    fn sound_notification_policy_selection_index_maps_known_policies() {
        assert_eq!(
            sound_notification_policy_selection_index(SoundNotificationPolicy::Never),
            Some(0)
        );
        assert_eq!(
            sound_notification_policy_selection_index(SoundNotificationPolicy::WhenInTray),
            Some(1)
        );
        assert_eq!(
            sound_notification_policy_selection_index(SoundNotificationPolicy::WhenVisible),
            Some(2)
        );
        assert_eq!(
            sound_notification_policy_selection_index(SoundNotificationPolicy::Always),
            Some(3)
        );
    }

    #[test]
    fn review_audio_policy_selection_index_maps_known_policies() {
        assert_eq!(
            review_audio_policy_selection_index(ReviewAudioPolicy::Off),
            Some(0)
        );
        assert_eq!(
            review_audio_policy_selection_index(ReviewAudioPolicy::Manual),
            Some(1)
        );
        assert_eq!(
            review_audio_policy_selection_index(ReviewAudioPolicy::Automatic),
            Some(2)
        );
    }

    #[test]
    fn exam_post_answer_audio_policy_selection_index_maps_known_policies() {
        assert_eq!(
            exam_post_answer_audio_policy_selection_index(ExamPostAnswerAudioPolicy::Off),
            Some(0)
        );
        assert_eq!(
            exam_post_answer_audio_policy_selection_index(ExamPostAnswerAudioPolicy::Question),
            Some(1)
        );
        assert_eq!(
            exam_post_answer_audio_policy_selection_index(ExamPostAnswerAudioPolicy::Answer),
            Some(2)
        );
        assert_eq!(
            exam_post_answer_audio_policy_selection_index(ExamPostAnswerAudioPolicy::Both),
            Some(3)
        );
    }

    #[test]
    fn quiz_audio_policy_selection_index_maps_known_policies() {
        assert_eq!(
            quiz_audio_policy_selection_index(QuizAudioPolicy::Off),
            Some(0)
        );
        assert_eq!(
            quiz_audio_policy_selection_index(QuizAudioPolicy::Prompt),
            Some(1)
        );
        assert_eq!(
            quiz_audio_policy_selection_index(QuizAudioPolicy::Choices),
            Some(2)
        );
        assert_eq!(
            quiz_audio_policy_selection_index(QuizAudioPolicy::Both),
            Some(3)
        );
    }

    #[test]
    fn calibration_recall_rule_selection_index_maps_known_rules() {
        assert_eq!(
            calibration_recall_rule_selection_index(CalibrationRecallRule::Lenient),
            Some(0)
        );
        assert_eq!(
            calibration_recall_rule_selection_index(CalibrationRecallRule::Strict),
            Some(1)
        );
    }

    #[test]
    fn options_combo_box_defaults_are_first_choices() {
        assert_eq!(
            deck_backup_policy_selection_index(DeckBackupPolicy::default()),
            Some(0)
        );
        assert_eq!(
            review_db_backup_policy_selection_index(ReviewDbBackupPolicy::default()),
            Some(0)
        );
        assert_eq!(
            automatic_full_deck_backup_policy_selection_index(
                AutomaticFullDeckBackupPolicy::default()
            ),
            Some(0)
        );
        assert_eq!(
            sound_notification_policy_selection_index(SoundNotificationPolicy::default()),
            Some(0)
        );
        assert_eq!(
            review_audio_policy_selection_index(ReviewAudioPolicy::default()),
            Some(0)
        );
        assert_eq!(
            exam_post_answer_audio_policy_selection_index(ExamPostAnswerAudioPolicy::default()),
            Some(0)
        );
        assert_eq!(
            quiz_audio_policy_selection_index(QuizAudioPolicy::default()),
            Some(0)
        );
        assert_eq!(
            calibration_recall_rule_selection_index(CalibrationRecallRule::default()),
            Some(0)
        );
    }

    #[test]
    fn ready_card_labels_include_cards_added_to_the_deck() {
        let (mut state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);

        state.add_card(datum("Capital of France", "Paris")).unwrap();

        assert_eq!(
            ready_card_labels(&state.data),
            vec![
                "Capital of Spain".to_string(),
                "Capital of France".to_string(),
            ]
        );
    }

    #[test]
    fn ready_card_labels_include_cards_added_to_an_empty_deck() {
        let (mut state, _temp_dir) = make_state(vec![]);

        state.add_card(datum("Capital of France", "Paris")).unwrap();

        assert_eq!(
            ready_card_labels(&state.data),
            vec!["Capital of France".to_string()]
        );
        assert_eq!(state.selected_ready_index, Some(0));
    }

    #[test]
    fn ready_card_labels_reflect_edited_prompts() {
        let (mut state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);

        state
            .edit_selected_card(datum("Capital of Spain and Aragon", "Madrid"))
            .unwrap();

        assert_eq!(
            ready_card_labels(&state.data),
            vec!["Capital of Spain and Aragon".to_string()]
        );
    }

    #[test]
    fn ready_card_labels_reflect_removed_cards() {
        let (mut state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);

        state.remove_selected_card().unwrap();

        assert_eq!(
            ready_card_labels(&state.data),
            vec!["Capital of France".to_string()]
        );
    }

    #[test]
    fn ready_card_labels_mark_suspended_cards() {
        let active = datum_with_id(1, "Capital of Spain", "Madrid");
        let suspended_card = datum_with_id(2, "Capital of France", "Paris");
        let data = vec![active, suspended_card.clone()];
        let mut suspended = HashSet::new();
        suspended.insert(DatumKey::for_datum(&suspended_card));
        let flagged = HashSet::new();

        assert_eq!(
            ready_card_labels_for_indices(
                &data,
                &[0, 1],
                &suspended,
                &flagged,
                &HashSet::new(),
                english(),
            ),
            vec![
                "Capital of Spain".to_string(),
                "Capital of France (suspended)".to_string(),
            ]
        );
        assert_eq!(
            ready_card_labels_for_indices(
                &data,
                &[1],
                &suspended,
                &flagged,
                &HashSet::new(),
                Localizer::new(localization::Language::Spanish),
            ),
            vec!["Capital of France (suspendida)".to_string()]
        );
    }

    #[test]
    fn ready_card_labels_mark_flagged_cards() {
        let active = datum_with_id(1, "Capital of Spain", "Madrid");
        let flagged_card = datum_with_id(2, "Capital of France", "Paris");
        let data = vec![active, flagged_card.clone()];
        let suspended = HashSet::new();
        let mut flagged = HashSet::new();
        flagged.insert(DatumKey::for_datum(&flagged_card));

        assert_eq!(
            ready_card_labels_for_indices(
                &data,
                &[0, 1],
                &suspended,
                &flagged,
                &HashSet::new(),
                english(),
            ),
            vec![
                "Capital of Spain".to_string(),
                "Capital of France (flagged)".to_string(),
            ]
        );
        assert_eq!(
            ready_card_labels_for_indices(
                &data,
                &[1],
                &suspended,
                &flagged,
                &HashSet::new(),
                Localizer::new(localization::Language::Spanish),
            ),
            vec!["Capital of France (marcada)".to_string()]
        );
    }

    #[test]
    fn ready_card_labels_mark_non_reversible_cards() {
        let active = datum_with_id(1, "Capital of Spain", "Madrid");
        let non_reversible_card = datum_with_id(2, "Capital of France", "Paris");
        let data = vec![active, non_reversible_card.clone()];
        let suspended = HashSet::new();
        let flagged = HashSet::new();
        let mut non_reversible = HashSet::new();
        non_reversible.insert(DatumKey::for_datum(&non_reversible_card));

        assert_eq!(
            ready_card_labels_for_indices(
                &data,
                &[0, 1],
                &suspended,
                &flagged,
                &non_reversible,
                english(),
            ),
            vec![
                "Capital of Spain".to_string(),
                "Capital of France (non-reversible)".to_string(),
            ]
        );
        assert_eq!(
            ready_card_labels_for_indices(
                &data,
                &[1],
                &suspended,
                &flagged,
                &non_reversible,
                Localizer::new(localization::Language::Spanish),
            ),
            vec!["Capital of France (no reversible)".to_string()]
        );
    }

    #[test]
    fn ready_card_filter_indices_distinguish_due_new_and_not_due() {
        let now = 1_800_000_000;
        let new_card = datum_with_id(1, "New", "Answer");
        let not_due_card = datum_with_id(2, "Not due", "Answer");
        let due_card = datum_with_id(3, "Due", "Answer");
        let data = vec![new_card.clone(), not_due_card.clone(), due_card.clone()];
        let successful_review = Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy);
        let mut schedules = HashMap::new();
        schedules.insert(
            DatumKey::for_datum(&not_due_card),
            CardSchedule::new(DatumKey::for_datum(&not_due_card)).reviewed(&successful_review, now),
        );
        schedules.insert(
            DatumKey::for_datum(&due_card),
            CardSchedule::new(DatumKey::for_datum(&due_card))
                .reviewed(&successful_review, now - 9 * TEST_DAY_SECONDS),
        );
        let mut suspended = HashSet::new();
        suspended.insert(DatumKey::for_datum(&due_card));
        let flagged = HashSet::new();

        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::All,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![0, 1, 2]
        );
        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::New,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![0]
        );
        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::Due,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![0]
        );
        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::NotDue,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![1]
        );
        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::Suspended,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![2]
        );
    }

    #[test]
    fn ready_card_filter_can_show_flagged_cards() {
        let now = 1_800_000_000;
        let active = datum_with_id(1, "Active", "Answer");
        let flagged_card = datum_with_id(2, "Flagged", "Answer");
        let data = vec![active, flagged_card.clone()];
        let schedules = HashMap::new();
        let suspended = HashSet::new();
        let mut flagged = HashSet::new();
        flagged.insert(DatumKey::for_datum(&flagged_card));

        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::Flagged,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![1]
        );
        assert_eq!(
            ready_card_filter_counts(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                "",
                now
            ),
            [2, 2, 2, 0, 0, 1, 0, 0, 0, 0, 2, 0, 0, 0, 0]
        );
    }

    #[test]
    fn ready_card_filter_can_show_non_reversible_cards() {
        let now = 1_800_000_000;
        let active = datum_with_id(1, "Active", "Answer");
        let non_reversible_card = datum_with_id(2, "Non reversible", "Answer");
        let data = vec![active, non_reversible_card.clone()];
        let schedules = HashMap::new();
        let suspended = HashSet::new();
        let flagged = HashSet::new();
        let mut non_reversible = HashSet::new();
        non_reversible.insert(DatumKey::for_datum(&non_reversible_card));

        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &non_reversible,
                ReadyCardFilter::NonReversible,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![1]
        );
        assert_eq!(
            ready_card_filter_counts(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &non_reversible,
                "",
                now,
            ),
            [2, 2, 2, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 0, 0]
        );
    }

    #[test]
    fn ready_card_sort_orders_by_next_scheduled_review() {
        let now = 1_800_000_000;
        let later_card = datum_with_id(1, "Later", "Answer");
        let new_card = datum_with_id(2, "New", "Answer");
        let sooner_card = datum_with_id(3, "Sooner", "Answer");
        let data = vec![later_card.clone(), new_card.clone(), sooner_card.clone()];
        let successful_review = Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy);
        let mut schedules = HashMap::new();
        schedules.insert(
            DatumKey::for_datum(&later_card),
            CardSchedule::new(DatumKey::for_datum(&later_card))
                .reviewed(&successful_review, now + 24 * 60 * 60),
        );
        schedules.insert(
            DatumKey::for_datum(&sooner_card),
            CardSchedule::new(DatumKey::for_datum(&sooner_card)).reviewed(&successful_review, now),
        );
        let suspended = HashSet::new();
        let flagged = HashSet::new();

        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::All,
                "",
                ReadyCardSort::NextScheduledReview,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![1, 2, 0]
        );
    }

    #[test]
    fn ready_card_sort_direction_can_reverse_next_scheduled_review_order() {
        let now = 1_800_000_000;
        let later_card = datum_with_id(1, "Later", "Answer");
        let new_card = datum_with_id(2, "New", "Answer");
        let sooner_card = datum_with_id(3, "Sooner", "Answer");
        let data = vec![later_card.clone(), new_card.clone(), sooner_card.clone()];
        let successful_review = Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy);
        let mut schedules = HashMap::new();
        schedules.insert(
            DatumKey::for_datum(&later_card),
            CardSchedule::new(DatumKey::for_datum(&later_card))
                .reviewed(&successful_review, now + 24 * 60 * 60),
        );
        schedules.insert(
            DatumKey::for_datum(&sooner_card),
            CardSchedule::new(DatumKey::for_datum(&sooner_card)).reviewed(&successful_review, now),
        );
        let suspended = HashSet::new();
        let flagged = HashSet::new();

        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::All,
                "",
                ReadyCardSort::NextScheduledReview,
                ReadyCardSortDirection::Descending,
                now
            ),
            vec![0, 2, 1]
        );
    }

    #[test]
    fn ready_card_sort_orders_by_last_reviewed_with_new_cards_last() {
        let now = 1_800_000_000;
        let older_card = datum_with_id(1, "Older", "Answer");
        let new_card = datum_with_id(2, "New", "Answer");
        let newer_card = datum_with_id(3, "Newer", "Answer");
        let data = vec![older_card.clone(), new_card.clone(), newer_card.clone()];
        let successful_review = Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy);
        let mut schedules = HashMap::new();
        schedules.insert(
            DatumKey::for_datum(&older_card),
            CardSchedule::new(DatumKey::for_datum(&older_card))
                .reviewed(&successful_review, now - 600),
        );
        schedules.insert(
            DatumKey::for_datum(&newer_card),
            CardSchedule::new(DatumKey::for_datum(&newer_card)).reviewed(&successful_review, now),
        );
        let suspended = HashSet::new();
        let flagged = HashSet::new();

        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::All,
                "",
                ReadyCardSort::LastReviewed,
                ReadyCardSortDirection::Descending,
                now
            ),
            vec![2, 0, 1]
        );
    }

    #[test]
    fn ready_card_sort_direction_can_show_oldest_reviewed_first() {
        let now = 1_800_000_000;
        let older_card = datum_with_id(1, "Older", "Answer");
        let new_card = datum_with_id(2, "New", "Answer");
        let newer_card = datum_with_id(3, "Newer", "Answer");
        let data = vec![older_card.clone(), new_card.clone(), newer_card.clone()];
        let successful_review = Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy);
        let mut schedules = HashMap::new();
        schedules.insert(
            DatumKey::for_datum(&older_card),
            CardSchedule::new(DatumKey::for_datum(&older_card))
                .reviewed(&successful_review, now - 600),
        );
        schedules.insert(
            DatumKey::for_datum(&newer_card),
            CardSchedule::new(DatumKey::for_datum(&newer_card)).reviewed(&successful_review, now),
        );
        let suspended = HashSet::new();
        let flagged = HashSet::new();

        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::All,
                "",
                ReadyCardSort::LastReviewed,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![0, 2, 1]
        );
    }

    #[test]
    fn ready_card_sort_orders_by_prompt_text() {
        let now = 1_800_000_000;
        let data = vec![
            datum_with_id(1, "gamma", "three"),
            datum_with_id(2, "Alpha", "one"),
            datum_with_id(3, "beta", "two"),
        ];
        let schedules = HashMap::new();
        let suspended = HashSet::new();
        let flagged = HashSet::new();

        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::All,
                "",
                ReadyCardSort::PromptText,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![1, 2, 0]
        );
        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::All,
                "",
                ReadyCardSort::PromptText,
                ReadyCardSortDirection::Descending,
                now
            ),
            vec![0, 2, 1]
        );
    }

    #[test]
    fn ready_card_sort_orders_by_response_text() {
        let now = 1_800_000_000;
        let data = vec![
            datum_with_id(1, "one", "gamma"),
            datum_with_id(2, "two", "Alpha"),
            datum_with_id(3, "three", "beta"),
        ];
        let schedules = HashMap::new();
        let suspended = HashSet::new();
        let flagged = HashSet::new();

        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::All,
                "",
                ReadyCardSort::ResponseText,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![1, 2, 0]
        );
    }

    #[test]
    fn ready_card_sort_orders_by_estimated_recall_with_new_cards_last() {
        let now = 1_800_000_000;
        let high_recall = datum_with_id(1, "High", "Answer");
        let new_card = datum_with_id(2, "New", "Answer");
        let low_recall = datum_with_id(3, "Low", "Answer");
        let data = vec![high_recall.clone(), new_card, low_recall.clone()];
        let mut schedules = HashMap::new();
        let high_key = DatumKey::for_datum(&high_recall);
        let low_key = DatumKey::for_datum(&low_recall);
        schedules.insert(
            high_key.clone(),
            CardSchedule::from_storage(
                high_key,
                cosimo::CardMemoryState::Review,
                now + 60 * 60,
                10.0,
                5.0,
                0,
                10,
                10 * 24 * 60 * 60,
                1,
                0,
                Some(now),
                Some(RecallFeedback::Easy),
            ),
        );
        schedules.insert(
            low_key.clone(),
            CardSchedule::from_storage(
                low_key,
                cosimo::CardMemoryState::Review,
                now - 60 * 60,
                1.0,
                5.0,
                10,
                1,
                24 * 60 * 60,
                1,
                0,
                Some(now - 10 * 24 * 60 * 60),
                Some(RecallFeedback::Hard),
            ),
        );
        let suspended = HashSet::new();
        let flagged = HashSet::new();

        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::All,
                "",
                ReadyCardSort::EstimatedRecall,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![2, 0, 1]
        );
        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::All,
                "",
                ReadyCardSort::EstimatedRecall,
                ReadyCardSortDirection::Descending,
                now
            ),
            vec![0, 2, 1]
        );
    }

    #[test]
    fn ready_study_item_sort_orders_reverse_cards_by_their_own_last_review() {
        let now = 1_800_000_000;
        let data = vec![datum_with_id(1, "one", "uno")];
        let items = study_items_for_deck(&data, &HashSet::from([DatumKey::for_card_id(1)]));
        let successful_review = Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy);
        let forward_key = DatumKey::for_card_id(1);
        let reverse_key = DatumKey::from_storage_key("card:1:reverse");
        let mut schedules = HashMap::new();
        schedules.insert(
            forward_key.clone(),
            CardSchedule::new(forward_key).reviewed(&successful_review, now - 600),
        );
        schedules.insert(
            reverse_key.clone(),
            CardSchedule::new(reverse_key).reviewed(&successful_review, now),
        );
        let suspended = HashSet::new();
        let flagged = HashSet::new();
        let empty = HashSet::new();
        let filter_state =
            study_item_filter_state(&schedules, &suspended, &flagged, &empty, &empty, now);

        assert_eq!(
            ready_study_item_indices_for_filter(
                &items,
                ReadyCardFilter::All,
                "",
                ReadyCardSort::LastReviewed,
                ReadyCardSortDirection::Descending,
                &filter_state
            ),
            vec![1, 0]
        );
    }

    #[test]
    fn ready_sort_menu_items_are_checkable() {
        let xrc = include_str!("../ui/main.xrc");

        for item_name in [
            "menu_sort_deck_order",
            "menu_sort_next_scheduled",
            "menu_sort_last_reviewed",
            "menu_sort_prompt_text",
            "menu_sort_response_text",
            "menu_sort_estimated_recall",
            "menu_sort_ascending",
            "menu_sort_descending",
        ] {
            let item = xrc
                .split(&format!("name=\"{item_name}\""))
                .nth(1)
                .expect("sort menu item exists");
            let item = item
                .split("</object>")
                .next()
                .expect("sort menu item has an object body");
            assert!(
                item.contains("<checkable>1</checkable>"),
                "{item_name} should be a checkable menu item"
            );
        }
    }

    #[test]
    fn ready_card_filter_counts_match_filter_membership() {
        let now = 1_800_000_000;
        let new_card = datum_with_id(1, "New", "Answer");
        let not_due_card = datum_with_id(2, "Not due", "Answer");
        let due_card = datum_with_id(3, "Due", "Answer");
        let data = vec![new_card.clone(), not_due_card.clone(), due_card.clone()];
        let successful_review = Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy);
        let mut schedules = HashMap::new();
        schedules.insert(
            DatumKey::for_datum(&not_due_card),
            CardSchedule::new(DatumKey::for_datum(&not_due_card)).reviewed(&successful_review, now),
        );
        schedules.insert(
            DatumKey::for_datum(&due_card),
            CardSchedule::new(DatumKey::for_datum(&due_card))
                .reviewed(&successful_review, now - 9 * TEST_DAY_SECONDS),
        );
        let mut suspended = HashSet::new();
        suspended.insert(DatumKey::for_datum(&due_card));
        let flagged = HashSet::new();

        assert_eq!(
            ready_card_filter_counts(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                "",
                now
            ),
            [3, 1, 1, 1, 1, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0]
        );
    }

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

        assert!(ready_card_matches_text_filter(&datum, "spain"));
        assert!(ready_card_matches_text_filter(&datum, " MAD "));
        assert!(!ready_card_matches_text_filter(&datum, "France"));
    }

    #[test]
    fn ready_card_text_filter_combines_with_type_filter_and_counts() {
        let now = 1_800_000_000;
        let new_match = datum_with_id(1, "Spanish pronoun", "yo");
        let new_other = datum_with_id(2, "French pronoun", "je");
        let due_match = datum_with_id(3, "Past tense", "Spanish answer");
        let not_due_match = datum_with_id(4, "Spanish article", "el");
        let data = vec![
            new_match.clone(),
            new_other,
            due_match.clone(),
            not_due_match.clone(),
        ];
        let successful_review = Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy);
        let mut schedules = HashMap::new();
        schedules.insert(
            DatumKey::for_datum(&due_match),
            CardSchedule::new(DatumKey::for_datum(&due_match))
                .reviewed(&successful_review, now - 9 * TEST_DAY_SECONDS),
        );
        schedules.insert(
            DatumKey::for_datum(&not_due_match),
            CardSchedule::new(DatumKey::for_datum(&not_due_match))
                .reviewed(&successful_review, now),
        );
        let suspended = HashSet::new();
        let flagged = HashSet::new();

        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::All,
                "spanish",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![0, 2, 3]
        );
        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::Due,
                "spanish",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![0, 2]
        );
        assert_eq!(
            ready_card_filter_counts(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                "spanish",
                now
            ),
            [3, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
        );
    }

    #[test]
    fn ready_card_filter_can_show_duplicate_prompts_and_responses() {
        let now = 1_800_000_000;
        let first_prompt = datum_with_id(1, "Same prompt", "one");
        let second_prompt = datum_with_id(2, "Same prompt", "two");
        let first_response = datum_with_id(3, "three", "Same response");
        let second_response = datum_with_id(4, "four", "Same response");
        let unique = datum_with_id(5, "unique", "answer");
        let data = vec![
            first_prompt,
            second_prompt,
            first_response,
            second_response,
            unique,
        ];
        let schedules = HashMap::new();
        let suspended = HashSet::new();
        let flagged = HashSet::new();

        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::DuplicatePrompts,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![0, 1]
        );
        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::DuplicateResponses,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![2, 3]
        );
        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::DuplicatePrompts,
                "two",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![1]
        );
        assert_eq!(
            ready_card_filter_counts(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                "",
                now
            ),
            [5, 5, 5, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0]
        );
    }

    #[test]
    fn ready_card_filter_can_show_suspended_cards() {
        let now = 1_800_000_000;
        let active = datum_with_id(1, "Active", "Answer");
        let suspended_card = datum_with_id(2, "Suspended", "Answer");
        let data = vec![active, suspended_card.clone()];
        let schedules = HashMap::new();
        let mut suspended = HashSet::new();
        suspended.insert(DatumKey::for_datum(&suspended_card));
        let flagged = HashSet::new();

        assert_eq!(
            ready_card_indices_for_filter(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                ReadyCardFilter::Suspended,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                now
            ),
            vec![1]
        );
        assert_eq!(
            ready_card_filter_counts(
                &data,
                &schedules,
                &suspended,
                &flagged,
                &HashSet::new(),
                "",
                now
            ),
            [2, 1, 1, 0, 1, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0]
        );
    }

    #[test]
    fn ready_study_item_filter_can_show_deferred_cards() {
        let now = 1_800_000_000;
        let data = vec![
            datum_with_id(1, "Active", "Answer"),
            datum_with_id(2, "Deferred", "Answer"),
        ];
        let items = study_items_for_deck(&data, &HashSet::new());
        let schedules = HashMap::new();
        let suspended = HashSet::new();
        let flagged = HashSet::new();
        let mut deferred = HashSet::new();
        deferred.insert(DatumKey::for_card_id(2));
        let non_reversible = HashSet::new();
        let filter_state = study_item_filter_state(
            &schedules,
            &suspended,
            &flagged,
            &deferred,
            &non_reversible,
            now,
        );

        assert_eq!(
            ready_study_item_indices_for_filter(
                &items,
                ReadyCardFilter::Deferred,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                &filter_state
            ),
            vec![1]
        );
        assert_eq!(
            ready_study_item_indices_for_filter(
                &items,
                ReadyCardFilter::Due,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                &filter_state
            ),
            vec![0]
        );
        assert_eq!(
            ready_study_item_labels_for_indices(
                &items,
                &[1],
                &suspended,
                &flagged,
                &deferred,
                &non_reversible,
                english(),
            ),
            vec!["Deferred (deferred)".to_string()]
        );
        assert_eq!(
            ready_study_item_filter_counts(&items, "", &filter_state,),
            [2, 1, 1, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0]
        );
    }

    #[test]
    fn ready_study_item_filter_can_show_reverse_cards() {
        let now = 1_800_000_000;
        let data = vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ];
        let items = study_items_for_deck(&data, &HashSet::from([DatumKey::for_card_id(1)]));
        let schedules = HashMap::new();
        let suspended = HashSet::new();
        let flagged = HashSet::new();
        let empty = HashSet::new();
        let filter_state =
            study_item_filter_state(&schedules, &suspended, &flagged, &empty, &empty, now);

        assert_eq!(
            ready_study_item_indices_for_filter(
                &items,
                ReadyCardFilter::Reverse,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                &filter_state
            ),
            vec![1]
        );
        assert_eq!(
            ready_study_item_labels_for_indices(
                &items,
                &[1],
                &suspended,
                &flagged,
                &HashSet::new(),
                &HashSet::new(),
                english()
            ),
            vec!["uno (reverse)".to_string()]
        );
        assert_eq!(
            ready_study_item_filter_counts(&items, "", &filter_state,),
            [3, 3, 3, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]
        );
    }

    #[test]
    fn ready_study_item_filter_detects_duplicates_across_reverse_cards() {
        let now = 1_800_000_000;
        let data = vec![
            datum_with_id(1, "forward one", "shared"),
            datum_with_id(2, "shared", "forward two"),
        ];
        let items = study_items_for_deck(&data, &HashSet::from([DatumKey::for_card_id(1)]));
        let schedules = HashMap::new();
        let suspended = HashSet::new();
        let flagged = HashSet::new();
        let empty = HashSet::new();
        let filter_state =
            study_item_filter_state(&schedules, &suspended, &flagged, &empty, &empty, now);

        assert_eq!(
            ready_study_item_indices_for_filter(
                &items,
                ReadyCardFilter::DuplicatePrompts,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                &filter_state
            ),
            vec![1, 2]
        );
        assert_eq!(
            ready_study_item_filter_counts(&items, "", &filter_state,),
            [3, 3, 3, 0, 0, 0, 0, 0, 1, 2, 0, 0, 0, 0, 0]
        );
    }

    #[test]
    fn ready_study_item_filter_finds_problem_cards_by_scheduler_signal_and_recent_failures() {
        let now = 1_800_000_000;
        let data = vec![
            datum_with_id(1, "High lifetime lapses but low rate", "Answer"),
            datum_with_id(2, "High again rate", "Answer"),
            datum_with_id(3, "Recent failures", "Answer"),
            datum_with_id(4, "Historically difficult but recently OK", "Answer"),
            datum_with_id(5, "Suspended problem", "Answer"),
            datum_with_id(6, "Ordinary card", "Answer"),
        ];
        let items = study_items_for_deck(&data, &HashSet::new());
        let mut schedules = HashMap::new();
        let mut review_stats = HashMap::new();
        for (card_id, difficulty, lapses, stats) in [
            (
                1,
                4.0,
                5,
                ReadyStudyItemReviewStats::from_counts(100, 5, 95, 5, 0),
            ),
            (
                2,
                4.0,
                0,
                ReadyStudyItemReviewStats::from_counts(10, 5, 5, 5, 1),
            ),
            (
                3,
                4.0,
                0,
                ReadyStudyItemReviewStats::from_counts(5, 2, 3, 5, 2),
            ),
            (
                4,
                9.5,
                0,
                ReadyStudyItemReviewStats::from_counts(10, 1, 9, 5, 0),
            ),
            (
                5,
                10.0,
                10,
                ReadyStudyItemReviewStats::from_counts(10, 10, 0, 5, 5),
            ),
            (
                6,
                4.0,
                0,
                ReadyStudyItemReviewStats::from_counts(10, 1, 9, 5, 0),
            ),
        ] {
            let datum_key = DatumKey::for_card_id(card_id);
            schedules.insert(
                datum_key.clone(),
                stored_review_schedule(datum_key.clone(), difficulty, lapses, now),
            );
            review_stats.insert(datum_key, stats);
        }
        let mut suspended = HashSet::new();
        suspended.insert(DatumKey::for_card_id(5));
        let empty = HashSet::new();
        let filter_state = study_item_filter_state_with_review_stats(
            &schedules,
            &suspended,
            &empty,
            &empty,
            &empty,
            &review_stats,
            now,
        );

        assert_eq!(
            ready_study_item_indices_for_filter(
                &items,
                ReadyCardFilter::ProblemCards,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                &filter_state
            ),
            vec![1, 2]
        );
        assert_eq!(
            ready_study_item_filter_counts(&items, "", &filter_state)
                [ReadyCardFilter::ProblemCards
                    .selection_index()
                    .index_usize()],
            2
        );
    }

    #[test]
    fn ready_study_item_filter_finds_weaker_direction_for_asymmetry() {
        let now = 1_800_000_000;
        let data = vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
            datum_with_id(3, "three", "tres"),
            datum_with_id(4, "four", "cuatro"),
        ];
        let reversible: HashSet<_> = (1..=4).map(DatumKey::for_card_id).collect();
        let items = study_items_for_deck(&data, &reversible);
        let mut review_stats = HashMap::new();
        for (key, stats) in [
            (
                DatumKey::for_card_id(1),
                ReadyStudyItemReviewStats::from_counts(10, 1, 9, 5, 0),
            ),
            (
                DatumKey::for_reverse_of(&DatumKey::for_card_id(1)),
                ReadyStudyItemReviewStats::from_counts(10, 8, 2, 5, 4),
            ),
            (
                DatumKey::for_card_id(2),
                ReadyStudyItemReviewStats::from_counts(10, 3, 7, 5, 1),
            ),
            (
                DatumKey::for_reverse_of(&DatumKey::for_card_id(2)),
                ReadyStudyItemReviewStats::from_counts(10, 4, 6, 5, 1),
            ),
            (
                DatumKey::for_card_id(3),
                ReadyStudyItemReviewStats::from_counts(4, 0, 4, 4, 0),
            ),
            (
                DatumKey::for_reverse_of(&DatumKey::for_card_id(3)),
                ReadyStudyItemReviewStats::from_counts(3, 3, 0, 3, 3),
            ),
            (
                DatumKey::for_card_id(4),
                ReadyStudyItemReviewStats::from_counts(10, 8, 2, 5, 4),
            ),
            (
                DatumKey::for_reverse_of(&DatumKey::for_card_id(4)),
                ReadyStudyItemReviewStats::from_counts(10, 1, 9, 5, 0),
            ),
        ] {
            review_stats.insert(key, stats);
        }
        let schedules = HashMap::new();
        let empty = HashSet::new();
        let filter_state = study_item_filter_state_with_review_stats(
            &schedules,
            &empty,
            &empty,
            &empty,
            &empty,
            &review_stats,
            now,
        );

        assert_eq!(
            ready_study_item_indices_for_filter(
                &items,
                ReadyCardFilter::AsymmetryIssues,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                &filter_state
            ),
            vec![1, 6]
        );
        assert_eq!(
            ready_study_item_filter_counts(&items, "", &filter_state)
                [ReadyCardFilter::AsymmetryIssues
                    .selection_index()
                    .index_usize()],
            2
        );
    }

    #[test]
    fn ready_study_item_filter_finds_recent_confidence_mismatches() {
        let now = 1_800_000_000;
        let data = vec![
            datum_with_id(1, "Latest high-confidence failure", "Answer"),
            datum_with_id(2, "Repeated high-confidence failures", "Answer"),
            datum_with_id(3, "Repeated low-confidence successes", "Answer"),
            datum_with_id(4, "Suspended mismatch", "Answer"),
            datum_with_id(5, "Ordinary card", "Answer"),
        ];
        let items = study_items_for_deck(&data, &HashSet::new());
        let mut review_stats = HashMap::new();
        for (card_id, stats) in [
            (
                1,
                ReadyStudyItemReviewStats::from_counts(5, 1, 4, 5, 1)
                    .with_confidence_diagnostics(1, true, 0),
            ),
            (
                2,
                ReadyStudyItemReviewStats::from_counts(5, 2, 3, 5, 2)
                    .with_confidence_diagnostics(2, false, 0),
            ),
            (
                3,
                ReadyStudyItemReviewStats::from_counts(5, 0, 5, 5, 0)
                    .with_confidence_diagnostics(0, false, 2),
            ),
            (
                4,
                ReadyStudyItemReviewStats::from_counts(5, 1, 4, 5, 1)
                    .with_confidence_diagnostics(1, true, 2),
            ),
            (5, ReadyStudyItemReviewStats::from_counts(5, 1, 4, 5, 1)),
        ] {
            review_stats.insert(DatumKey::for_card_id(card_id), stats);
        }
        let schedules = HashMap::new();
        let suspended = HashSet::from([DatumKey::for_card_id(4)]);
        let empty = HashSet::new();
        let filter_state = study_item_filter_state_with_review_stats(
            &schedules,
            &suspended,
            &empty,
            &empty,
            &empty,
            &review_stats,
            now,
        );

        assert_eq!(
            ready_study_item_indices_for_filter(
                &items,
                ReadyCardFilter::HighConfidenceFailures,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                &filter_state
            ),
            vec![0, 1]
        );
        assert_eq!(
            ready_study_item_indices_for_filter(
                &items,
                ReadyCardFilter::LowConfidenceSuccesses,
                "",
                ReadyCardSort::DeckOrder,
                ReadyCardSortDirection::Ascending,
                &filter_state
            ),
            vec![2]
        );
        assert_eq!(
            ready_study_item_filter_counts(&items, "", &filter_state)
                [ReadyCardFilter::HighConfidenceFailures
                    .selection_index()
                    .index_usize()],
            2
        );
        assert_eq!(
            ready_study_item_filter_counts(&items, "", &filter_state)
                [ReadyCardFilter::LowConfidenceSuccesses
                    .selection_index()
                    .index_usize()],
            1
        );
    }

    #[test]
    fn ready_study_item_review_stats_uses_recent_confidence_rated_attempts() {
        let key = DatumKey::for_card_id(1);
        let reviews = vec![
            ReviewEvent::new(
                1,
                key.clone(),
                1,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Again),
            ),
            ReviewEvent::new(
                2,
                key.clone(),
                2,
                Review::new(PromptGuess::NotAnswered, RecallFeedback::Good),
            ),
            ReviewEvent::new(
                3,
                key.clone(),
                3,
                Review::new(PromptGuess::DontKnow, RecallFeedback::Easy),
            ),
            ReviewEvent::new(
                4,
                key.clone(),
                4,
                Review::new(PromptGuess::ProbablyDontKnow, RecallFeedback::Good),
            ),
            ReviewEvent::new(
                5,
                key,
                5,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Again),
            ),
        ];

        let stats =
            ReadyStudyItemReviewStats::from_reviews(&reviews, CalibrationRecallRule::Lenient);

        assert!(stats.recent_high_confidence_failure());
        assert!(stats.recent_low_confidence_success());
    }

    #[test]
    fn ready_confidence_diagnostics_use_calibration_recall_rule() {
        let key = DatumKey::for_card_id(1);
        let reviews = vec![
            ReviewEvent::new(
                1,
                key.clone(),
                1,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Hard),
            ),
            ReviewEvent::new(
                2,
                key.clone(),
                2,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Hard),
            ),
            ReviewEvent::new(
                3,
                key.clone(),
                3,
                Review::new(PromptGuess::ProbablyDontKnow, RecallFeedback::Hard),
            ),
            ReviewEvent::new(
                4,
                key,
                4,
                Review::new(PromptGuess::ProbablyDontKnow, RecallFeedback::Hard),
            ),
        ];

        let lenient =
            ReadyStudyItemReviewStats::from_reviews(&reviews, CalibrationRecallRule::Lenient);
        let strict =
            ReadyStudyItemReviewStats::from_reviews(&reviews, CalibrationRecallRule::Strict);

        assert!(!lenient.recent_high_confidence_failure());
        assert!(lenient.recent_low_confidence_success());
        assert!(strict.recent_high_confidence_failure());
        assert!(!strict.recent_low_confidence_success());
    }

    #[test]
    fn ready_filter_choice_labels_include_counts() {
        assert_eq!(
            ready_filter_choice_label(ReadyCardFilter::Due, english(), 12),
            "Due cards (12)"
        );
        assert_eq!(
            ready_filter_choice_label(
                ReadyCardFilter::Due,
                Localizer::new(localization::Language::Spanish),
                12
            ),
            "Cartas pendientes (12)"
        );
    }

    #[test]
    fn selected_ready_card_details_include_reviews_and_schedule() {
        let reviewed_at = current_time_and_seed().unwrap().0 - 90_061;
        let (state, _temp_dir) = make_state(vec![
            datum_with_id(7, "Capital of Spain", "Madrid").with_reversal_prohibited(true),
        ]);
        record_review_at(
            &state,
            &state.data[0],
            reviewed_at,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Easy,
        );
        let reviewed_at_timestamp = format_unix_timestamp_utc(reviewed_at);

        let details = state
            .selected_ready_card_details_text()
            .unwrap()
            .expect("selected card details");

        assert!(details.contains("Prompt: Capital of Spain"));
        assert!(details.contains("Response: Madrid"));
        assert!(details.contains("Card ID: 7"));
        assert!(details.contains("Datum key: card:7"));
        assert!(details.contains("Number of attempts: 1"));
        assert!(details.contains("Last attempt: 1 day, 1 hour, 1 minute"));
        assert!(details.contains(&format!("ago ({reviewed_at_timestamp})")));
        assert!(details.contains("Schedule last updated: 1 day, 1 hour, 1 minute"));
        assert!(details.contains("Last confidence rating: 3. Probably know"));
        assert!(details.contains("Last recall rating: 4. Easy"));
        assert!(details.contains("Availability: scheduled in"));
        assert!(!details.contains("Due now:"));
        assert!(!details.contains("Next scheduled review:"));
        assert!(details.contains("Estimated recall now: "));
        assert!(details.contains("Recent recall: 1 of last 1 recalled (100.0%)"));
        assert!(details.contains("Lapse summary: no lapses recorded"));
        assert!(details.contains("Memory state: review"));
        assert!(details.contains("Repetitions: 1"));
        assert!(details.contains("Attempt history"));
        assert!(details.contains(&format!(
            "1. {reviewed_at_timestamp}. Confidence: 3. Probably know. Recall: 4. Easy."
        )));
    }

    #[test]
    fn selected_ready_card_details_report_new_unscheduled_cards() {
        let (state, _temp_dir) = make_state(vec![
            datum_with_id(7, "Capital of Spain", "Madrid").with_reversal_prohibited(true),
        ]);

        let details = state
            .selected_ready_card_details_text()
            .unwrap()
            .expect("selected card details");

        assert!(details.contains("Number of attempts: 0"));
        assert!(details.contains("Availability: new card; available now"));
        assert!(!details.contains("Due now:"));
        assert!(!details.contains("Next scheduled review:"));
        assert!(details.contains("Estimated recall now: Not available"));
        assert!(details.contains("Recent recall: None"));
        assert!(details.contains("Memory state: new"));
        assert!(details.contains("Stored schedule: None"));
    }

    #[test]
    fn selected_ready_card_details_show_diagnostics_for_problem_cards() {
        let now = current_time_and_seed().unwrap().0;
        let (state, _temp_dir) = make_state(vec![datum_with_id(7, "Capital of Spain", "Madrid")]);
        for (offset, feedback) in [
            (50_000, RecallFeedback::Again),
            (40_000, RecallFeedback::Hard),
            (30_000, RecallFeedback::Again),
            (20_000, RecallFeedback::Good),
            (10_000, RecallFeedback::Easy),
        ] {
            record_review_at(
                &state,
                &state.data[0],
                now - offset,
                PromptGuess::ProbablyKnow,
                feedback,
            );
        }
        state
            .store
            .save_card_schedule(&CardSchedule::from_storage(
                DatumKey::for_card_id(7),
                cosimo::CardMemoryState::Relearning,
                now + 600,
                3.0,
                5.0,
                0,
                0,
                600,
                5,
                2,
                Some(now - 10_000),
                Some(RecallFeedback::Again),
            ))
            .unwrap();

        let details = state
            .selected_ready_card_details_text()
            .unwrap()
            .expect("selected card details");

        assert!(details.contains("Availability: learning step in "));
        assert!(details.contains("Recent recall: 3 of last 5 recalled (60.0%)"));
        assert!(details.contains("Lapses: 2"));
        assert!(details.contains("Lapse summary: 2 lapses recorded; repeatedly relearned"));
    }

    #[test]
    fn selected_ready_card_details_combines_card_states_on_one_line() {
        let (state, _temp_dir) = make_state(vec![
            datum_with_id(7, "Capital of Spain", "Madrid").with_reversal_prohibited(true),
        ]);
        state
            .store
            .set_datum_suspended(&DatumKey::for_card_id(7), true)
            .unwrap();
        state
            .store
            .set_datum_flagged(&DatumKey::for_card_id(7), true)
            .unwrap();
        let details = state
            .selected_ready_card_details_text()
            .unwrap()
            .expect("selected card details");

        assert!(details.contains("State: suspended, flagged, non-reversible"));
        assert!(!details.contains("Non-reversible: yes"));
    }

    #[test]
    fn selected_ready_card_details_describes_active_reverse_as_has_reverse() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(7, "one", "uno")]);
        state.toggle_selected_card_reversal().unwrap();
        state.select_ready_index(0);

        let details = state
            .selected_ready_card_details_text()
            .unwrap()
            .expect("selected card details");

        assert!(details.contains("State: has reverse"));
        assert!(!details.contains("State: reversible"));
    }

    #[test]
    fn selected_ready_card_details_show_future_review_time_as_delta_then_timestamp() {
        let due_at = current_time_and_seed().unwrap().0 + 90_061;
        let (state, _temp_dir) = make_state(vec![datum_with_id(7, "Capital of Spain", "Madrid")]);
        state
            .store
            .save_card_schedule(&CardSchedule::from_storage(
                DatumKey::for_card_id(7),
                cosimo::CardMemoryState::Review,
                due_at,
                3.0,
                5.0,
                0,
                0,
                90_061,
                1,
                0,
                Some(due_at - 24 * 60 * 60),
                Some(RecallFeedback::Good),
            ))
            .unwrap();

        let details = state
            .selected_ready_card_details_text()
            .unwrap()
            .expect("selected card details");

        assert!(details.contains("Availability: scheduled in 1 day, 1 hour, 1 minute"));
        assert!(details.contains(" UTC)"));
    }

    #[test]
    fn selected_ready_card_details_show_past_review_time_as_delta_then_timestamp() {
        let due_at = current_time_and_seed().unwrap().0 - 90_061;
        let (state, _temp_dir) = make_state(vec![datum_with_id(7, "Capital of Spain", "Madrid")]);
        state
            .store
            .save_card_schedule(&CardSchedule::from_storage(
                DatumKey::for_card_id(7),
                cosimo::CardMemoryState::Review,
                due_at,
                3.0,
                5.0,
                0,
                0,
                90_061,
                1,
                0,
                Some(due_at - 24 * 60 * 60),
                Some(RecallFeedback::Good),
            ))
            .unwrap();

        let details = state
            .selected_ready_card_details_text()
            .unwrap()
            .expect("selected card details");

        assert!(details.contains("Availability: available now; scheduled 1 day, 1 hour, 1 minute"));
        assert!(details.contains(" ago ("));
        assert!(details.contains(" UTC)"));
    }

    #[test]
    fn selected_reverse_card_details_show_storage_key_not_content_hash() {
        let (mut state, _temp_dir) = make_state(vec![datum_with_id(7, "one", "uno")]);
        state.toggle_selected_card_reversal().unwrap();
        state.select_ready_index(1);

        let details = state
            .selected_ready_card_details_text()
            .unwrap()
            .expect("selected card details");

        assert!(details.contains("Prompt: uno"));
        assert!(details.contains("Response: one"));
        assert!(details.contains("Card ID: 7"));
        assert!(details.contains("Datum key: card:7:reverse"));
        assert!(details.contains("State: reverse"));
        assert!(!details.contains("fnv1a64:"));
    }

    #[test]
    fn unix_timestamp_formatting_uses_utc_calendar_time() {
        assert_eq!(format_unix_timestamp_utc(0), "1970-01-01 00:00:00 UTC");
        assert_eq!(
            format_unix_timestamp_utc(1_800_000_000),
            "2027-01-15 08:00:00 UTC"
        );
    }

    #[test]
    fn apply_ready_list_selection_uses_filtered_deck_indices() {
        let (state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);
        let state = Rc::new(RefCell::new(state));
        {
            let state_ref = state.borrow();
            *state_ref.ready_visible_indices.borrow_mut() = vec![1];
        }

        assert!(apply_ready_list_selection(&state, ReadyListRow::new(0)));

        assert_eq!(state.borrow().selected_ready_index, Some(1));
    }

    #[test]
    fn ready_filter_selection_prefers_existing_visible_card_then_first_visible() {
        assert_eq!(normalize_ready_selection(Some(2), &[1, 2]), Some(2));
        assert_eq!(normalize_ready_selection(Some(0), &[1, 2]), Some(1));
        assert_eq!(normalize_ready_selection(Some(0), &[]), None);
    }

    #[test]
    fn ready_text_filter_normalizes_selection_to_visible_cards() {
        let (mut state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
            datum("Capital of Italy", "Rome"),
        ]);
        state.select_ready_index(1);

        state.set_ready_card_text_filter("Italy".to_string());

        assert_eq!(state.selected_ready_index, Some(2));
        assert_eq!(state.ready_visible_indices.borrow().as_slice(), &[2]);
    }

    #[test]
    fn ready_card_filter_follows_prompt_list_in_tab_order() {
        let xrc = include_str!("../ui/main.xrc");

        let prompt_list = xrc.find("name=\"list_ready_cards\"").unwrap();
        let text_filter = xrc.find("name=\"text_ready_card_filter\"").unwrap();
        let filter_choice = xrc.find("name=\"choice_ready_card_filter\"").unwrap();
        let add_button = xrc.find("name=\"button_ready_add_card\"").unwrap();

        assert!(prompt_list < text_filter);
        assert!(text_filter < filter_choice);
        assert!(filter_choice < add_button);
    }

    #[test]
    fn exam_start_button_follows_other_review_start_buttons() {
        let xrc = include_str!("../ui/main.xrc");

        let start = xrc.find("name=\"button_start_cycle\"").unwrap();
        let force_start = xrc.find("name=\"button_force_start_cycle\"").unwrap();
        let flagged = xrc.find("name=\"button_review_flagged_cards\"").unwrap();
        let exam = xrc.find("name=\"button_start_exam\"").unwrap();
        let quit = xrc.find("name=\"button_start_quit\"").unwrap();

        assert!(start < force_start);
        assert!(force_start < flagged);
        assert!(flagged < exam);
        assert!(exam < quit);
    }

    #[test]
    fn ready_card_text_filter_is_single_line_and_updates_on_change() {
        let xrc = include_str!("../ui/main.xrc");
        let source = include_str!("main.rs");
        let ready_screen_source = include_str!("ready_screen.rs");
        let text_filter = xrc.find("name=\"text_ready_card_filter\"").unwrap();
        let after_text_filter = &xrc[text_filter..];
        let next_sizer_item = after_text_filter.find("</object>").unwrap();
        let text_filter_xrc = &after_text_filter[..next_sizer_item];

        assert!(!text_filter_xrc.contains("wxTE_MULTILINE"));
        assert!(source.contains("ui.ready_card_text_filter.on_text_changed("));
        assert!(source.contains("state_ref.update_ready_card_text_filter_value(text_filter)"));
        assert!(source.contains("ReadyFilterChangeKind::Text"));
        assert!(ready_screen_source.contains("fn refresh_ready_screen_after_filter_change("));
    }

    #[test]
    fn ready_filter_changes_use_lightweight_refresh_path() {
        let source = include_str!("main.rs");
        let filter_start = source
            .find("ui.ready_card_filter_choice\n        .on_selection_changed")
            .unwrap();
        let text_start = source
            .find("ui.ready_card_text_filter.on_text_changed")
            .unwrap();
        let ready_list_key_start = source.find("ui.ready_cards.on_key_down").unwrap();
        let filter_handler = &source[filter_start..text_start];
        let text_handler = &source[text_start..ready_list_key_start];

        assert!(filter_handler.contains("update_ready_card_filter_value(filter)"));
        assert!(filter_handler.contains("refresh_ready_screen_after_filter_change"));
        assert!(filter_handler.contains("ReadyFilterChangeKind::Choice"));
        assert!(!filter_handler.contains("render(&ui_copy"));
        assert!(text_handler.contains("update_ready_card_text_filter_value(text_filter)"));
        assert!(text_handler.contains("refresh_ready_screen_after_filter_change"));
        assert!(text_handler.contains("ReadyFilterChangeKind::Text"));
        assert!(!text_handler.contains("render(&ui_copy"));
    }

    #[test]
    fn ready_combo_filter_changes_skip_count_refresh_and_force_list_replacement() {
        let ready_screen_source = include_str!("ready_screen.rs");
        let review_ui_source = include_str!("review_ui.rs");

        assert!(ready_screen_source.contains("enum ReadyFilterChangeKind"));
        assert!(ready_screen_source.contains("change_kind == ReadyFilterChangeKind::Text"));
        assert!(ready_screen_source.contains("enum ReadyFilterCountRefresh"));
        assert!(ready_screen_source.contains("ReadyFilterCountRefresh::Skip"));
        assert!(ready_screen_source.contains("ReadyFilterCountRefresh::Refresh"));
        assert!(ready_screen_source.contains("enum ReadyListLabelSync"));
        assert!(ready_screen_source.contains("ReadyListLabelSync::ReplaceExisting"));
        assert!(ready_screen_source.contains("ReadyListLabelSync::Incremental"));
        assert!(ready_screen_source.contains(
            "count_refresh.should_refresh() || state.ready_card_filter.requires_review_stats()"
        ));
        assert!(ready_screen_source.contains("fn sync_ready_selection_buttons("));
        assert!(ready_screen_source.contains("sync_ready_selection_buttons(ui, &state_ref);"));
        assert!(review_ui_source.contains("fn replace_ready_card_labels("));
        assert!(review_ui_source.contains("self.ready_cards.freeze();"));
        assert!(review_ui_source.contains("self.ready_cards.thaw();"));
    }

    #[test]
    fn add_card_is_rejected_during_a_running_cycle() {
        let (mut state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);
        state.start_cycle();

        let error = state
            .add_card(datum("Capital of France", "Paris"))
            .unwrap_err();

        assert_eq!(
            error,
            english().card_modification_only_before_cycle_starts()
        );
        assert_eq!(state.data.len(), 1);
    }

    #[test]
    fn edit_card_is_rejected_during_a_running_cycle() {
        let (mut state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);
        state.start_cycle();

        let error = state
            .edit_selected_card(datum("Capital of Spain", "Madrid, Spain"))
            .unwrap_err();

        assert_eq!(
            error,
            english().card_modification_only_before_cycle_starts()
        );
    }

    #[test]
    fn remove_card_is_rejected_during_a_running_cycle() {
        let (mut state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);
        state.start_cycle();

        let error = state.remove_selected_card().unwrap_err();

        assert_eq!(
            error,
            english().card_modification_only_before_cycle_starts()
        );
        assert_eq!(state.data.len(), 2);
    }

    #[test]
    fn select_ready_index_updates_the_selected_card() {
        let (mut state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);

        state.select_ready_index(1);

        assert_eq!(state.selected_ready_index, Some(1));
        assert_eq!(
            state.selected_ready_datum(),
            Some(&datum("Capital of France", "Paris"))
        );
    }

    #[test]
    fn apply_ready_list_selection_updates_selection_when_not_syncing() {
        let (state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);
        let state = Rc::new(RefCell::new(state));

        apply_ready_list_selection(&state, ReadyListRow::new(1));

        assert_eq!(state.borrow().selected_ready_index, Some(1));
    }

    #[test]
    fn apply_ready_list_selection_ignores_events_during_ready_list_sync() {
        let (state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);
        let state = Rc::new(RefCell::new(state));
        state.borrow().ready_list_sync_in_progress.set(true);

        apply_ready_list_selection(&state, ReadyListRow::new(1));

        assert_eq!(state.borrow().selected_ready_index, Some(0));
    }

    #[test]
    fn apply_ready_list_selection_ignores_reselecting_the_same_item() {
        let (mut state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);
        state.select_ready_index(1);
        let state = Rc::new(RefCell::new(state));

        assert!(!apply_ready_list_selection(&state, ReadyListRow::new(1)));
        assert_eq!(state.borrow().selected_ready_index, Some(1));
    }

    #[test]
    fn mutate_ready_card_state_adds_without_leaking_the_mutable_borrow() {
        let (state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);
        let state = Rc::new(RefCell::new(state));

        mutate_ready_card_state(&state, |state| {
            state.add_card(datum("Capital of France", "Paris"))
        })
        .unwrap();

        assert_eq!(
            ready_card_labels(&state.borrow().data),
            vec![
                "Capital of Spain".to_string(),
                "Capital of France".to_string(),
            ]
        );
    }

    #[test]
    fn mutate_ready_card_state_edits_without_leaking_the_mutable_borrow() {
        let (state, _temp_dir) = make_state(vec![datum("Capital of Spain", "Madrid")]);
        let state = Rc::new(RefCell::new(state));

        mutate_ready_card_state(&state, |state| {
            state.edit_selected_card(datum("Capital of Spain and Aragon", "Madrid"))
        })
        .unwrap();

        assert_eq!(
            ready_card_labels(&state.borrow().data),
            vec!["Capital of Spain and Aragon".to_string()]
        );
    }

    #[test]
    fn mutate_ready_card_state_removes_without_leaking_the_mutable_borrow() {
        let (state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);
        let state = Rc::new(RefCell::new(state));

        mutate_ready_card_state(&state, AppState::remove_selected_card).unwrap();

        assert_eq!(
            ready_card_labels(&state.borrow().data),
            vec!["Capital of France".to_string()]
        );
    }

    #[test]
    fn remove_selected_card_keeps_selection_on_the_next_available_card() {
        let (mut state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
            datum("Capital of Italy", "Rome"),
        ]);
        state.select_ready_index(1);

        state.remove_selected_card().unwrap();

        assert_eq!(
            state.data,
            vec![
                datum("Capital of Spain", "Madrid"),
                datum("Capital of Italy", "Rome"),
            ]
        );
        assert_eq!(state.selected_ready_index, Some(1));
        assert_eq!(
            state.selected_ready_datum(),
            Some(&datum("Capital of Italy", "Rome"))
        );
    }

    #[test]
    fn remove_selected_card_keeps_selection_on_next_visible_sorted_card() {
        let older_card = datum_with_id(1, "Older", "Answer");
        let new_card = datum_with_id(2, "New", "Answer");
        let newest_card = datum_with_id(3, "Newest", "Answer");
        let (mut state, _temp_dir) =
            make_state(vec![older_card.clone(), new_card, newest_card.clone()]);
        record_review_at(
            &state,
            &older_card,
            1_800_000_000 - 600,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Good,
        );
        record_review_at(
            &state,
            &newest_card,
            1_800_000_000,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Good,
        );
        state.set_ready_card_sort(ReadyCardSort::LastReviewed);
        assert_eq!(state.ready_visible_indices.borrow().as_slice(), &[2, 0, 1]);
        state.select_ready_index(2);

        state.remove_selected_card().unwrap();

        assert_eq!(state.ready_visible_indices.borrow().as_slice(), &[0, 1]);
        assert_selected_ready_key(&state, DatumKey::for_card_id(1));
        assert_eq!(state.selected_ready_index, Some(0));
    }

    #[test]
    fn remove_selected_card_keeps_selection_on_next_visible_filtered_card() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "Spanish article", "el"),
            datum_with_id(2, "French article", "le"),
            datum_with_id(3, "Spanish pronoun", "yo"),
            datum_with_id(4, "French pronoun", "je"),
        ]);
        state.set_ready_card_text_filter("French".to_string());
        assert_eq!(state.ready_visible_indices.borrow().as_slice(), &[1, 3]);
        state.select_ready_index(1);

        state.remove_selected_card().unwrap();

        assert_eq!(state.ready_visible_indices.borrow().as_slice(), &[2]);
        assert_selected_ready_key(&state, DatumKey::for_card_id(4));
        assert_eq!(state.selected_ready_index, Some(2));
    }

    #[test]
    fn adding_after_removing_standard_card_does_not_reuse_dormant_history_id() {
        let removed_card = datum_with_id(1, "Old", "Answer");
        let (mut state, _temp_dir) = make_state(vec![removed_card.clone()]);
        record_review_at(
            &state,
            &removed_card,
            1_800_000_000,
            PromptGuess::ProbablyKnow,
            RecallFeedback::Good,
        );

        state.remove_selected_card().unwrap();
        state.add_card(datum("New", "Answer")).unwrap();

        assert_eq!(state.data, vec![datum_with_id(2, "New", "Answer")]);
        assert_eq!(
            state
                .store
                .reviews_for_datum_key(&DatumKey::for_card_id(1))
                .unwrap()
                .len(),
            1
        );
        assert!(
            state
                .store
                .reviews_for_datum_key(&DatumKey::for_card_id(2))
                .unwrap()
                .is_empty()
        );
    }

    #[test]
    fn remove_selected_reverse_card_moves_selection_to_parent_card() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);
        state.toggle_selected_card_reversal().unwrap();
        state.select_ready_index(1);

        state.remove_selected_card().unwrap();

        assert_selected_ready_key(&state, DatumKey::for_card_id(1));
        assert_eq!(state.selected_ready_index, Some(0));
    }

    #[test]
    fn remove_selected_reverse_card_clears_selection_when_parent_is_filtered_out() {
        let (mut state, _temp_dir) = make_state(vec![
            datum_with_id(1, "one", "uno"),
            datum_with_id(2, "two", "dos"),
        ]);
        state.toggle_selected_card_reversal().unwrap();
        state.set_ready_card_filter(ReadyCardFilter::Reverse);
        state.select_ready_index(1);

        state.remove_selected_card().unwrap();

        assert_eq!(
            state.ready_visible_indices.borrow().as_slice(),
            &[] as &[usize]
        );
        assert_eq!(state.selected_ready_key, None);
        assert_eq!(state.selected_ready_index, None);
    }

    #[test]
    fn remove_selected_last_card_moves_selection_to_previous_card() {
        let (mut state, _temp_dir) = make_state(vec![
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
        ]);
        state.select_ready_index(1);

        state.remove_selected_card().unwrap();

        assert_eq!(state.selected_ready_index, Some(0));
        assert_eq!(
            state.selected_ready_datum(),
            Some(&datum("Capital of Spain", "Madrid"))
        );
    }
}
