//! Ready-screen and study-screen rendering for the desktop application.
//!
//! This module owns the targeted render models for review phase status, card
//! panel content, ready summaries, menu/control availability, and ready-screen
//! chrome. Some native-widget operations intentionally remain direct here:
//! list replacement, focus repair, timer refresh, and audio/menu synchronisation
//! are coupled to wxdragon control behaviour rather than pure display text.

use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::rc::Rc;

use cosimo::{
    CardSchedule, Datum, DatumKey, ScheduledSelectionKind, SchedulerConfig, schedule_datum_for_key,
    schedule_session_for_keys,
};
use wxdragon::prelude::*;

use crate::CosimoUi;
use crate::app_config::SoundNotificationPolicy;
use crate::app_state::AppState;
use crate::audio_generation::{
    audio_generation_metadata_allows_generation, audio_generation_metadata_is_complete,
};
use crate::keyboard::is_ready_screen_refresh_key;
use crate::localization::Localizer;
use crate::menu_ui::set_menu_item_label;
use crate::notifications::{due_notification_kind, play_due_notification};
use crate::ready_list::{
    ReadyCardFilter, ReadyCardSort, ReadyCardSortDirection, ReadyListRow,
    ReadyStudyItemFilterState, ready_study_item_filter_counts, ready_study_item_indices_for_filter,
    ready_study_item_labels_for_indices,
};
use crate::resident_tray::{ResidentTray, resident_tray_visible};
use crate::review_audio::{
    ReviewAudioSide, current_review_audio_file, selected_ready_audio_available,
};
use crate::review_session::{ReviewPhase, ReviewSessionKind};
use crate::review_ui::{
    MenuAvailability, ReadyCardListFocusAvailability, ReadyScreenFocus, ReviewUi,
    set_ready_filter_choices,
};
use crate::runtime::current_time_and_seed;
use crate::study_item::StudyItem;
use crate::time_format::format_relative_duration_rounded;

const READY_REFRESH_INTERVAL_MS: i32 = 60_000;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReadyRefreshKeyDisposition {
    Refresh,
    Skip,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReadyFilterChangeKind {
    Choice,
    Text,
}

pub(crate) fn ready_to_start_summary_text(
    localizer: Localizer,
    total_cards: usize,
    review_db_path: &Path,
    minimum_cards_per_session: usize,
    new_cards_per_day: usize,
    scheduled_cards: Option<usize>,
    due_cards: Option<usize>,
) -> String {
    localizer.ready_to_start_summary_text(
        total_cards,
        &review_db_path.display().to_string(),
        minimum_cards_per_session,
        new_cards_per_day,
        scheduled_cards,
        due_cards,
    )
}

pub(crate) fn ready_summary_warning_text(
    state: &AppState,
    schedule_warning: Option<&str>,
) -> Option<String> {
    let mut warnings = Vec::new();
    let duplicate_prompt_count = state.duplicate_prompt_card_count();
    if duplicate_prompt_count > 0 {
        warnings.push(
            state
                .localizer
                .duplicate_prompts_warning(duplicate_prompt_count),
        );
    }
    warnings.extend(state.ready_summary_warnings.iter().cloned());
    if let Some(warning) = schedule_warning {
        warnings.push(warning.to_string());
    }
    (!warnings.is_empty()).then(|| warnings.join("\n\n"))
}

fn ready_summary_text_with_warning(summary: String, warning: Option<String>) -> String {
    match warning {
        Some(warning) => format!("{warning}\n\n{summary}"),
        None => summary,
    }
}

pub(crate) fn append_status_warning(status: String, warning: Option<String>) -> String {
    match warning {
        Some(warning) if status.is_empty() => warning,
        Some(warning) => format!("{status} {warning}"),
        None => status,
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ReviewPhaseRenderModel {
    status: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ReviewCardPanelRenderModel {
    prompt_visible: bool,
    prompt_heading: String,
    prompt_text: String,
    response_heading: String,
    response_text: String,
    response_visible: bool,
    review_audio_available: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ReviewActionControlsRenderModel {
    guess_panel_visible: bool,
    feedback_panel_visible: bool,
    guess_buttons_enabled: bool,
    feedback_buttons_enabled: bool,
    reveal_response_enabled: bool,
    guess_flag_card_enabled: bool,
    guess_defer_card_enabled: bool,
    guess_end_pass_enabled: bool,
    feedback_end_pass_enabled: bool,
    feedback_flag_card_enabled: bool,
    feedback_edit_card_enabled: bool,
    start_exam_enabled: bool,
    start_quiz_enabled: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ReadySummaryRenderModel {
    text: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ReviewPhaseChromeRenderModel {
    start_panel_visible: bool,
    ready_cards_heading_visible: bool,
    ready_card_text_filter_visible: bool,
    ready_card_filter_visible: Option<bool>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ReadyPhaseControlsRenderModel {
    ready_edit_enabled: bool,
    ready_remove_enabled: bool,
    ready_audio_available: bool,
    start_cycle_enabled: bool,
    force_start_enabled: bool,
    review_flagged_enabled: bool,
}

impl ReviewActionControlsRenderModel {
    fn for_ready_phase(active_cards_available: bool) -> Self {
        Self {
            guess_panel_visible: false,
            feedback_panel_visible: false,
            guess_buttons_enabled: false,
            feedback_buttons_enabled: false,
            reveal_response_enabled: false,
            guess_flag_card_enabled: false,
            guess_defer_card_enabled: false,
            guess_end_pass_enabled: false,
            feedback_end_pass_enabled: false,
            feedback_flag_card_enabled: false,
            feedback_edit_card_enabled: false,
            start_exam_enabled: active_cards_available,
            start_quiz_enabled: active_cards_available,
        }
    }

    fn for_prompt_phase() -> Self {
        Self {
            guess_panel_visible: true,
            feedback_panel_visible: false,
            guess_buttons_enabled: true,
            feedback_buttons_enabled: false,
            reveal_response_enabled: true,
            guess_flag_card_enabled: true,
            guess_defer_card_enabled: true,
            guess_end_pass_enabled: true,
            feedback_end_pass_enabled: false,
            feedback_flag_card_enabled: false,
            feedback_edit_card_enabled: false,
            start_exam_enabled: false,
            start_quiz_enabled: false,
        }
    }

    fn for_response_phase() -> Self {
        Self {
            guess_panel_visible: false,
            feedback_panel_visible: true,
            guess_buttons_enabled: false,
            feedback_buttons_enabled: true,
            reveal_response_enabled: false,
            guess_flag_card_enabled: false,
            guess_defer_card_enabled: false,
            guess_end_pass_enabled: false,
            feedback_end_pass_enabled: true,
            feedback_flag_card_enabled: true,
            feedback_edit_card_enabled: true,
            start_exam_enabled: false,
            start_quiz_enabled: false,
        }
    }
}

fn review_phase_chrome_render_model(phase: ReviewPhase) -> ReviewPhaseChromeRenderModel {
    match phase {
        ReviewPhase::ReadyToStart => ReviewPhaseChromeRenderModel {
            start_panel_visible: true,
            ready_cards_heading_visible: false,
            ready_card_text_filter_visible: true,
            ready_card_filter_visible: Some(true),
        },
        ReviewPhase::Prompt | ReviewPhase::Response { .. } => ReviewPhaseChromeRenderModel {
            start_panel_visible: false,
            ready_cards_heading_visible: false,
            ready_card_text_filter_visible: false,
            ready_card_filter_visible: None,
        },
    }
}

fn ready_phase_controls_render_model(
    has_selected_card: bool,
    selected_audio_available: bool,
    schedule_preview: Option<ReadySchedulePreview>,
    total_cards: usize,
    active_cards: usize,
    available_flagged_cards: Option<usize>,
) -> ReadyPhaseControlsRenderModel {
    let active_cards_available = active_cards > 0;
    ReadyPhaseControlsRenderModel {
        ready_edit_enabled: has_selected_card,
        ready_remove_enabled: has_selected_card,
        ready_audio_available: selected_audio_available,
        start_cycle_enabled: schedule_preview
            .map(|preview| preview.scheduled_cards > 0)
            .unwrap_or(total_cards > 0),
        force_start_enabled: active_cards_available,
        review_flagged_enabled: available_flagged_cards.is_some_and(|count| count > 0),
    }
}

fn ready_summary_render_model(
    localizer: Localizer,
    total_cards: usize,
    review_db_path: &Path,
    minimum_cards_per_session: usize,
    new_cards_per_day: usize,
    schedule_preview: Option<ReadySchedulePreview>,
    warning: Option<String>,
) -> ReadySummaryRenderModel {
    ReadySummaryRenderModel {
        text: ready_summary_text_with_warning(
            ready_to_start_summary_text(
                localizer,
                total_cards,
                review_db_path,
                minimum_cards_per_session,
                new_cards_per_day,
                schedule_preview.map(|preview| preview.scheduled_cards),
                schedule_preview.map(|preview| preview.due_cards),
            ),
            warning,
        ),
    }
}

fn review_phase_render_model(
    localizer: Localizer,
    phase: ReviewPhase,
    total_cards: usize,
    scheduled_cards: usize,
    current_index: usize,
    last_status: &str,
    schedule_warning: Option<&str>,
) -> ReviewPhaseRenderModel {
    let phase_instruction = match phase {
        ReviewPhase::ReadyToStart => localizer.ready_phase_instruction(total_cards),
        ReviewPhase::Prompt => localizer.prompt_phase_instruction(),
        ReviewPhase::Response { .. } => localizer.response_phase_instruction(),
    };

    let status = if last_status.is_empty() {
        localizer.loaded_cards_status(total_cards, phase_instruction)
    } else {
        match phase {
            ReviewPhase::ReadyToStart => format!("{last_status} {phase_instruction}"),
            ReviewPhase::Prompt | ReviewPhase::Response { .. } => localizer.current_card_status(
                last_status,
                current_index + 1,
                scheduled_cards,
                phase_instruction,
            ),
        }
    };

    ReviewPhaseRenderModel {
        status: append_status_warning(status, schedule_warning.map(str::to_string)),
    }
}

fn ready_phase_card_panel_render_model(localizer: Localizer) -> ReviewCardPanelRenderModel {
    ReviewCardPanelRenderModel {
        prompt_visible: false,
        prompt_heading: localizer.prompt_heading().to_string(),
        prompt_text: String::new(),
        response_heading: localizer.response_heading().to_string(),
        response_text: String::new(),
        response_visible: false,
        review_audio_available: false,
    }
}

fn prompt_phase_card_panel_render_model(
    localizer: Localizer,
    prompt: &str,
    review_audio_side: Option<ReviewAudioSide>,
) -> ReviewCardPanelRenderModel {
    ReviewCardPanelRenderModel {
        prompt_visible: true,
        prompt_heading: if review_audio_side == Some(ReviewAudioSide::Prompt) {
            localizer.prompt_heading_with_audio()
        } else {
            localizer.prompt_heading()
        }
        .to_string(),
        prompt_text: prompt.to_string(),
        response_heading: localizer.response_heading().to_string(),
        response_text: String::new(),
        response_visible: false,
        review_audio_available: review_audio_side.is_some(),
    }
}

fn response_phase_card_panel_render_model(
    localizer: Localizer,
    prompt: &str,
    response: &str,
    review_audio_side: Option<ReviewAudioSide>,
) -> ReviewCardPanelRenderModel {
    ReviewCardPanelRenderModel {
        prompt_visible: true,
        prompt_heading: localizer.prompt_heading().to_string(),
        prompt_text: prompt.to_string(),
        response_heading: if review_audio_side == Some(ReviewAudioSide::Response) {
            localizer.response_heading_with_audio()
        } else {
            localizer.response_heading()
        }
        .to_string(),
        response_text: response.to_string(),
        response_visible: true,
        review_audio_available: review_audio_side.is_some(),
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EditCardMenuAction {
    SelectedReadyCard,
    DisabledDuringPrompt,
    RevealedReviewCard,
}

impl EditCardMenuAction {
    pub(crate) fn for_phase(phase: ReviewPhase) -> Self {
        match phase {
            ReviewPhase::ReadyToStart => Self::SelectedReadyCard,
            ReviewPhase::Prompt => Self::DisabledDuringPrompt,
            ReviewPhase::Response { .. } => Self::RevealedReviewCard,
        }
    }

    pub(crate) fn label(self, localizer: Localizer) -> &'static str {
        match self {
            Self::SelectedReadyCard => localizer.edit_selected_card_menu_item(),
            Self::DisabledDuringPrompt | Self::RevealedReviewCard => {
                localizer.edit_current_card_menu_item()
            }
        }
    }

    pub(crate) fn is_enabled(self, has_selected_card: bool) -> bool {
        match self {
            Self::SelectedReadyCard => has_selected_card,
            Self::DisabledDuringPrompt => false,
            Self::RevealedReviewCard => true,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ReadySchedulePreview {
    pub(crate) scheduled_cards: usize,
    pub(crate) due_cards: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ReadyScheduleStatus {
    NotReadyPhase,
    Available(ReadySchedulePreview),
    Unavailable(String),
}

impl ReadyScheduleStatus {
    fn for_phase(state: &AppState, phase: ReviewPhase) -> Self {
        if !matches!(phase, ReviewPhase::ReadyToStart) {
            return Self::NotReadyPhase;
        }

        ready_phase_schedule_status(state)
    }

    pub(crate) fn preview(&self) -> Option<ReadySchedulePreview> {
        match self {
            Self::Available(preview) => Some(*preview),
            Self::NotReadyPhase | Self::Unavailable(_) => None,
        }
    }

    pub(crate) fn warning(&self) -> Option<&str> {
        match self {
            Self::Unavailable(warning) => Some(warning),
            Self::NotReadyPhase | Self::Available(_) => None,
        }
    }
}

pub(crate) fn ready_phase_schedule_status(state: &AppState) -> ReadyScheduleStatus {
    match schedule_preview_if_started_now(state) {
        Ok(preview) => ReadyScheduleStatus::Available(preview),
        Err(error) => ReadyScheduleStatus::Unavailable(
            state
                .localizer
                .could_not_preview_scheduled_cards_status(&error),
        ),
    }
}

pub(crate) fn schedule_preview_if_started_now(
    state: &AppState,
) -> Result<ReadySchedulePreview, String> {
    let (now_unix_seconds, _) = current_time_and_seed()?;
    schedule_preview_at(state, now_unix_seconds, 0)
}

pub(crate) fn schedule_preview_at(
    state: &AppState,
    now_unix_seconds: i64,
    filler_seed: u64,
) -> Result<ReadySchedulePreview, String> {
    if state.data.is_empty() {
        return Ok(ReadySchedulePreview {
            scheduled_cards: 0,
            due_cards: 0,
        });
    }

    let config = SchedulerConfig::new(
        state.settings.minimum_cards_per_session,
        now_unix_seconds,
        filler_seed,
    );
    let items = state
        .study_items()
        .map_err(|error| state.localizer.could_not_build_review_queue(&error))?;
    let schedules = state
        .load_study_item_schedules(&items)
        .map_err(|error| state.localizer.could_not_build_review_queue(&error))?;
    let suspended = state
        .load_suspended_study_item_keys(&items)
        .map_err(|error| state.localizer.could_not_build_review_queue(&error))?;
    let deferred = state
        .load_deferred_study_item_keys_at(&items, now_unix_seconds)
        .map_err(|error| state.localizer.could_not_build_review_queue(&error))?;
    let active_indices: Vec<usize> = items
        .iter()
        .enumerate()
        .filter_map(|(index, item)| {
            (!suspended.contains(item.datum_key()) && !deferred.contains(item.datum_key()))
                .then_some(index)
        })
        .collect();
    let active_data: Vec<(DatumKey, &Datum)> = active_indices
        .iter()
        .map(|index| (items[*index].datum_key().clone(), items[*index].datum()))
        .collect();
    let config = state.scheduler_config_with_new_card_limit(&items, &active_indices, &config)?;
    let scheduled_cards = schedule_session_for_keys(&active_data, &schedules, config);
    let due_cards = scheduled_cards
        .iter()
        .filter(|card| card.selection_kind() == ScheduledSelectionKind::Due)
        .count();
    Ok(ReadySchedulePreview {
        scheduled_cards: scheduled_cards.len(),
        due_cards,
    })
}

pub(crate) fn main_status_bar_text(
    state: &AppState,
    now_unix_seconds: i64,
) -> Result<String, String> {
    if !state.session.is_ready() {
        let studied_cards = state.session.reviewed_card_count();
        let total_cards = state.session.scheduled_card_count();
        return Ok(match state.session.kind() {
            Some(ReviewSessionKind::ScheduledStudy) => state
                .localizer
                .scheduled_study_session_status_bar(studied_cards, total_cards),
            Some(ReviewSessionKind::UnscheduledStudy) => state
                .localizer
                .unscheduled_study_session_status_bar(studied_cards, total_cards),
            Some(ReviewSessionKind::Exam) => {
                state.localizer.exam_status_bar(studied_cards, total_cards)
            }
            Some(ReviewSessionKind::Quiz) => {
                state.localizer.quiz_status_bar(studied_cards, total_cards)
            }
            None => state
                .localizer
                .main_status_bar_schedule_unavailable(total_cards),
        });
    }

    let items = state
        .study_items()
        .map_err(|error| state.localizer.could_not_build_review_queue(&error))?;
    let total_cards = items.len();
    if total_cards == 0 {
        return Ok(state
            .localizer
            .main_status_bar_no_review_scheduled(total_cards));
    }

    let schedules = state
        .load_study_item_schedules(&items)
        .map_err(|error| state.localizer.could_not_build_review_queue(&error))?;
    let suspended = state
        .load_suspended_study_item_keys(&items)
        .map_err(|error| state.localizer.could_not_build_review_queue(&error))?;
    let deferred = state
        .load_deferred_study_item_keys_at(&items, now_unix_seconds)
        .map_err(|error| state.localizer.could_not_build_review_queue(&error))?;
    let scheduled_study_due = scheduled_study_due_keys(
        state,
        &items,
        &schedules,
        &suspended,
        &deferred,
        now_unix_seconds,
        0,
    )?;
    let due_cards = scheduled_study_due.len();
    let mut next_due_at: Option<i64> = None;

    for item in &items {
        if suspended.contains(item.datum_key()) || deferred.contains(item.datum_key()) {
            continue;
        }
        let Some(schedule) = schedules.get(item.datum_key()) else {
            continue;
        };
        let scheduled =
            schedule_datum_for_key(item.datum_key().clone(), Some(schedule), now_unix_seconds);
        if !scheduled.is_due() {
            next_due_at = Some(match next_due_at {
                Some(existing) => existing.min(scheduled.due_at_unix_seconds()),
                None => scheduled.due_at_unix_seconds(),
            });
        }
    }

    if due_cards > 0 {
        return Ok(state
            .localizer
            .main_status_bar_due_now(total_cards, due_cards));
    }

    match next_due_at {
        Some(next_due_at) => Ok(state.localizer.main_status_bar_next_review(
            total_cards,
            &format_relative_duration_rounded(
                state.localizer,
                next_due_at.saturating_sub(now_unix_seconds),
            ),
        )),
        None => Ok(state
            .localizer
            .main_status_bar_no_review_scheduled(total_cards)),
    }
}

struct ReadyFilterRefreshData {
    items: Vec<StudyItem>,
    visible_indices: Vec<usize>,
    suspended: HashSet<DatumKey>,
    flagged: HashSet<DatumKey>,
    deferred: HashSet<DatumKey>,
    non_reversible: HashSet<DatumKey>,
    filter_counts: Option<[usize; ReadyCardFilter::COUNT]>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReadyFilterCountRefresh {
    Skip,
    Refresh,
}

impl ReadyFilterCountRefresh {
    fn should_refresh(self) -> bool {
        self == Self::Refresh
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReadyListLabelSync {
    Incremental,
    ReplaceExisting,
}

fn ready_filter_refresh_data(
    state: &AppState,
    count_refresh: ReadyFilterCountRefresh,
) -> Result<ReadyFilterRefreshData, String> {
    let items = state.study_items()?;
    let schedules: HashMap<DatumKey, CardSchedule> = state.load_study_item_schedules(&items)?;
    let suspended = state.load_suspended_study_item_keys(&items)?;
    let flagged = state.load_flagged_study_item_keys(&items)?;
    let deferred = state.load_deferred_study_item_keys(&items)?;
    let non_reversible = state.load_reversal_excluded_card_keys()?;
    let review_stats =
        if count_refresh.should_refresh() || state.ready_card_filter.requires_review_stats() {
            Some(state.load_study_item_review_stats(&items)?)
        } else {
            None
        };
    let now_unix_seconds = current_time_and_seed()
        .map(|(now_unix_seconds, _)| now_unix_seconds)
        .unwrap_or(0);
    let scheduled_study_due = scheduled_study_due_keys(
        state,
        &items,
        &schedules,
        &suspended,
        &deferred,
        now_unix_seconds,
        0,
    )?;
    let filter_state = ReadyStudyItemFilterState {
        schedules: &schedules,
        suspended: &suspended,
        flagged: &flagged,
        deferred: &deferred,
        non_reversible: &non_reversible,
        scheduled_study_due: Some(&scheduled_study_due),
        review_stats: review_stats.as_ref(),
        now_unix_seconds,
    };
    let visible_indices = ready_study_item_indices_for_filter(
        &items,
        state.ready_card_filter,
        &state.ready_card_text_filter,
        state.ready_card_sort,
        state.ready_card_sort_direction,
        &filter_state,
    );
    let filter_counts = count_refresh.should_refresh().then(|| {
        ready_study_item_filter_counts(&items, &state.ready_card_text_filter, &filter_state)
    });

    Ok(ReadyFilterRefreshData {
        items,
        visible_indices,
        suspended,
        flagged,
        deferred,
        non_reversible,
        filter_counts,
    })
}

pub(crate) fn scheduled_study_due_keys(
    state: &AppState,
    items: &[StudyItem],
    schedules: &HashMap<DatumKey, CardSchedule>,
    suspended: &HashSet<DatumKey>,
    deferred: &HashSet<DatumKey>,
    now_unix_seconds: i64,
    filler_seed: u64,
) -> Result<HashSet<DatumKey>, String> {
    let active_indices: Vec<usize> = items
        .iter()
        .enumerate()
        .filter_map(|(index, item)| {
            (!suspended.contains(item.datum_key()) && !deferred.contains(item.datum_key()))
                .then_some(index)
        })
        .collect();
    let active_data: Vec<(DatumKey, &Datum)> = active_indices
        .iter()
        .map(|index| (items[*index].datum_key().clone(), items[*index].datum()))
        .collect();
    let config = SchedulerConfig::new(
        state.settings.minimum_cards_per_session,
        now_unix_seconds,
        filler_seed,
    );
    let config = state.scheduler_config_with_new_card_limit(items, &active_indices, &config)?;

    Ok(schedule_session_for_keys(&active_data, schedules, config)
        .into_iter()
        .filter(|card| card.selection_kind() == ScheduledSelectionKind::Due)
        .map(|card| items[active_indices[card.deck_index()]].datum_key().clone())
        .collect())
}

fn sync_ready_cards_list_from_refresh_data(
    ui: &ReviewUi,
    state: &AppState,
    refresh_data: &ReadyFilterRefreshData,
    label_sync: ReadyListLabelSync,
) {
    state.ready_list_sync_in_progress.set(true);
    let expected_labels = ready_study_item_labels_for_indices(
        &refresh_data.items,
        &refresh_data.visible_indices,
        &refresh_data.suspended,
        &refresh_data.flagged,
        &refresh_data.deferred,
        &refresh_data.non_reversible,
        state.localizer,
    );

    match label_sync {
        ReadyListLabelSync::Incremental => ui.sync_ready_card_labels(&expected_labels),
        ReadyListLabelSync::ReplaceExisting => ui.replace_ready_card_labels(&expected_labels),
    }

    let selected_visible_row = selected_ready_list_row(
        state.selected_ready_key.as_ref(),
        &refresh_data.items,
        &refresh_data.visible_indices,
    );
    if selected_visible_row != ui.ready_card_selection() {
        ui.set_ready_card_selection(selected_visible_row);
    }
    state.ready_list_sync_in_progress.set(false);
}

fn sync_ready_cards_list(ui: &ReviewUi, state: &AppState) {
    if let Ok(refresh_data) = ready_filter_refresh_data(state, ReadyFilterCountRefresh::Skip) {
        *state.ready_visible_indices.borrow_mut() = refresh_data.visible_indices.clone();
        sync_ready_cards_list_from_refresh_data(
            ui,
            state,
            &refresh_data,
            ReadyListLabelSync::Incremental,
        );
        return;
    }

    state.ready_list_sync_in_progress.set(true);
    let visible_indices = state.ready_visible_indices_for_current_filter();
    let items = state
        .study_items()
        .unwrap_or_else(|_| state.fallback_study_items());
    let suspended = state
        .load_suspended_study_item_keys(&items)
        .unwrap_or_default();
    let flagged = state
        .load_flagged_study_item_keys(&items)
        .unwrap_or_default();
    let deferred = state
        .load_deferred_study_item_keys(&items)
        .unwrap_or_default();
    let non_reversible = state.load_reversal_excluded_card_keys().unwrap_or_default();
    let expected_labels = ready_study_item_labels_for_indices(
        &items,
        &visible_indices,
        &suspended,
        &flagged,
        &deferred,
        &non_reversible,
        state.localizer,
    );
    *state.ready_visible_indices.borrow_mut() = visible_indices.clone();

    ui.sync_ready_card_labels(&expected_labels);

    let selected_visible_row =
        selected_ready_list_row(state.selected_ready_key.as_ref(), &items, &visible_indices);
    if selected_visible_row != ui.ready_card_selection() {
        ui.set_ready_card_selection(selected_visible_row);
    }
    state.ready_list_sync_in_progress.set(false);
}

fn selected_ready_list_row(
    selected_key: Option<&DatumKey>,
    items: &[StudyItem],
    visible_indices: &[usize],
) -> Option<ReadyListRow> {
    selected_key
        .and_then(|selected_key| {
            items
                .iter()
                .position(|item| item.datum_key() == selected_key)
        })
        .and_then(|selected_index| {
            visible_indices
                .iter()
                .position(|index| *index == selected_index)
        })
        .map(ReadyListRow::new)
}

fn sync_ready_filter_choices(ui: &ReviewUi, state: &AppState) {
    let counts = state.study_items().ok().and_then(|items| {
        let schedules = state.load_study_item_schedules(&items).ok()?;
        let suspended = state.load_suspended_study_item_keys(&items).ok()?;
        let flagged = state.load_flagged_study_item_keys(&items).ok()?;
        let deferred = state.load_deferred_study_item_keys(&items).ok()?;
        let non_reversible = state.load_reversal_excluded_card_keys().ok()?;
        let review_stats = state.load_study_item_review_stats(&items).ok()?;
        let now_unix_seconds = current_time_and_seed()
            .map(|(now_unix_seconds, _)| now_unix_seconds)
            .unwrap_or(0);
        let scheduled_study_due = scheduled_study_due_keys(
            state,
            &items,
            &schedules,
            &suspended,
            &deferred,
            now_unix_seconds,
            0,
        )
        .ok()?;
        let filter_state = ReadyStudyItemFilterState {
            schedules: &schedules,
            suspended: &suspended,
            flagged: &flagged,
            deferred: &deferred,
            non_reversible: &non_reversible,
            scheduled_study_due: Some(&scheduled_study_due),
            review_stats: Some(&review_stats),
            now_unix_seconds,
        };
        Some(ready_study_item_filter_counts(
            &items,
            &state.ready_card_text_filter,
            &filter_state,
        ))
    });
    sync_ready_filter_choices_with_counts(ui, state, counts);
}

fn sync_ready_filter_choices_with_counts(
    ui: &ReviewUi,
    state: &AppState,
    counts: Option<[usize; ReadyCardFilter::COUNT]>,
) {
    set_ready_filter_choices(ui.ready_card_filter_choice, state.localizer, counts);
    let selection = state.ready_card_filter.selection_index().index();
    if ui.ready_card_filter_choice.get_selection() != Some(selection) {
        ui.ready_card_filter_choice.set_selection(selection);
    }
}

fn sync_ready_card_text_filter(ui: &ReviewUi, state: &AppState) {
    if ui.ready_card_text_filter.get_value() != state.ready_card_text_filter {
        ui.ready_card_text_filter
            .set_value(&state.ready_card_text_filter);
    }
}

fn sync_ready_sort_menu(ui: &ReviewUi, state: &AppState) {
    if let Some(menu_bar) = ui.frame.get_menu_bar() {
        menu_bar.check_item(
            CosimoUi::xrc_id("menu_sort_deck_order"),
            state.ready_card_sort == ReadyCardSort::DeckOrder,
        );
        menu_bar.check_item(
            CosimoUi::xrc_id("menu_sort_next_scheduled"),
            state.ready_card_sort == ReadyCardSort::NextScheduledReview,
        );
        menu_bar.check_item(
            CosimoUi::xrc_id("menu_sort_last_reviewed"),
            state.ready_card_sort == ReadyCardSort::LastReviewed,
        );
        menu_bar.check_item(
            CosimoUi::xrc_id("menu_sort_prompt_text"),
            state.ready_card_sort == ReadyCardSort::PromptText,
        );
        menu_bar.check_item(
            CosimoUi::xrc_id("menu_sort_response_text"),
            state.ready_card_sort == ReadyCardSort::ResponseText,
        );
        menu_bar.check_item(
            CosimoUi::xrc_id("menu_sort_estimated_recall"),
            state.ready_card_sort == ReadyCardSort::EstimatedRecall,
        );
        menu_bar.check_item(
            CosimoUi::xrc_id("menu_sort_ascending"),
            state.ready_card_sort_direction == ReadyCardSortDirection::Ascending,
        );
        menu_bar.check_item(
            CosimoUi::xrc_id("menu_sort_descending"),
            state.ready_card_sort_direction == ReadyCardSortDirection::Descending,
        );
    }
}

pub(crate) fn ready_screen_has_cards(state: &AppState) -> bool {
    !state
        .study_items()
        .unwrap_or_else(|_| state.fallback_study_items())
        .is_empty()
}

pub(crate) fn sync_ready_selection_controls(ui: &ReviewUi, state: &AppState) {
    sync_ready_selection_buttons(ui, state);
    sync_cards_menu_state(ui, state);
}

pub(crate) fn sync_ready_selection_buttons(ui: &ReviewUi, state: &AppState) {
    let has_selected_card = selected_ready_item_is_visible(state);
    ui.ready_edit_card_button.enable(has_selected_card);
    ui.ready_remove_card_button.enable(has_selected_card);
    let selected_audio_available = selected_ready_audio_available(state);
    ui.ready_audio_button.show(selected_audio_available);
    ui.ready_audio_button.enable(selected_audio_available);
}

#[derive(Debug, PartialEq, Eq)]
pub(crate) struct ReadyCardContextMenuLabels {
    pub(crate) edit: &'static str,
    pub(crate) remove: &'static str,
    pub(crate) details: &'static str,
    pub(crate) toggle_suspension: String,
    pub(crate) toggle_flag: &'static str,
    pub(crate) toggle_deferral: &'static str,
    pub(crate) toggle_reversal: Option<&'static str>,
    pub(crate) toggle_reversal_exclusion: Option<&'static str>,
}

pub(crate) fn ready_card_context_menu_labels(
    state: &AppState,
) -> Option<ReadyCardContextMenuLabels> {
    if !state.session.is_ready() || !selected_ready_item_is_visible(state) {
        return None;
    }

    let toggle_suspension = if selected_ready_card_is_suspended(state).unwrap_or(false) {
        state.localizer.unsuspend_selected_card_menu_item()
    } else {
        state.localizer.suspend_selected_card_menu_item()
    };

    Some(ReadyCardContextMenuLabels {
        edit: state.localizer.edit_selected_card_menu_item(),
        remove: state.localizer.remove_selected_card_menu_item(),
        details: state.localizer.selected_card_details_menu_item(),
        toggle_suspension: format!("{toggle_suspension}\tCtrl-S"),
        toggle_flag: current_or_selected_card_flag_menu_label(state),
        toggle_deferral: selected_ready_card_deferral_label(state),
        toggle_reversal: selected_ready_card_reversal_available(state)
            .then(|| selected_ready_card_reversal_label(state)),
        toggle_reversal_exclusion: selected_ready_card_reversal_exclusion_available(state)
            .then(|| selected_ready_card_reversal_exclusion_label(state)),
    })
}

pub(crate) fn show_ready_card_context_menu(
    ui: &ReviewUi,
    state: &Rc<RefCell<AppState>>,
    position: Option<wxdragon::geometry::Point>,
) {
    let state_ref = state.borrow();
    let Some(labels) = ready_card_context_menu_labels(&state_ref) else {
        return;
    };
    drop(state_ref);

    let mut menu_builder = Menu::builder()
        .append_item(CosimoUi::xrc_id("menu_edit_card"), labels.edit, "")
        .append_item(CosimoUi::xrc_id("menu_remove_card"), labels.remove, "")
        .append_item(CosimoUi::xrc_id("menu_card_details"), labels.details, "")
        .append_item(
            CosimoUi::xrc_id("menu_toggle_card_flag"),
            labels.toggle_flag,
            "",
        )
        .append_item(
            CosimoUi::xrc_id("menu_toggle_card_suspension"),
            &labels.toggle_suspension,
            "",
        )
        .append_item(
            CosimoUi::xrc_id("menu_toggle_card_deferral"),
            labels.toggle_deferral,
            "",
        );
    if let Some(label) = labels.toggle_reversal {
        menu_builder =
            menu_builder.append_item(CosimoUi::xrc_id("menu_toggle_card_reversal"), label, "");
    }
    if let Some(label) = labels.toggle_reversal_exclusion {
        menu_builder = menu_builder.append_item(
            CosimoUi::xrc_id("menu_toggle_card_reversal_exclusion"),
            label,
            "",
        );
    }
    let mut menu = menu_builder.build();
    ui.ready_cards.popup_menu(&mut menu, position);
}

pub(crate) fn sync_cards_menu_state(ui: &ReviewUi, state: &AppState) {
    let items = state
        .study_items()
        .unwrap_or_else(|_| state.fallback_study_items());
    let visible_indices = state.ready_visible_indices.borrow();
    let selected_item = selected_ready_visible_item(state, &items, &visible_indices);
    let has_selected_card = selected_item.is_some();
    let suspended = state.load_suspended_card_keys().unwrap_or_default();
    let flagged = state.load_flagged_card_keys().unwrap_or_default();
    let deferred = state.load_deferred_card_keys().unwrap_or_default();
    let reversible = state.load_reversible_card_keys().unwrap_or_default();
    let non_reversible = state.load_reversal_excluded_card_keys().unwrap_or_default();
    let edit_action = EditCardMenuAction::for_phase(state.session.phase());
    let edit_label = edit_action.label(state.localizer);
    let edit_enabled = edit_action.is_enabled(has_selected_card);
    let suspend_label = if selected_item.is_some_and(|item| suspended.contains(item.datum_key())) {
        state.localizer.unsuspend_selected_card_menu_item()
    } else {
        state.localizer.suspend_selected_card_menu_item()
    };
    let selected_or_current_key = if state.session.is_ready() {
        selected_item.map(|item| item.datum_key().clone())
    } else {
        state
            .current_study_item()
            .map(|item| item.datum_key().clone())
    };
    let flag_label = match (state.session.is_ready(), selected_or_current_key.as_ref()) {
        (true, Some(key)) if flagged.contains(key) => {
            state.localizer.unflag_selected_card_menu_item()
        }
        (true, _) => state.localizer.flag_selected_card_menu_item(),
        (false, Some(key)) if flagged.contains(key) => {
            state.localizer.unflag_current_card_menu_item()
        }
        (false, _) => state.localizer.flag_current_card_menu_item(),
    };
    let deferral_label = if selected_item.is_some_and(|item| deferred.contains(item.datum_key())) {
        state.localizer.clear_selected_card_deferral_menu_item()
    } else {
        state
            .localizer
            .defer_selected_card_until_tomorrow_menu_item()
    };
    let selected_parent_key = selected_item.map(StudyItem::parent_datum_key);
    let reversal_label = match (selected_item, selected_parent_key.as_ref()) {
        (Some(item), _) if item.is_reverse() => state.localizer.remove_reverse_card_menu_item(),
        (_, Some(parent_key)) if reversible.contains(parent_key) => {
            state.localizer.remove_reverse_card_menu_item()
        }
        _ => state.localizer.make_selected_card_reversible_menu_item(),
    };
    let reversal_available = match (selected_item, selected_parent_key.as_ref()) {
        (Some(item), _) if item.is_reverse() => true,
        (_, Some(parent_key)) if reversible.contains(parent_key) => true,
        (_, Some(parent_key)) => !non_reversible.contains(parent_key),
        _ => false,
    };
    let reversal_exclusion_label = if selected_parent_key
        .as_ref()
        .is_some_and(|parent_key| non_reversible.contains(parent_key))
    {
        state.localizer.allow_selected_card_reversal_menu_item()
    } else {
        state
            .localizer
            .mark_selected_card_non_reversible_menu_item()
    };
    let reversal_exclusion_available = selected_item.is_some_and(|item| !item.is_reverse());
    if let Some(menu_bar) = ui.frame.get_menu_bar() {
        set_menu_item_label(&menu_bar, CosimoUi::xrc_id("menu_edit_card"), edit_label);
        set_menu_item_label(
            &menu_bar,
            CosimoUi::xrc_id("menu_toggle_card_suspension"),
            suspend_label,
        );
        set_menu_item_label(
            &menu_bar,
            CosimoUi::xrc_id("menu_toggle_card_flag"),
            flag_label,
        );
        set_menu_item_label(
            &menu_bar,
            CosimoUi::xrc_id("menu_toggle_card_deferral"),
            deferral_label,
        );
        set_menu_item_label(
            &menu_bar,
            CosimoUi::xrc_id("menu_toggle_card_reversal"),
            reversal_label,
        );
        set_menu_item_label(
            &menu_bar,
            CosimoUi::xrc_id("menu_toggle_card_reversal_exclusion"),
            reversal_exclusion_label,
        );
    }
    let can_modify_ready_cards = state.session.is_ready();
    let flag_enabled = if state.session.is_ready() {
        has_selected_card
    } else {
        state.current_study_item().is_some()
    };
    let undo_bulk_reversal_enabled = can_modify_ready_cards
        && state
            .latest_bulk_reversal_count()
            .is_ok_and(|count| count > 0);
    let make_eligible_reversal_enabled = eligible_reversal_menu_enabled(state);
    let scheduled_start_enabled = can_modify_ready_cards
        && ready_phase_schedule_status(state)
            .preview()
            .map(|preview| preview.scheduled_cards > 0)
            .unwrap_or_else(|| !state.data.is_empty());
    let active_cards = items
        .iter()
        .filter(|item| {
            !suspended.contains(item.datum_key()) && !deferred.contains(item.datum_key())
        })
        .count();
    let review_flagged_enabled = can_modify_ready_cards
        && items.iter().any(|item| {
            flagged.contains(item.datum_key())
                && !suspended.contains(item.datum_key())
                && !deferred.contains(item.datum_key())
        });
    let resolve_duplicate_prompts_enabled =
        can_modify_ready_cards && state.duplicate_prompt_group_count() > 0;
    let shown_keys: Vec<DatumKey> = visible_indices
        .iter()
        .filter_map(|index| items.get(*index))
        .map(|item| item.datum_key().clone())
        .collect();
    let bulk_action_enabled = |active_keys: &HashSet<DatumKey>, should_be_active| {
        can_modify_ready_cards
            && shown_keys
                .iter()
                .any(|key| active_keys.contains(key) != should_be_active)
    };
    ui.sync_menu_state(MenuAvailability {
        add: can_modify_ready_cards,
        edit: edit_enabled,
        remove: can_modify_ready_cards && has_selected_card,
        card_details: can_modify_ready_cards && has_selected_card,
        resolve_duplicate_prompts: resolve_duplicate_prompts_enabled,
        suspend: can_modify_ready_cards && has_selected_card,
        flag: flag_enabled,
        flag_all_shown: bulk_action_enabled(&flagged, true),
        unflag_all_shown: bulk_action_enabled(&flagged, false),
        suspend_all_shown: bulk_action_enabled(&suspended, true),
        unsuspend_all_shown: bulk_action_enabled(&suspended, false),
        review_flagged: review_flagged_enabled,
        start_scheduled: scheduled_start_enabled,
        force_start: can_modify_ready_cards && active_cards > 0,
        start_exam: can_modify_ready_cards && active_cards > 0,
        start_quiz: can_modify_ready_cards && active_cards > 0,
        sync_upload: can_modify_ready_cards,
        sync_download: can_modify_ready_cards && state.deck_metadata.deck_uuid().is_some(),
        defer: can_modify_ready_cards && has_selected_card,
        reverse: can_modify_ready_cards && has_selected_card && reversal_available,
        reversal_exclusion: can_modify_ready_cards
            && has_selected_card
            && reversal_exclusion_available,
        make_eligible_reversal: make_eligible_reversal_enabled,
        undo_bulk_reversal: undo_bulk_reversal_enabled,
        generate_audio: audio_generation_menu_enabled(state),
    });
}

fn selected_ready_visible_item<'a>(
    state: &AppState,
    items: &'a [StudyItem],
    visible_indices: &[usize],
) -> Option<&'a StudyItem> {
    let selected_key = state.selected_ready_key.as_ref()?;
    items.iter().enumerate().find_map(|(index, item)| {
        (item.datum_key() == selected_key && visible_indices.contains(&index)).then_some(item)
    })
}

pub(crate) fn audio_generation_menu_enabled(state: &AppState) -> bool {
    state.session.is_ready()
        && audio_generation_metadata_is_complete(&state.deck_metadata)
        && audio_generation_metadata_allows_generation(&state.deck_metadata)
}

pub(crate) fn eligible_reversal_menu_enabled(state: &AppState) -> bool {
    state.session.is_ready()
        && state
            .eligible_reversible_card_count()
            .is_ok_and(|count| count > 0)
}

pub(crate) fn selected_ready_item_is_visible(state: &AppState) -> bool {
    let Some(selected_key) = &state.selected_ready_key else {
        return false;
    };
    let items = state
        .study_items()
        .unwrap_or_else(|_| state.fallback_study_items());
    let Some(selected_index) = items
        .iter()
        .position(|item| item.datum_key() == selected_key)
    else {
        return false;
    };
    state
        .ready_visible_indices
        .borrow()
        .contains(&selected_index)
}

pub(crate) fn render_ready_screen_after_card_change(ui: &ReviewUi, state: &Rc<RefCell<AppState>>) {
    {
        let mut state_ref = state.borrow_mut();
        state_ref.normalize_ready_selection_for_filter();
        state_ref.sync_ready_due_notification_baseline();
    }
    let state_ref = state.borrow();
    render(ui, &state_ref);
    ui.focus_ready_card_list(ReadyCardListFocusAvailability::for_has_cards(
        ready_screen_has_cards(&state_ref),
    ));
}

pub(crate) fn refresh_ready_screen_after_card_state_change(
    ui: &ReviewUi,
    state: &Rc<RefCell<AppState>>,
) {
    {
        let mut state_ref = state.borrow_mut();
        state_ref.sync_ready_due_notification_baseline();
    }

    let refresh_data = {
        let state_ref = state.borrow();
        if state_ref.session.phase() != ReviewPhase::ReadyToStart {
            render(ui, &state_ref);
            return;
        }
        ready_filter_refresh_data(&state_ref, ReadyFilterCountRefresh::Refresh)
    };

    let Ok(refresh_data) = refresh_data else {
        render_ready_screen_after_card_change(ui, state);
        return;
    };

    {
        let mut state_ref = state.borrow_mut();
        state_ref.normalize_ready_selection_for_visible_items(
            &refresh_data.items,
            refresh_data.visible_indices.clone(),
        );
    }

    let state_ref = state.borrow();
    let total_cards = state_ref.data.len();
    let localizer = state_ref.localizer;
    let ready_schedule_status =
        ReadyScheduleStatus::for_phase(&state_ref, ReviewPhase::ReadyToStart);
    let ready_summary = ready_summary_render_model(
        localizer,
        total_cards,
        &state_ref.review_db_path,
        state_ref.settings.minimum_cards_per_session,
        state_ref.settings.new_cards_per_day,
        ready_schedule_status.preview(),
        ready_summary_warning_text(&state_ref, ready_schedule_status.warning()),
    );
    let phase_model = review_phase_render_model(
        localizer,
        ReviewPhase::ReadyToStart,
        total_cards,
        state_ref.scheduled_card_count(),
        state_ref.session.current_index(),
        &state_ref.last_status,
        ready_schedule_status.warning(),
    );
    ui.status.set_label(&phase_model.status);
    let status_bar_text = current_time_and_seed()
        .and_then(|(now_unix_seconds, _)| main_status_bar_text(&state_ref, now_unix_seconds))
        .unwrap_or_else(|_| localizer.main_status_bar_schedule_unavailable(total_cards));
    ui.status_bar.set_status_text(&status_bar_text, 0);
    sync_cycle_summary_text(ui, &ready_summary.text);
    *state_ref.ready_visible_indices.borrow_mut() = refresh_data.visible_indices.clone();
    sync_ready_cards_list_from_refresh_data(
        ui,
        &state_ref,
        &refresh_data,
        ReadyListLabelSync::Incremental,
    );
    sync_ready_card_text_filter(ui, &state_ref);
    sync_ready_filter_choices_with_counts(ui, &state_ref, refresh_data.filter_counts);
    let has_selected_card = selected_ready_item_is_visible(&state_ref);
    let selected_audio_available = selected_ready_audio_available(&state_ref);
    let active_cards = refresh_data
        .items
        .iter()
        .filter(|item| {
            !refresh_data.suspended.contains(item.datum_key())
                && !refresh_data.deferred.contains(item.datum_key())
        })
        .count();
    let available_flagged_cards = refresh_data
        .items
        .iter()
        .filter(|item| {
            refresh_data.flagged.contains(item.datum_key())
                && !refresh_data.suspended.contains(item.datum_key())
                && !refresh_data.deferred.contains(item.datum_key())
        })
        .count();
    sync_ready_phase_controls(
        ui,
        localizer,
        ready_phase_controls_render_model(
            has_selected_card,
            selected_audio_available,
            ready_schedule_status.preview(),
            total_cards,
            active_cards,
            Some(available_flagged_cards),
        ),
    );
    sync_review_action_controls(
        ui,
        ReviewActionControlsRenderModel::for_ready_phase(active_cards > 0),
    );
    ui.root_panel.layout();
    ui.focus_ready_card_list(ReadyCardListFocusAvailability::for_has_cards(
        ready_screen_has_cards(&state_ref),
    ));
}

pub(crate) fn render_ready_screen_after_maintenance(
    ui: &ReviewUi,
    state: &Rc<RefCell<AppState>>,
    previous_focus: Option<ReadyScreenFocus>,
) {
    {
        let mut state_ref = state.borrow_mut();
        state_ref.normalize_ready_selection_for_filter();
        state_ref.sync_ready_due_notification_baseline();
    }
    let state_ref = state.borrow();
    render(ui, &state_ref);
    if let Some(previous_focus) = previous_focus {
        ui.restore_ready_screen_focus(
            previous_focus,
            ReadyCardListFocusAvailability::for_has_cards(ready_screen_has_cards(&state_ref)),
        );
    }
}

pub(crate) fn refresh_ready_screen_after_filter_change(
    ui: &ReviewUi,
    state: &Rc<RefCell<AppState>>,
    change_kind: ReadyFilterChangeKind,
) {
    let refresh_data = {
        let state_ref = state.borrow();
        if state_ref.session.phase() != ReviewPhase::ReadyToStart {
            render(ui, &state_ref);
            return;
        }
        let count_refresh = match change_kind {
            ReadyFilterChangeKind::Choice => ReadyFilterCountRefresh::Skip,
            ReadyFilterChangeKind::Text => ReadyFilterCountRefresh::Refresh,
        };
        ready_filter_refresh_data(&state_ref, count_refresh)
    };

    let Ok(refresh_data) = refresh_data else {
        let mut state_ref = state.borrow_mut();
        state_ref.normalize_ready_selection_for_filter();
        drop(state_ref);
        let state_ref = state.borrow();
        sync_ready_cards_list(ui, &state_ref);
        sync_ready_card_text_filter(ui, &state_ref);
        sync_ready_filter_choices(ui, &state_ref);
        sync_ready_selection_controls(ui, &state_ref);
        return;
    };

    {
        let mut state_ref = state.borrow_mut();
        state_ref.normalize_ready_selection_for_visible_items(
            &refresh_data.items,
            refresh_data.visible_indices.clone(),
        );
    }

    let state_ref = state.borrow();
    sync_ready_cards_list_from_refresh_data(
        ui,
        &state_ref,
        &refresh_data,
        match change_kind {
            ReadyFilterChangeKind::Choice => ReadyListLabelSync::ReplaceExisting,
            ReadyFilterChangeKind::Text => ReadyListLabelSync::Incremental,
        },
    );
    sync_ready_card_text_filter(ui, &state_ref);
    if change_kind == ReadyFilterChangeKind::Text {
        sync_ready_filter_choices_with_counts(ui, &state_ref, refresh_data.filter_counts);
    }
    sync_ready_selection_buttons(ui, &state_ref);
}

pub(crate) fn selected_ready_card_is_suspended(state: &AppState) -> Result<bool, String> {
    let Some(item) = state.selected_ready_study_item()? else {
        return Ok(false);
    };
    let suspended = state.load_suspended_card_keys()?;
    Ok(suspended.contains(item.datum_key()))
}

fn selected_ready_card_is_deferred(state: &AppState) -> Result<bool, String> {
    let Some(item) = state.selected_ready_study_item()? else {
        return Ok(false);
    };
    let deferred = state.load_deferred_card_keys()?;
    Ok(deferred.contains(item.datum_key()))
}

fn current_or_selected_card_is_flagged(state: &AppState) -> Result<bool, String> {
    let item = if state.session.is_ready() {
        state.selected_ready_study_item()?
    } else {
        state.current_study_item()
    };
    let Some(item) = item else {
        return Ok(false);
    };
    let flagged = state.load_flagged_card_keys()?;
    Ok(flagged.contains(item.datum_key()))
}

pub(crate) fn current_or_selected_card_flag_menu_label(state: &AppState) -> &'static str {
    let is_flagged = current_or_selected_card_is_flagged(state).unwrap_or(false);
    match (state.session.is_ready(), is_flagged) {
        (true, true) => state.localizer.unflag_selected_card_menu_item(),
        (true, false) => state.localizer.flag_selected_card_menu_item(),
        (false, true) => state.localizer.unflag_current_card_menu_item(),
        (false, false) => state.localizer.flag_current_card_menu_item(),
    }
}

pub(crate) fn current_card_flag_button_label(state: &AppState) -> &'static str {
    if current_or_selected_card_is_flagged(state).unwrap_or(false) {
        state.localizer.unflag_card_button_label()
    } else {
        state.localizer.flag_card_button_label()
    }
}

pub(crate) fn selected_ready_card_deferral_label(state: &AppState) -> &'static str {
    if selected_ready_card_is_deferred(state).unwrap_or(false) {
        state.localizer.clear_selected_card_deferral_menu_item()
    } else {
        state
            .localizer
            .defer_selected_card_until_tomorrow_menu_item()
    }
}

pub(crate) fn selected_ready_card_reversal_label(state: &AppState) -> &'static str {
    let Ok(Some(item)) = state.selected_ready_study_item() else {
        return state.localizer.make_selected_card_reversible_menu_item();
    };
    if item.is_reverse()
        || state
            .load_reversible_card_keys()
            .is_ok_and(|keys| keys.contains(&item.parent_datum_key()))
    {
        state.localizer.remove_reverse_card_menu_item()
    } else {
        state.localizer.make_selected_card_reversible_menu_item()
    }
}

pub(crate) fn selected_ready_card_reversal_available(state: &AppState) -> bool {
    let Ok(Some(item)) = state.selected_ready_study_item() else {
        return false;
    };
    if item.is_reverse() {
        return true;
    }
    let parent_key = item.parent_datum_key();
    if state
        .load_reversible_card_keys()
        .is_ok_and(|keys| keys.contains(&parent_key))
    {
        return true;
    }
    !state
        .load_reversal_excluded_card_keys()
        .is_ok_and(|keys| keys.contains(&parent_key))
}

pub(crate) fn selected_ready_card_reversal_exclusion_label(state: &AppState) -> &'static str {
    let Ok(Some(item)) = state.selected_ready_study_item() else {
        return state
            .localizer
            .mark_selected_card_non_reversible_menu_item();
    };
    if state
        .load_reversal_excluded_card_keys()
        .is_ok_and(|keys| keys.contains(&item.parent_datum_key()))
    {
        state.localizer.allow_selected_card_reversal_menu_item()
    } else {
        state
            .localizer
            .mark_selected_card_non_reversible_menu_item()
    }
}

pub(crate) fn selected_ready_card_reversal_exclusion_available(state: &AppState) -> bool {
    state
        .selected_ready_study_item()
        .is_ok_and(|item| item.is_some_and(|item| !item.is_reverse()))
}

pub(crate) fn install_ready_refresh_timer(
    ui: &ReviewUi,
    state: Rc<RefCell<AppState>>,
    resident_tray: Rc<RefCell<Option<ResidentTray>>>,
) -> Rc<RefCell<Option<Timer<Panel>>>> {
    let timer_store = Rc::new(RefCell::new(None));
    let timer = Timer::new(&ui.root_panel);
    let ui_copy = *ui;
    timer.on_tick(move |_| {
        refresh_ready_screen_after_timed_refresh(&ui_copy, &state, &resident_tray)
    });
    timer.start(READY_REFRESH_INTERVAL_MS, false);
    timer_store.borrow_mut().replace(timer);
    timer_store
}

pub(crate) fn install_ready_refresh_key_handlers(ui: &ReviewUi, state: Rc<RefCell<AppState>>) {
    bind_ready_refresh_key_handler(ui.frame, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.root_panel, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.start_panel, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.cycle_summary, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.ready_card_text_filter, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.ready_card_filter_choice, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.ready_add_card_button, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.ready_edit_card_button, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.ready_remove_card_button, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.ready_audio_button, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.start_cycle_button, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.force_start_cycle_button, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.review_flagged_cards_button, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.start_exam_button, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.start_quiz_button, *ui, Rc::clone(&state));
    bind_ready_refresh_key_handler(ui.start_quit_button, *ui, Rc::clone(&state));
}

fn bind_ready_refresh_key_handler<W>(widget: W, ui: ReviewUi, state: Rc<RefCell<AppState>>)
where
    W: WxWidget + WindowEvents + Copy + 'static,
{
    widget.on_key_down(move |event| {
        if let WindowEventData::Keyboard(key_event) = event {
            match ready_refresh_key_disposition(
                state.borrow().session.phase(),
                key_event.get_key_code(),
            ) {
                ReadyRefreshKeyDisposition::Refresh => {
                    refresh_ready_screen_without_notification(&ui, &state);
                    key_event.event.skip(false);
                }
                ReadyRefreshKeyDisposition::Skip => key_event.event.skip(true),
            }
        }
    });
}

pub(crate) fn ready_refresh_key_disposition(
    phase: ReviewPhase,
    key_code: Option<i32>,
) -> ReadyRefreshKeyDisposition {
    if matches!(phase, ReviewPhase::ReadyToStart) && is_ready_screen_refresh_key(key_code) {
        ReadyRefreshKeyDisposition::Refresh
    } else {
        ReadyRefreshKeyDisposition::Skip
    }
}

fn refresh_ready_screen_after_timed_refresh(
    ui: &ReviewUi,
    state: &Rc<RefCell<AppState>>,
    resident_tray: &Rc<RefCell<Option<ResidentTray>>>,
) {
    if !state.borrow().session.is_ready() {
        return;
    }

    notify_ready_due_change(ui, state, resident_tray);
    state.borrow_mut().normalize_ready_selection_for_filter();
    render(ui, &state.borrow());
    sync_ready_selection_controls(ui, &state.borrow());
}

pub(crate) fn refresh_ready_screen_without_notification(
    ui: &ReviewUi,
    state: &Rc<RefCell<AppState>>,
) {
    if !state.borrow().session.is_ready() {
        return;
    }

    state.borrow_mut().sync_ready_due_notification_baseline();
    state.borrow_mut().normalize_ready_selection_for_filter();
    render(ui, &state.borrow());
    sync_ready_selection_controls(ui, &state.borrow());
}

fn notify_ready_due_change(
    ui: &ReviewUi,
    state: &Rc<RefCell<AppState>>,
    resident_tray: &Rc<RefCell<Option<ResidentTray>>>,
) {
    let current_due_count = {
        let state_ref = state.borrow();
        match schedule_preview_if_started_now(&state_ref) {
            Ok(preview) => preview.due_cards,
            Err(_) => return,
        }
    };

    let notification_kind = {
        let mut state_ref = state.borrow_mut();
        let notification_kind = if sound_notification_policy_allows(
            state_ref.settings.sound_notification_policy,
            NotificationVisibility::new(
                ui.frame.is_shown() && !ui.frame.is_iconized(),
                resident_tray_visible(resident_tray),
            ),
        ) && !state_ref.ready_due_notifications_suspended()
        {
            due_notification_kind(
                state_ref.last_ready_due_notification_count,
                current_due_count,
            )
        } else {
            None
        };
        state_ref.last_ready_due_notification_count = Some(current_due_count);
        notification_kind
    };

    if let Some(notification_kind) = notification_kind {
        play_due_notification(notification_kind);
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct NotificationVisibility {
    app_visible: bool,
    in_tray: bool,
}

impl NotificationVisibility {
    pub(crate) fn new(app_visible: bool, in_tray: bool) -> Self {
        Self {
            app_visible,
            in_tray,
        }
    }
}

pub(crate) fn sound_notification_policy_allows(
    policy: SoundNotificationPolicy,
    visibility: NotificationVisibility,
) -> bool {
    policy.allows_notification(visibility.app_visible, visibility.in_tray)
}

pub(crate) fn render(ui: &ReviewUi, state: &AppState) {
    let total_cards = state.data.len();
    let scheduled_cards = state.scheduled_card_count();
    let localizer = state.localizer;
    let phase = state.session.phase();
    let ready_schedule_status = ReadyScheduleStatus::for_phase(state, phase);
    let scheduled_cards_if_started = ready_schedule_status.preview();
    let phase_model = review_phase_render_model(
        localizer,
        phase,
        total_cards,
        scheduled_cards,
        state.session.current_index(),
        &state.last_status,
        ready_schedule_status.warning(),
    );

    ui.status.set_label(&phase_model.status);
    let status_bar_text = current_time_and_seed()
        .and_then(|(now_unix_seconds, _)| main_status_bar_text(state, now_unix_seconds))
        .unwrap_or_else(|_| localizer.main_status_bar_schedule_unavailable(total_cards));
    ui.status_bar.set_status_text(&status_bar_text, 0);
    sync_cards_menu_state(ui, state);
    sync_review_phase_chrome(ui, review_phase_chrome_render_model(phase));

    match phase {
        ReviewPhase::ReadyToStart => {
            let ready_summary = ready_summary_render_model(
                localizer,
                total_cards,
                &state.review_db_path,
                state.settings.minimum_cards_per_session,
                state.settings.new_cards_per_day,
                scheduled_cards_if_started,
                ready_summary_warning_text(state, ready_schedule_status.warning()),
            );
            sync_cycle_summary_text(ui, &ready_summary.text);
            sync_ready_cards_list(ui, state);
            sync_ready_card_text_filter(ui, state);
            sync_ready_filter_choices(ui, state);
            sync_ready_sort_menu(ui, state);
            let has_selected_card = selected_ready_item_is_visible(state);
            let selected_audio_available = selected_ready_audio_available(state);
            let active_cards = state.active_card_count().unwrap_or(total_cards);
            let ready_controls_model = ready_phase_controls_render_model(
                has_selected_card,
                selected_audio_available,
                scheduled_cards_if_started,
                total_cards,
                active_cards,
                state.available_flagged_card_count().ok(),
            );
            sync_ready_phase_controls(ui, localizer, ready_controls_model);
            let card_model = ready_phase_card_panel_render_model(localizer);
            sync_review_card_panel(ui, localizer, &card_model);
            let controls_model = ReviewActionControlsRenderModel::for_ready_phase(active_cards > 0);
            sync_review_action_controls(ui, controls_model);
        }
        ReviewPhase::Prompt => {
            let datum = state
                .current_datum()
                .expect("current index is kept in range");
            let review_audio = current_review_audio_file(state);
            let card_model = prompt_phase_card_panel_render_model(
                localizer,
                datum.prompt(),
                review_audio.as_ref().map(|audio| audio.side()),
            );
            sync_review_card_panel(ui, localizer, &card_model);
            ui.ready_audio_button.show(false);
            ui.ready_audio_button.enable(false);
            let controls_model = ReviewActionControlsRenderModel::for_prompt_phase();
            sync_review_action_controls(ui, controls_model);
            ui.guess_flag_card_button
                .set_label(current_card_flag_button_label(state));
        }
        ReviewPhase::Response { .. } => {
            let datum = state
                .current_datum()
                .expect("current index is kept in range");
            let review_audio = current_review_audio_file(state);
            let card_model = response_phase_card_panel_render_model(
                localizer,
                datum.prompt(),
                datum.response(),
                review_audio.as_ref().map(|audio| audio.side()),
            );
            sync_review_card_panel(ui, localizer, &card_model);
            ui.ready_audio_button.show(false);
            ui.ready_audio_button.enable(false);
            let controls_model = ReviewActionControlsRenderModel::for_response_phase();
            sync_review_action_controls(ui, controls_model);
            ui.feedback_flag_card_button
                .set_label(current_card_flag_button_label(state));
        }
    }

    ui.root_panel.layout();
    match phase {
        ReviewPhase::ReadyToStart => {}
        ReviewPhase::Prompt => ui.prompt.set_focus(),
        ReviewPhase::Response { .. } => ui.response.set_focus(),
    }
    state
        .review_audio_player
        .borrow_mut()
        .sync_after_render(ui, state);
}

fn sync_review_phase_chrome(ui: &ReviewUi, model: ReviewPhaseChromeRenderModel) {
    ui.start_panel.show(model.start_panel_visible);
    ui.ready_cards_heading
        .show(model.ready_cards_heading_visible);
    ui.ready_card_text_filter_label
        .show(model.ready_card_text_filter_visible);
    ui.ready_card_text_filter
        .show(model.ready_card_text_filter_visible);
    if let Some(ready_card_filter_visible) = model.ready_card_filter_visible {
        ui.ready_card_filter_label.show(ready_card_filter_visible);
        ui.ready_card_filter_choice.show(ready_card_filter_visible);
    }
}

fn sync_review_card_panel(ui: &ReviewUi, localizer: Localizer, model: &ReviewCardPanelRenderModel) {
    ui.prompt_heading.set_label(&model.prompt_heading);
    ui.prompt_heading.show(model.prompt_visible);
    ui.prompt.show(model.prompt_visible);
    ui.prompt.set_value(&model.prompt_text);
    if model.prompt_visible {
        ui.prompt.set_insertion_point(0);
    }
    ui.response_heading.set_label(&model.response_heading);
    ui.response_heading.show(model.response_visible);
    ui.response.show(model.response_visible);
    ui.response.set_value(&model.response_text);
    if model.response_visible {
        ui.response.set_insertion_point(0);
    }
    ui.review_audio_button
        .set_label(localizer.play_stop_review_audio_button_label());
    ui.review_audio_button.show(model.review_audio_available);
    ui.review_audio_button.enable(model.review_audio_available);
}

fn sync_ready_phase_controls(
    ui: &ReviewUi,
    localizer: Localizer,
    model: ReadyPhaseControlsRenderModel,
) {
    ui.ready_edit_card_button.enable(model.ready_edit_enabled);
    ui.ready_remove_card_button
        .enable(model.ready_remove_enabled);
    ui.ready_audio_button
        .set_label(localizer.play_stop_review_audio_button_label());
    ui.ready_audio_button.show(model.ready_audio_available);
    ui.ready_audio_button.enable(model.ready_audio_available);
    ui.start_cycle_button.enable(model.start_cycle_enabled);
    ui.force_start_cycle_button
        .enable(model.force_start_enabled);
    ui.review_flagged_cards_button
        .enable(model.review_flagged_enabled);
}

fn sync_review_action_controls(ui: &ReviewUi, model: ReviewActionControlsRenderModel) {
    ui.guess_panel.show(model.guess_panel_visible);
    ui.feedback_panel.show(model.feedback_panel_visible);
    set_buttons_enabled(&ui.guess_buttons, model.guess_buttons_enabled);
    set_buttons_enabled(&ui.feedback_buttons, model.feedback_buttons_enabled);
    ui.reveal_response_button
        .enable(model.reveal_response_enabled);
    ui.guess_flag_card_button
        .enable(model.guess_flag_card_enabled);
    ui.guess_defer_card_button
        .enable(model.guess_defer_card_enabled);
    ui.guess_end_pass_button
        .enable(model.guess_end_pass_enabled);
    ui.feedback_end_pass_button
        .enable(model.feedback_end_pass_enabled);
    ui.feedback_flag_card_button
        .enable(model.feedback_flag_card_enabled);
    ui.feedback_edit_card_button
        .enable(model.feedback_edit_card_enabled);
    ui.start_exam_button.enable(model.start_exam_enabled);
    ui.start_quiz_button.enable(model.start_quiz_enabled);
}

fn set_buttons_enabled(buttons: &[Button; 4], enabled: bool) {
    for button in buttons {
        button.enable(enabled);
    }
}

fn sync_cycle_summary_text(ui: &ReviewUi, summary: &str) {
    let current_summary = ui.cycle_summary.get_value();
    if text_control_value_matches(&current_summary, summary) {
        return;
    }

    let insertion_point = ui
        .cycle_summary
        .has_focus()
        .then(|| ui.cycle_summary.get_insertion_point());
    ui.cycle_summary.set_value(summary);
    if let Some(insertion_point) = insertion_point {
        ui.cycle_summary
            .set_insertion_point(insertion_point.min(summary.len() as i64));
    } else {
        ui.cycle_summary.set_insertion_point(0);
    }
}

pub(crate) fn text_control_value_matches(current: &str, expected: &str) -> bool {
    current == expected || current.replace("\r\n", "\n") == expected
}

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

    fn english() -> Localizer {
        Localizer::new(crate::localization::Language::English)
    }

    #[test]
    fn review_phase_render_model_builds_ready_status_with_warning() {
        let localizer = english();
        let status = review_phase_render_model(
            localizer,
            ReviewPhase::ReadyToStart,
            3,
            0,
            0,
            "Deck loaded.",
            Some("Schedule unavailable."),
        )
        .status;

        assert_eq!(
            status,
            format!(
                "Deck loaded. {} Schedule unavailable.",
                localizer.ready_phase_instruction(3)
            )
        );
    }

    #[test]
    fn review_phase_render_model_builds_running_progress_status() {
        let localizer = english();
        let status = review_phase_render_model(
            localizer,
            ReviewPhase::Prompt,
            3,
            5,
            1,
            "Previous card recorded.",
            None,
        )
        .status;

        assert_eq!(
            status,
            localizer.current_card_status(
                "Previous card recorded.",
                2,
                5,
                localizer.prompt_phase_instruction()
            )
        );
    }

    #[test]
    fn prompt_phase_card_panel_render_model_hides_response_and_marks_prompt_audio() {
        let localizer = english();
        let model =
            prompt_phase_card_panel_render_model(localizer, "Front", Some(ReviewAudioSide::Prompt));

        assert!(model.prompt_visible);
        assert_eq!(model.prompt_heading, localizer.prompt_heading_with_audio());
        assert_eq!(model.prompt_text, "Front");
        assert_eq!(model.response_heading, localizer.response_heading());
        assert_eq!(model.response_text, "");
        assert!(!model.response_visible);
        assert!(model.review_audio_available);
    }

    #[test]
    fn response_phase_card_panel_render_model_shows_response_and_marks_response_audio() {
        let localizer = english();
        let model = response_phase_card_panel_render_model(
            localizer,
            "Front",
            "Back",
            Some(ReviewAudioSide::Response),
        );

        assert!(model.prompt_visible);
        assert_eq!(model.prompt_heading, localizer.prompt_heading());
        assert_eq!(model.prompt_text, "Front");
        assert_eq!(
            model.response_heading,
            localizer.response_heading_with_audio()
        );
        assert_eq!(model.response_text, "Back");
        assert!(model.response_visible);
        assert!(model.review_audio_available);
    }

    #[test]
    fn ready_phase_card_panel_render_model_clears_review_card_fields() {
        let localizer = english();
        let model = ready_phase_card_panel_render_model(localizer);

        assert!(!model.prompt_visible);
        assert_eq!(model.prompt_heading, localizer.prompt_heading());
        assert_eq!(model.prompt_text, "");
        assert_eq!(model.response_heading, localizer.response_heading());
        assert_eq!(model.response_text, "");
        assert!(!model.response_visible);
        assert!(!model.review_audio_available);
    }

    #[test]
    fn prompt_phase_action_controls_enable_guess_controls_only() {
        let model = ReviewActionControlsRenderModel::for_prompt_phase();

        assert!(model.guess_panel_visible);
        assert!(!model.feedback_panel_visible);
        assert!(model.guess_buttons_enabled);
        assert!(!model.feedback_buttons_enabled);
        assert!(model.reveal_response_enabled);
        assert!(model.guess_flag_card_enabled);
        assert!(model.guess_defer_card_enabled);
        assert!(model.guess_end_pass_enabled);
        assert!(!model.feedback_end_pass_enabled);
        assert!(!model.feedback_flag_card_enabled);
        assert!(!model.feedback_edit_card_enabled);
        assert!(!model.start_exam_enabled);
        assert!(!model.start_quiz_enabled);
    }

    #[test]
    fn ready_phase_action_controls_disable_review_controls_and_use_active_cards_for_exam_buttons() {
        let model = ReviewActionControlsRenderModel::for_ready_phase(true);

        assert!(!model.guess_panel_visible);
        assert!(!model.feedback_panel_visible);
        assert!(!model.guess_buttons_enabled);
        assert!(!model.feedback_buttons_enabled);
        assert!(!model.reveal_response_enabled);
        assert!(!model.guess_flag_card_enabled);
        assert!(!model.guess_defer_card_enabled);
        assert!(!model.guess_end_pass_enabled);
        assert!(!model.feedback_end_pass_enabled);
        assert!(!model.feedback_flag_card_enabled);
        assert!(!model.feedback_edit_card_enabled);
        assert!(model.start_exam_enabled);
        assert!(model.start_quiz_enabled);

        let inactive_model = ReviewActionControlsRenderModel::for_ready_phase(false);

        assert!(!inactive_model.start_exam_enabled);
        assert!(!inactive_model.start_quiz_enabled);
    }

    #[test]
    fn phase_chrome_model_shows_ready_controls_only_before_review_starts() {
        let model = review_phase_chrome_render_model(ReviewPhase::ReadyToStart);

        assert!(model.start_panel_visible);
        assert!(!model.ready_cards_heading_visible);
        assert!(model.ready_card_text_filter_visible);
        assert_eq!(model.ready_card_filter_visible, Some(true));
    }

    #[test]
    fn phase_chrome_model_hides_ready_chrome_during_prompt_without_touching_filter_choice() {
        let prompt_model = review_phase_chrome_render_model(ReviewPhase::Prompt);

        assert!(!prompt_model.start_panel_visible);
        assert!(!prompt_model.ready_cards_heading_visible);
        assert!(!prompt_model.ready_card_text_filter_visible);
        assert_eq!(prompt_model.ready_card_filter_visible, None);
    }

    #[test]
    fn response_phase_action_controls_enable_feedback_controls_only() {
        let model = ReviewActionControlsRenderModel::for_response_phase();

        assert!(!model.guess_panel_visible);
        assert!(model.feedback_panel_visible);
        assert!(!model.guess_buttons_enabled);
        assert!(model.feedback_buttons_enabled);
        assert!(!model.reveal_response_enabled);
        assert!(!model.guess_flag_card_enabled);
        assert!(!model.guess_defer_card_enabled);
        assert!(!model.guess_end_pass_enabled);
        assert!(model.feedback_end_pass_enabled);
        assert!(model.feedback_flag_card_enabled);
        assert!(model.feedback_edit_card_enabled);
        assert!(!model.start_exam_enabled);
        assert!(!model.start_quiz_enabled);
    }

    #[test]
    fn ready_summary_render_model_combines_schedule_preview_and_warning() {
        let localizer = english();
        let review_db_path = std::path::Path::new("/tmp/cosimo-test.sqlite3");
        let schedule_preview = ReadySchedulePreview {
            scheduled_cards: 2,
            due_cards: 1,
        };
        let warning = Some("Schedule warning.".to_string());
        let model = ready_summary_render_model(
            localizer,
            3,
            review_db_path,
            5,
            20,
            Some(schedule_preview),
            warning.clone(),
        );

        assert_eq!(
            model.text,
            ready_summary_text_with_warning(
                ready_to_start_summary_text(
                    localizer,
                    3,
                    review_db_path,
                    5,
                    20,
                    Some(schedule_preview.scheduled_cards),
                    Some(schedule_preview.due_cards),
                ),
                warning,
            )
        );
    }

    #[test]
    fn ready_phase_controls_use_schedule_preview_and_active_card_counts() {
        let model = ready_phase_controls_render_model(
            true,
            true,
            Some(ReadySchedulePreview {
                scheduled_cards: 0,
                due_cards: 0,
            }),
            5,
            3,
            Some(1),
        );

        assert!(model.ready_edit_enabled);
        assert!(model.ready_remove_enabled);
        assert!(model.ready_audio_available);
        assert!(!model.start_cycle_enabled);
        assert!(model.force_start_enabled);
        assert!(model.review_flagged_enabled);
    }

    #[test]
    fn ready_phase_controls_fall_back_to_total_cards_without_schedule_preview() {
        let empty_model = ready_phase_controls_render_model(false, false, None, 0, 0, None);

        assert!(!empty_model.ready_edit_enabled);
        assert!(!empty_model.ready_remove_enabled);
        assert!(!empty_model.ready_audio_available);
        assert!(!empty_model.start_cycle_enabled);
        assert!(!empty_model.force_start_enabled);
        assert!(!empty_model.review_flagged_enabled);

        let unscheduled_model =
            ready_phase_controls_render_model(false, false, None, 1, 0, Some(0));

        assert!(unscheduled_model.start_cycle_enabled);
        assert!(!unscheduled_model.force_start_enabled);
        assert!(!unscheduled_model.review_flagged_enabled);
    }

    #[test]
    fn ready_refresh_key_only_refreshes_ready_screen_on_f5() {
        assert_eq!(
            ready_refresh_key_disposition(ReviewPhase::ReadyToStart, Some(116)),
            ReadyRefreshKeyDisposition::Refresh
        );
        assert_eq!(
            ready_refresh_key_disposition(ReviewPhase::ReadyToStart, Some(344)),
            ReadyRefreshKeyDisposition::Refresh
        );
        assert_eq!(
            ready_refresh_key_disposition(ReviewPhase::Prompt, Some(116)),
            ReadyRefreshKeyDisposition::Skip
        );
        assert_eq!(
            ready_refresh_key_disposition(ReviewPhase::ReadyToStart, Some(115)),
            ReadyRefreshKeyDisposition::Skip
        );
        assert_eq!(
            ready_refresh_key_disposition(ReviewPhase::ReadyToStart, None),
            ReadyRefreshKeyDisposition::Skip
        );
    }
}
