use crate::confidence_diagnostics::RecentConfidenceDiagnostics;
use crate::learning_dashboard_model::CalibrationRecallRule;
use crate::localization::Localizer;
use crate::recall_estimate::estimated_recall_now;
use crate::study_item::StudyItem;
#[cfg(test)]
use cosimo::schedule_datum;
use cosimo::{
    CardMemoryState, CardSchedule, Datum, DatumKey, RecallFeedback, ReviewEvent,
    recent_scheduler_underperformance_signal, schedule_datum_for_key,
};
use std::cmp::Ordering;
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReadyCardFilter {
    All,
    Due,
    New,
    NotDue,
    Suspended,
    Flagged,
    Deferred,
    NonReversible,
    Reverse,
    DuplicatePrompts,
    DuplicateResponses,
    ProblemCards,
    AsymmetryIssues,
    HighConfidenceFailures,
    LowConfidenceSuccesses,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ReadyListRow {
    index: usize,
}

impl ReadyListRow {
    pub(crate) fn new(index: usize) -> Self {
        Self { index }
    }

    pub(crate) fn from_wx_selection(selection: Option<u32>) -> Option<Self> {
        selection.map(|index| Self::new(index as usize))
    }

    pub(crate) fn from_wx_event_selection(selection: Option<i32>) -> Option<Self> {
        selection
            .and_then(|index| u32::try_from(index).ok())
            .map(|index| Self::new(index as usize))
    }

    pub(crate) fn index_usize(self) -> usize {
        self.index
    }

    pub(crate) fn index_u32(self) -> u32 {
        self.index as u32
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ReadyCardFilterSelection {
    index: u32,
}

impl ReadyCardFilterSelection {
    pub(crate) fn new(index: u32) -> Option<Self> {
        (index < ReadyCardFilter::COUNT as u32).then_some(Self { index })
    }

    pub(crate) fn from_wx_event_selection(selection: Option<i32>) -> Option<Self> {
        selection
            .and_then(|index| u32::try_from(index).ok())
            .and_then(Self::new)
    }

    pub(crate) fn index(self) -> u32 {
        self.index
    }

    pub(crate) fn index_usize(self) -> usize {
        self.index as usize
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReadyCardSort {
    DeckOrder,
    NextScheduledReview,
    LastReviewed,
    PromptText,
    ResponseText,
    EstimatedRecall,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReadyCardSortDirection {
    Ascending,
    Descending,
}

impl ReadyCardSort {
    #[cfg(test)]
    pub(crate) const ALL: &'static [Self] = &[
        Self::DeckOrder,
        Self::NextScheduledReview,
        Self::LastReviewed,
        Self::PromptText,
        Self::ResponseText,
        Self::EstimatedRecall,
    ];

    pub(crate) fn from_code(code: &str) -> Option<Self> {
        match code {
            "deck-order" => Some(Self::DeckOrder),
            "next-scheduled-review" => Some(Self::NextScheduledReview),
            "last-reviewed" => Some(Self::LastReviewed),
            "prompt-text" => Some(Self::PromptText),
            "response-text" => Some(Self::ResponseText),
            "estimated-recall" => Some(Self::EstimatedRecall),
            _ => None,
        }
    }

    pub(crate) fn code(self) -> &'static str {
        match self {
            Self::DeckOrder => "deck-order",
            Self::NextScheduledReview => "next-scheduled-review",
            Self::LastReviewed => "last-reviewed",
            Self::PromptText => "prompt-text",
            Self::ResponseText => "response-text",
            Self::EstimatedRecall => "estimated-recall",
        }
    }

    pub(crate) fn default_direction(self) -> ReadyCardSortDirection {
        match self {
            Self::DeckOrder
            | Self::NextScheduledReview
            | Self::PromptText
            | Self::ResponseText
            | Self::EstimatedRecall => ReadyCardSortDirection::Ascending,
            Self::LastReviewed => ReadyCardSortDirection::Descending,
        }
    }
}

impl ReadyCardSortDirection {
    #[cfg(test)]
    pub(crate) const ALL: &'static [Self] = &[Self::Ascending, Self::Descending];

    pub(crate) fn from_code(code: &str) -> Option<Self> {
        match code {
            "ascending" => Some(Self::Ascending),
            "descending" => Some(Self::Descending),
            _ => None,
        }
    }

    pub(crate) fn code(self) -> &'static str {
        match self {
            Self::Ascending => "ascending",
            Self::Descending => "descending",
        }
    }
}

pub(crate) struct ReadyStudyItemFilterState<'a> {
    pub(crate) schedules: &'a HashMap<DatumKey, CardSchedule>,
    pub(crate) suspended: &'a HashSet<DatumKey>,
    pub(crate) flagged: &'a HashSet<DatumKey>,
    pub(crate) deferred: &'a HashSet<DatumKey>,
    pub(crate) non_reversible: &'a HashSet<DatumKey>,
    pub(crate) scheduled_study_due: Option<&'a HashSet<DatumKey>>,
    pub(crate) review_stats: Option<&'a HashMap<DatumKey, ReadyStudyItemReviewStats>>,
    pub(crate) now_unix_seconds: i64,
}

struct ReadyFilterContext<'a> {
    schedules: &'a HashMap<DatumKey, CardSchedule>,
    suspended: &'a HashSet<DatumKey>,
    flagged: &'a HashSet<DatumKey>,
    deferred: &'a HashSet<DatumKey>,
    non_reversible: &'a HashSet<DatumKey>,
    scheduled_study_due: Option<&'a HashSet<DatumKey>>,
    duplicate_prompts: &'a HashSet<DatumKey>,
    duplicate_responses: &'a HashSet<DatumKey>,
    problem_study_items: HashSet<DatumKey>,
    asymmetric_study_items: HashSet<DatumKey>,
    high_confidence_failure_study_items: HashSet<DatumKey>,
    low_confidence_success_study_items: HashSet<DatumKey>,
    now_unix_seconds: i64,
}

impl<'a> ReadyFilterContext<'a> {
    fn from_study_items(
        items: &[StudyItem],
        state: &'a ReadyStudyItemFilterState<'a>,
        duplicate_prompts: &'a HashSet<DatumKey>,
        duplicate_responses: &'a HashSet<DatumKey>,
    ) -> Self {
        let problem_study_items = state
            .review_stats
            .map(|review_stats| problem_study_items(items, review_stats))
            .unwrap_or_default();
        let asymmetric_study_items = state
            .review_stats
            .map(|review_stats| asymmetric_study_items(items, review_stats))
            .unwrap_or_default();
        let high_confidence_failure_study_items = state
            .review_stats
            .map(|review_stats| {
                diagnostic_study_items(items, review_stats, |stats| {
                    stats.recent_high_confidence_failure()
                })
            })
            .unwrap_or_default();
        let low_confidence_success_study_items = state
            .review_stats
            .map(|review_stats| {
                diagnostic_study_items(items, review_stats, |stats| {
                    stats.recent_low_confidence_success()
                })
            })
            .unwrap_or_default();
        Self {
            schedules: state.schedules,
            suspended: state.suspended,
            flagged: state.flagged,
            deferred: state.deferred,
            non_reversible: state.non_reversible,
            scheduled_study_due: state.scheduled_study_due,
            duplicate_prompts,
            duplicate_responses,
            problem_study_items,
            asymmetric_study_items,
            high_confidence_failure_study_items,
            low_confidence_success_study_items,
            now_unix_seconds: state.now_unix_seconds,
        }
    }
}

impl ReadyCardFilter {
    pub(crate) const ALL: [Self; 15] = [
        Self::All,
        Self::Due,
        Self::New,
        Self::NotDue,
        Self::Suspended,
        Self::Flagged,
        Self::Deferred,
        Self::NonReversible,
        Self::Reverse,
        Self::DuplicatePrompts,
        Self::DuplicateResponses,
        Self::ProblemCards,
        Self::AsymmetryIssues,
        Self::HighConfidenceFailures,
        Self::LowConfidenceSuccesses,
    ];

    pub(crate) const COUNT: usize = Self::ALL.len();

    pub(crate) fn from_code(code: &str) -> Option<Self> {
        match code {
            "all" => Some(Self::All),
            "due" => Some(Self::Due),
            "new" => Some(Self::New),
            "not-due" => Some(Self::NotDue),
            "suspended" => Some(Self::Suspended),
            "flagged" => Some(Self::Flagged),
            "deferred" => Some(Self::Deferred),
            "non-reversible" => Some(Self::NonReversible),
            "reverse" => Some(Self::Reverse),
            "duplicate-prompts" => Some(Self::DuplicatePrompts),
            "duplicate-responses" => Some(Self::DuplicateResponses),
            "problem-cards" => Some(Self::ProblemCards),
            "asymmetry-issues" => Some(Self::AsymmetryIssues),
            "high-confidence-failures" => Some(Self::HighConfidenceFailures),
            "low-confidence-successes" => Some(Self::LowConfidenceSuccesses),
            _ => None,
        }
    }

    pub(crate) fn code(self) -> &'static str {
        match self {
            Self::All => "all",
            Self::Due => "due",
            Self::New => "new",
            Self::NotDue => "not-due",
            Self::Suspended => "suspended",
            Self::Flagged => "flagged",
            Self::Deferred => "deferred",
            Self::NonReversible => "non-reversible",
            Self::Reverse => "reverse",
            Self::DuplicatePrompts => "duplicate-prompts",
            Self::DuplicateResponses => "duplicate-responses",
            Self::ProblemCards => "problem-cards",
            Self::AsymmetryIssues => "asymmetry-issues",
            Self::HighConfidenceFailures => "high-confidence-failures",
            Self::LowConfidenceSuccesses => "low-confidence-successes",
        }
    }

    pub(crate) fn from_selection(selection: Option<ReadyCardFilterSelection>) -> Self {
        selection
            .and_then(|selection| Self::ALL.get(selection.index_usize()).copied())
            .unwrap_or(Self::All)
    }

    pub(crate) fn selection_index(self) -> ReadyCardFilterSelection {
        let index = Self::ALL
            .iter()
            .position(|filter| *filter == self)
            .unwrap_or_default() as u32;
        ReadyCardFilterSelection::new(index).expect("ready-card filter must have a valid selection")
    }

    pub(crate) fn label(self, localizer: Localizer) -> &'static str {
        match self {
            Self::All => localizer.ready_card_filter_all(),
            Self::Due => localizer.ready_card_filter_due(),
            Self::New => localizer.ready_card_filter_new(),
            Self::NotDue => localizer.ready_card_filter_not_due(),
            Self::Suspended => localizer.ready_card_filter_suspended(),
            Self::Flagged => localizer.ready_card_filter_flagged(),
            Self::Deferred => localizer.ready_card_filter_deferred(),
            Self::NonReversible => localizer.ready_card_filter_non_reversible(),
            Self::Reverse => localizer.ready_card_filter_reverse(),
            Self::DuplicatePrompts => localizer.ready_card_filter_duplicate_prompts(),
            Self::DuplicateResponses => localizer.ready_card_filter_duplicate_responses(),
            Self::ProblemCards => localizer.ready_card_filter_problem_cards(),
            Self::AsymmetryIssues => localizer.ready_card_filter_asymmetry_issues(),
            Self::HighConfidenceFailures => localizer.ready_card_filter_high_confidence_failures(),
            Self::LowConfidenceSuccesses => localizer.ready_card_filter_low_confidence_successes(),
        }
    }

    pub(crate) fn requires_review_stats(self) -> bool {
        matches!(
            self,
            Self::ProblemCards
                | Self::AsymmetryIssues
                | Self::HighConfidenceFailures
                | Self::LowConfidenceSuccesses
        )
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub(crate) struct ReadyStudyItemReviewStats {
    attempts: usize,
    again_count: usize,
    strict_recall_count: usize,
    scheduler_underperformance_signal: f64,
    recent_attempts: usize,
    recent_again_count: usize,
    recent_high_confidence_failures: usize,
    recent_low_confidence_successes: usize,
    latest_confidence_rated_high_failure: bool,
}

impl ReadyStudyItemReviewStats {
    const MIN_PROBLEM_ATTEMPTS: usize = 4;
    const RECENT_ATTEMPTS: usize = 5;
    const RECENT_AGAIN_PROBLEM_COUNT: usize = 2;
    const PROBLEM_SCHEDULER_SIGNAL_ATTEMPTS: usize = 12;
    const PROBLEM_SCHEDULER_SIGNAL_DECAY: f64 = 0.72;
    const MIN_ASYMMETRY_ATTEMPTS: usize = 4;
    const ASYMMETRY_RECALL_GAP: f64 = 0.30;

    pub(crate) fn from_reviews(
        reviews: &[ReviewEvent],
        calibration_recall_rule: CalibrationRecallRule,
    ) -> Self {
        let attempts = reviews.len();
        let again_count = reviews
            .iter()
            .filter(|event| event.review().recall_feedback() == RecallFeedback::Again)
            .count();
        let strict_recall_count = reviews
            .iter()
            .filter(|event| {
                matches!(
                    event.review().recall_feedback(),
                    RecallFeedback::Good | RecallFeedback::Easy
                )
            })
            .count();
        let scheduler_underperformance_signal = recent_scheduler_underperformance_signal(
            reviews,
            Self::PROBLEM_SCHEDULER_SIGNAL_DECAY,
            Self::PROBLEM_SCHEDULER_SIGNAL_ATTEMPTS,
        );
        let recent_attempts = attempts.min(Self::RECENT_ATTEMPTS);
        let recent_again_count = reviews
            .iter()
            .rev()
            .take(Self::RECENT_ATTEMPTS)
            .filter(|event| event.review().recall_feedback() == RecallFeedback::Again)
            .count();
        let confidence_diagnostics =
            RecentConfidenceDiagnostics::from_reviews(reviews, |feedback| {
                calibration_recall_rule.is_recalled(feedback)
            });
        Self {
            attempts,
            again_count,
            strict_recall_count,
            scheduler_underperformance_signal,
            recent_attempts,
            recent_again_count,
            recent_high_confidence_failures: confidence_diagnostics.high_confidence_failures,
            recent_low_confidence_successes: confidence_diagnostics.low_confidence_successes,
            latest_confidence_rated_high_failure: confidence_diagnostics
                .latest_confidence_rated_high_failure,
        }
    }

    #[cfg(test)]
    pub(crate) fn from_counts(
        attempts: usize,
        again_count: usize,
        strict_recall_count: usize,
        recent_attempts: usize,
        recent_again_count: usize,
    ) -> Self {
        Self {
            attempts,
            again_count,
            strict_recall_count,
            scheduler_underperformance_signal: if attempts == 0 {
                0.0
            } else {
                again_count as f64 / attempts as f64
            },
            recent_attempts,
            recent_again_count,
            recent_high_confidence_failures: 0,
            recent_low_confidence_successes: 0,
            latest_confidence_rated_high_failure: false,
        }
    }

    #[cfg(test)]
    pub(crate) fn with_confidence_diagnostics(
        mut self,
        recent_high_confidence_failures: usize,
        latest_confidence_rated_high_failure: bool,
        recent_low_confidence_successes: usize,
    ) -> Self {
        self.recent_high_confidence_failures = recent_high_confidence_failures;
        self.recent_low_confidence_successes = recent_low_confidence_successes;
        self.latest_confidence_rated_high_failure = latest_confidence_rated_high_failure;
        self
    }

    fn is_problem_eligible(self) -> bool {
        self.attempts >= Self::MIN_PROBLEM_ATTEMPTS
    }

    fn scheduler_underperformance_signal(self) -> f64 {
        self.scheduler_underperformance_signal
    }

    fn strict_recall_rate_with_smoothing(self) -> f64 {
        (self.strict_recall_count as f64 + 1.0) / (self.attempts as f64 + 2.0)
    }

    fn recent_failure_problem(self) -> bool {
        self.recent_attempts >= Self::MIN_PROBLEM_ATTEMPTS
            && self.recent_again_count >= Self::RECENT_AGAIN_PROBLEM_COUNT
    }

    pub(crate) fn recent_high_confidence_failure(self) -> bool {
        RecentConfidenceDiagnostics {
            high_confidence_failures: self.recent_high_confidence_failures,
            low_confidence_successes: self.recent_low_confidence_successes,
            latest_confidence_rated_high_failure: self.latest_confidence_rated_high_failure,
        }
        .matches_high_confidence_failure_filter()
    }

    pub(crate) fn recent_low_confidence_success(self) -> bool {
        RecentConfidenceDiagnostics {
            high_confidence_failures: self.recent_high_confidence_failures,
            low_confidence_successes: self.recent_low_confidence_successes,
            latest_confidence_rated_high_failure: self.latest_confidence_rated_high_failure,
        }
        .matches_low_confidence_success_filter()
    }
}

pub(crate) fn ready_filter_choice_label(
    filter: ReadyCardFilter,
    localizer: Localizer,
    count: usize,
) -> String {
    format!("{} ({count})", filter.label(localizer))
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ReadyListUpdate {
    NoChange,
    Append(String),
    Delete(ReadyListRow),
    Rebuild(Vec<String>),
}

fn ready_card_label(datum: &Datum) -> String {
    let first_line = datum
        .prompt()
        .lines()
        .find(|line| !line.trim().is_empty())
        .unwrap_or(datum.prompt())
        .trim();
    if datum.prompt().lines().nth(1).is_some() {
        format!("{first_line} ...")
    } else {
        first_line.to_string()
    }
}

#[cfg(test)]
fn ready_card_label_with_suspension(
    datum: &Datum,
    suspended: &HashSet<DatumKey>,
    flagged: &HashSet<DatumKey>,
    deferred: &HashSet<DatumKey>,
    non_reversible: &HashSet<DatumKey>,
    localizer: Localizer,
) -> String {
    let mut label = ready_card_label(datum);
    let datum_key = DatumKey::for_datum(datum);
    if suspended.contains(&datum_key) {
        label.push(' ');
        label.push_str(localizer.ready_card_suspended_suffix());
    }
    if flagged.contains(&datum_key) {
        label.push(' ');
        label.push_str(localizer.ready_card_flagged_suffix());
    }
    if deferred.contains(&datum_key) {
        label.push(' ');
        label.push_str(localizer.ready_card_deferred_suffix());
    }
    if non_reversible.contains(&datum_key) {
        label.push(' ');
        label.push_str(localizer.ready_card_non_reversible_suffix());
    }
    label
}

#[cfg(test)]
pub(crate) fn ready_card_labels(data: &[Datum]) -> Vec<String> {
    data.iter().map(ready_card_label).collect()
}

#[cfg(test)]
pub(crate) fn ready_card_labels_for_indices(
    data: &[Datum],
    indices: &[usize],
    suspended: &HashSet<DatumKey>,
    flagged: &HashSet<DatumKey>,
    non_reversible: &HashSet<DatumKey>,
    localizer: Localizer,
) -> Vec<String> {
    let deferred = HashSet::new();
    indices
        .iter()
        .filter_map(|index| data.get(*index))
        .map(|datum| {
            ready_card_label_with_suspension(
                datum,
                suspended,
                flagged,
                &deferred,
                non_reversible,
                localizer,
            )
        })
        .collect()
}

pub(crate) fn ready_study_item_labels_for_indices(
    items: &[StudyItem],
    indices: &[usize],
    suspended: &HashSet<DatumKey>,
    flagged: &HashSet<DatumKey>,
    deferred: &HashSet<DatumKey>,
    non_reversible: &HashSet<DatumKey>,
    localizer: Localizer,
) -> Vec<String> {
    indices
        .iter()
        .filter_map(|index| items.get(*index))
        .map(|item| {
            ready_study_item_label(
                item,
                suspended,
                flagged,
                deferred,
                non_reversible,
                localizer,
            )
        })
        .collect()
}

fn ready_study_item_label(
    item: &StudyItem,
    suspended: &HashSet<DatumKey>,
    flagged: &HashSet<DatumKey>,
    deferred: &HashSet<DatumKey>,
    non_reversible: &HashSet<DatumKey>,
    localizer: Localizer,
) -> String {
    let mut label = ready_card_label(item.datum());
    if item.is_reverse() {
        label.push(' ');
        label.push_str(localizer.ready_card_reverse_suffix());
    }
    if suspended.contains(item.datum_key()) {
        label.push(' ');
        label.push_str(localizer.ready_card_suspended_suffix());
    }
    if flagged.contains(item.datum_key()) {
        label.push(' ');
        label.push_str(localizer.ready_card_flagged_suffix());
    }
    if deferred.contains(item.datum_key()) {
        label.push(' ');
        label.push_str(localizer.ready_card_deferred_suffix());
    }
    if non_reversible.contains(item.datum_key()) {
        label.push(' ');
        label.push_str(localizer.ready_card_non_reversible_suffix());
    }
    label
}

#[cfg(test)]
#[expect(
    clippy::too_many_arguments,
    reason = "test helper keeps existing ready-list dimensions explicit"
)]
pub(crate) fn ready_card_indices_for_filter(
    data: &[Datum],
    schedules: &HashMap<DatumKey, CardSchedule>,
    suspended: &HashSet<DatumKey>,
    flagged: &HashSet<DatumKey>,
    non_reversible: &HashSet<DatumKey>,
    filter: ReadyCardFilter,
    text_filter: &str,
    sort: ReadyCardSort,
    sort_direction: ReadyCardSortDirection,
    now_unix_seconds: i64,
) -> Vec<usize> {
    let deferred = HashSet::new();
    let duplicate_prompts = duplicate_card_fields(data, DuplicateField::Prompt);
    let duplicate_responses = duplicate_card_fields(data, DuplicateField::Response);
    let context = ReadyFilterContext {
        schedules,
        suspended,
        flagged,
        deferred: &deferred,
        non_reversible,
        scheduled_study_due: None,
        duplicate_prompts: &duplicate_prompts,
        duplicate_responses: &duplicate_responses,
        problem_study_items: HashSet::new(),
        asymmetric_study_items: HashSet::new(),
        high_confidence_failure_study_items: HashSet::new(),
        low_confidence_success_study_items: HashSet::new(),
        now_unix_seconds,
    };
    let mut indices: Vec<usize> = data
        .iter()
        .enumerate()
        .filter_map(|(index, datum)| {
            if ready_card_matches_text_filter(datum, text_filter)
                && ready_card_matches_filter(datum, filter, &context)
            {
                Some(index)
            } else {
                None
            }
        })
        .collect();
    sort_ready_card_indices(
        &mut indices,
        data,
        schedules,
        sort,
        sort_direction,
        now_unix_seconds,
    );
    indices
}

pub(crate) fn ready_study_item_indices_for_filter(
    items: &[StudyItem],
    filter: ReadyCardFilter,
    text_filter: &str,
    sort: ReadyCardSort,
    sort_direction: ReadyCardSortDirection,
    state: &ReadyStudyItemFilterState,
) -> Vec<usize> {
    let duplicate_prompts = duplicate_study_item_fields(items, DuplicateField::Prompt);
    let duplicate_responses = duplicate_study_item_fields(items, DuplicateField::Response);
    let context = ReadyFilterContext::from_study_items(
        items,
        state,
        &duplicate_prompts,
        &duplicate_responses,
    );
    let mut indices: Vec<usize> = items
        .iter()
        .enumerate()
        .filter_map(|(index, item)| {
            if ready_card_matches_text_filter(item.datum(), text_filter)
                && ready_study_item_matches_filter(item, filter, &context)
            {
                Some(index)
            } else {
                None
            }
        })
        .collect();
    sort_ready_study_item_indices(
        &mut indices,
        items,
        state.schedules,
        sort,
        sort_direction,
        state.now_unix_seconds,
    );
    indices
}

#[cfg(test)]
fn sort_ready_card_indices(
    indices: &mut [usize],
    data: &[Datum],
    schedules: &HashMap<DatumKey, CardSchedule>,
    sort: ReadyCardSort,
    sort_direction: ReadyCardSortDirection,
    now_unix_seconds: i64,
) {
    match (sort, sort_direction) {
        (ReadyCardSort::DeckOrder, ReadyCardSortDirection::Ascending) => {}
        (ReadyCardSort::DeckOrder, ReadyCardSortDirection::Descending) => indices.reverse(),
        (ReadyCardSort::NextScheduledReview, ReadyCardSortDirection::Ascending) => indices
            .sort_by_key(|index| {
                let datum = &data[*index];
                let datum_key = DatumKey::for_datum(datum);
                let scheduled = schedule_datum(datum, schedules.get(&datum_key), now_unix_seconds);
                (scheduled.due_at_unix_seconds(), *index)
            }),
        (ReadyCardSort::NextScheduledReview, ReadyCardSortDirection::Descending) => indices
            .sort_by_key(|index| {
                let datum = &data[*index];
                let datum_key = DatumKey::for_datum(datum);
                let scheduled = schedule_datum(datum, schedules.get(&datum_key), now_unix_seconds);
                (Reverse(scheduled.due_at_unix_seconds()), *index)
            }),
        (ReadyCardSort::LastReviewed, ReadyCardSortDirection::Ascending) => {
            indices.sort_by_key(|index| {
                let datum = &data[*index];
                last_reviewed_ascending_sort_key(*index, schedules.get(&DatumKey::for_datum(datum)))
            })
        }
        (ReadyCardSort::LastReviewed, ReadyCardSortDirection::Descending) => {
            indices.sort_by_key(|index| {
                let datum = &data[*index];
                last_reviewed_descending_sort_key(
                    *index,
                    schedules.get(&DatumKey::for_datum(datum)),
                )
            })
        }
        (ReadyCardSort::PromptText, ReadyCardSortDirection::Ascending) => {
            indices.sort_by_key(|index| (data[*index].prompt().to_lowercase(), *index))
        }
        (ReadyCardSort::PromptText, ReadyCardSortDirection::Descending) => {
            indices.sort_by(|left, right| {
                data[*right]
                    .prompt()
                    .to_lowercase()
                    .cmp(&data[*left].prompt().to_lowercase())
                    .then_with(|| left.cmp(right))
            })
        }
        (ReadyCardSort::ResponseText, ReadyCardSortDirection::Ascending) => {
            indices.sort_by_key(|index| (data[*index].response().to_lowercase(), *index))
        }
        (ReadyCardSort::ResponseText, ReadyCardSortDirection::Descending) => {
            indices.sort_by(|left, right| {
                data[*right]
                    .response()
                    .to_lowercase()
                    .cmp(&data[*left].response().to_lowercase())
                    .then_with(|| left.cmp(right))
            })
        }
        (ReadyCardSort::EstimatedRecall, direction) => indices.sort_by(|left, right| {
            compare_estimated_recall(
                schedules.get(&DatumKey::for_datum(&data[*left])),
                *left,
                schedules.get(&DatumKey::for_datum(&data[*right])),
                *right,
                direction,
                now_unix_seconds,
            )
        }),
    }
}

fn sort_ready_study_item_indices(
    indices: &mut [usize],
    items: &[StudyItem],
    schedules: &HashMap<DatumKey, CardSchedule>,
    sort: ReadyCardSort,
    sort_direction: ReadyCardSortDirection,
    now_unix_seconds: i64,
) {
    match (sort, sort_direction) {
        (ReadyCardSort::DeckOrder, ReadyCardSortDirection::Ascending) => {}
        (ReadyCardSort::DeckOrder, ReadyCardSortDirection::Descending) => indices.reverse(),
        (ReadyCardSort::NextScheduledReview, ReadyCardSortDirection::Ascending) => indices
            .sort_by_key(|index| {
                let item = &items[*index];
                let scheduled = schedule_datum_for_key(
                    item.datum_key().clone(),
                    schedules.get(item.datum_key()),
                    now_unix_seconds,
                );
                (scheduled.due_at_unix_seconds(), *index)
            }),
        (ReadyCardSort::NextScheduledReview, ReadyCardSortDirection::Descending) => indices
            .sort_by_key(|index| {
                let item = &items[*index];
                let scheduled = schedule_datum_for_key(
                    item.datum_key().clone(),
                    schedules.get(item.datum_key()),
                    now_unix_seconds,
                );
                (Reverse(scheduled.due_at_unix_seconds()), *index)
            }),
        (ReadyCardSort::LastReviewed, ReadyCardSortDirection::Ascending) => {
            indices.sort_by_key(|index| {
                let item = &items[*index];
                last_reviewed_ascending_sort_key(*index, schedules.get(item.datum_key()))
            })
        }
        (ReadyCardSort::LastReviewed, ReadyCardSortDirection::Descending) => {
            indices.sort_by_key(|index| {
                let item = &items[*index];
                last_reviewed_descending_sort_key(*index, schedules.get(item.datum_key()))
            })
        }
        (ReadyCardSort::PromptText, ReadyCardSortDirection::Ascending) => {
            indices.sort_by_key(|index| (items[*index].datum().prompt().to_lowercase(), *index))
        }
        (ReadyCardSort::PromptText, ReadyCardSortDirection::Descending) => {
            indices.sort_by(|left, right| {
                items[*right]
                    .datum()
                    .prompt()
                    .to_lowercase()
                    .cmp(&items[*left].datum().prompt().to_lowercase())
                    .then_with(|| left.cmp(right))
            })
        }
        (ReadyCardSort::ResponseText, ReadyCardSortDirection::Ascending) => {
            indices.sort_by_key(|index| (items[*index].datum().response().to_lowercase(), *index))
        }
        (ReadyCardSort::ResponseText, ReadyCardSortDirection::Descending) => {
            indices.sort_by(|left, right| {
                items[*right]
                    .datum()
                    .response()
                    .to_lowercase()
                    .cmp(&items[*left].datum().response().to_lowercase())
                    .then_with(|| left.cmp(right))
            })
        }
        (ReadyCardSort::EstimatedRecall, direction) => indices.sort_by(|left, right| {
            compare_estimated_recall(
                schedules.get(items[*left].datum_key()),
                *left,
                schedules.get(items[*right].datum_key()),
                *right,
                direction,
                now_unix_seconds,
            )
        }),
    }
}

fn last_reviewed_ascending_sort_key(
    index: usize,
    schedule: Option<&CardSchedule>,
) -> (bool, i64, usize) {
    let last_reviewed = schedule.and_then(CardSchedule::last_reviewed_at_unix_seconds);
    (last_reviewed.is_none(), last_reviewed.unwrap_or(0), index)
}

fn last_reviewed_descending_sort_key(
    index: usize,
    schedule: Option<&CardSchedule>,
) -> (bool, Reverse<i64>, usize) {
    let last_reviewed = schedule.and_then(CardSchedule::last_reviewed_at_unix_seconds);
    (
        last_reviewed.is_none(),
        Reverse(last_reviewed.unwrap_or(0)),
        index,
    )
}

fn compare_estimated_recall(
    left_schedule: Option<&CardSchedule>,
    left_index: usize,
    right_schedule: Option<&CardSchedule>,
    right_index: usize,
    direction: ReadyCardSortDirection,
    now_unix_seconds: i64,
) -> Ordering {
    let left_recall =
        left_schedule.and_then(|schedule| estimated_recall_now(schedule, now_unix_seconds));
    let right_recall =
        right_schedule.and_then(|schedule| estimated_recall_now(schedule, now_unix_seconds));
    match (left_recall, right_recall) {
        (Some(left), Some(right)) => match direction {
            ReadyCardSortDirection::Ascending => left
                .total_cmp(&right)
                .then_with(|| left_index.cmp(&right_index)),
            ReadyCardSortDirection::Descending => right
                .total_cmp(&left)
                .then_with(|| left_index.cmp(&right_index)),
        },
        (Some(_), None) => Ordering::Less,
        (None, Some(_)) => Ordering::Greater,
        (None, None) => left_index.cmp(&right_index),
    }
}

#[cfg(test)]
pub(crate) fn ready_card_filter_counts(
    data: &[Datum],
    schedules: &HashMap<DatumKey, CardSchedule>,
    suspended: &HashSet<DatumKey>,
    flagged: &HashSet<DatumKey>,
    non_reversible: &HashSet<DatumKey>,
    text_filter: &str,
    now_unix_seconds: i64,
) -> [usize; ReadyCardFilter::COUNT] {
    let deferred = HashSet::new();
    let duplicate_prompts = duplicate_card_fields(data, DuplicateField::Prompt);
    let duplicate_responses = duplicate_card_fields(data, DuplicateField::Response);
    let context = ReadyFilterContext {
        schedules,
        suspended,
        flagged,
        deferred: &deferred,
        non_reversible,
        scheduled_study_due: None,
        duplicate_prompts: &duplicate_prompts,
        duplicate_responses: &duplicate_responses,
        problem_study_items: HashSet::new(),
        asymmetric_study_items: HashSet::new(),
        high_confidence_failure_study_items: HashSet::new(),
        low_confidence_success_study_items: HashSet::new(),
        now_unix_seconds,
    };
    let mut counts = [0; ReadyCardFilter::COUNT];
    for datum in data {
        if !ready_card_matches_text_filter(datum, text_filter) {
            continue;
        }
        for (index, filter) in ReadyCardFilter::ALL.iter().copied().enumerate() {
            if ready_card_matches_filter(datum, filter, &context) {
                counts[index] += 1;
            }
        }
    }
    counts
}

pub(crate) fn ready_study_item_filter_counts(
    items: &[StudyItem],
    text_filter: &str,
    state: &ReadyStudyItemFilterState,
) -> [usize; ReadyCardFilter::COUNT] {
    let duplicate_prompts = duplicate_study_item_fields(items, DuplicateField::Prompt);
    let duplicate_responses = duplicate_study_item_fields(items, DuplicateField::Response);
    let context = ReadyFilterContext::from_study_items(
        items,
        state,
        &duplicate_prompts,
        &duplicate_responses,
    );
    let mut counts = [0; ReadyCardFilter::COUNT];
    for item in items {
        if !ready_card_matches_text_filter(item.datum(), text_filter) {
            continue;
        }
        for (index, filter) in ReadyCardFilter::ALL.iter().copied().enumerate() {
            if ready_study_item_matches_filter(item, filter, &context) {
                counts[index] += 1;
            }
        }
    }
    counts
}

#[cfg(test)]
fn ready_card_matches_filter(
    datum: &Datum,
    filter: ReadyCardFilter,
    context: &ReadyFilterContext,
) -> bool {
    let datum_key = DatumKey::for_datum(datum);
    let is_suspended = context.suspended.contains(&datum_key);
    let is_flagged = context.flagged.contains(&datum_key);
    let is_deferred = context.deferred.contains(&datum_key);
    let is_non_reversible = context.non_reversible.contains(&datum_key);
    match filter {
        ReadyCardFilter::All => true,
        ReadyCardFilter::Suspended => is_suspended,
        ReadyCardFilter::Flagged => is_flagged,
        ReadyCardFilter::Deferred => is_deferred,
        ReadyCardFilter::NonReversible => is_non_reversible,
        ReadyCardFilter::Reverse => false,
        ReadyCardFilter::DuplicatePrompts => context.duplicate_prompts.contains(&datum_key),
        ReadyCardFilter::DuplicateResponses => context.duplicate_responses.contains(&datum_key),
        ReadyCardFilter::ProblemCards => context.problem_study_items.contains(&datum_key),
        ReadyCardFilter::AsymmetryIssues => context.asymmetric_study_items.contains(&datum_key),
        ReadyCardFilter::HighConfidenceFailures => context
            .high_confidence_failure_study_items
            .contains(&datum_key),
        ReadyCardFilter::LowConfidenceSuccesses => context
            .low_confidence_success_study_items
            .contains(&datum_key),
        ReadyCardFilter::Due | ReadyCardFilter::New | ReadyCardFilter::NotDue
            if is_suspended || is_deferred =>
        {
            false
        }
        ReadyCardFilter::New => match context.schedules.get(&DatumKey::for_datum(datum)) {
            Some(schedule) => schedule.memory_state() == CardMemoryState::New,
            None => true,
        },
        ReadyCardFilter::Due => context.scheduled_study_due.map_or_else(
            || {
                schedule_datum(
                    datum,
                    context.schedules.get(&datum_key),
                    context.now_unix_seconds,
                )
                .is_due()
            },
            |due_keys| due_keys.contains(&datum_key),
        ),
        ReadyCardFilter::NotDue => {
            let Some(schedule) = context.schedules.get(&datum_key) else {
                return false;
            };
            !schedule_datum(datum, Some(schedule), context.now_unix_seconds).is_due()
        }
    }
}

fn ready_study_item_matches_filter(
    item: &StudyItem,
    filter: ReadyCardFilter,
    context: &ReadyFilterContext,
) -> bool {
    let is_suspended = context.suspended.contains(item.datum_key());
    let is_flagged = context.flagged.contains(item.datum_key());
    let is_deferred = context.deferred.contains(item.datum_key());
    let is_non_reversible = context.non_reversible.contains(item.datum_key());
    match filter {
        ReadyCardFilter::All => true,
        ReadyCardFilter::Suspended => is_suspended,
        ReadyCardFilter::Flagged => is_flagged,
        ReadyCardFilter::Deferred => is_deferred,
        ReadyCardFilter::NonReversible => is_non_reversible,
        ReadyCardFilter::Reverse => item.is_reverse(),
        ReadyCardFilter::DuplicatePrompts => context.duplicate_prompts.contains(item.datum_key()),
        ReadyCardFilter::DuplicateResponses => {
            context.duplicate_responses.contains(item.datum_key())
        }
        ReadyCardFilter::ProblemCards
        | ReadyCardFilter::AsymmetryIssues
        | ReadyCardFilter::HighConfidenceFailures
        | ReadyCardFilter::LowConfidenceSuccesses
            if is_suspended || is_deferred =>
        {
            false
        }
        ReadyCardFilter::ProblemCards => context.problem_study_items.contains(item.datum_key()),
        ReadyCardFilter::AsymmetryIssues => {
            context.asymmetric_study_items.contains(item.datum_key())
        }
        ReadyCardFilter::HighConfidenceFailures => context
            .high_confidence_failure_study_items
            .contains(item.datum_key()),
        ReadyCardFilter::LowConfidenceSuccesses => context
            .low_confidence_success_study_items
            .contains(item.datum_key()),
        ReadyCardFilter::Due | ReadyCardFilter::New | ReadyCardFilter::NotDue
            if is_suspended || is_deferred =>
        {
            false
        }
        ReadyCardFilter::New => match context.schedules.get(item.datum_key()) {
            Some(schedule) => schedule.memory_state() == CardMemoryState::New,
            None => true,
        },
        ReadyCardFilter::Due => context.scheduled_study_due.map_or_else(
            || {
                schedule_datum_for_key(
                    item.datum_key().clone(),
                    context.schedules.get(item.datum_key()),
                    context.now_unix_seconds,
                )
                .is_due()
            },
            |due_keys| due_keys.contains(item.datum_key()),
        ),
        ReadyCardFilter::NotDue => {
            let Some(schedule) = context.schedules.get(item.datum_key()) else {
                return false;
            };
            !schedule_datum_for_key(
                item.datum_key().clone(),
                Some(schedule),
                context.now_unix_seconds,
            )
            .is_due()
        }
    }
}

fn problem_study_items(
    items: &[StudyItem],
    review_stats: &HashMap<DatumKey, ReadyStudyItemReviewStats>,
) -> HashSet<DatumKey> {
    let candidates: Vec<_> = items
        .iter()
        .filter_map(|item| {
            let stats = review_stats.get(item.datum_key()).copied()?;
            stats
                .is_problem_eligible()
                .then_some((item.datum_key().clone(), stats))
        })
        .collect();
    let underperformance_threshold = problem_threshold(
        candidates
            .iter()
            .map(|(_, stats)| stats.scheduler_underperformance_signal())
            .collect(),
        0.25,
    );

    candidates
        .into_iter()
        .filter_map(|(datum_key, stats)| {
            (stats.recent_failure_problem()
                || stats.scheduler_underperformance_signal() >= underperformance_threshold)
                .then_some(datum_key)
        })
        .collect()
}

fn asymmetric_study_items(
    items: &[StudyItem],
    review_stats: &HashMap<DatumKey, ReadyStudyItemReviewStats>,
) -> HashSet<DatumKey> {
    let mut pairs: HashMap<DatumKey, DirectionPair> = HashMap::new();
    for item in items {
        let pair = pairs.entry(item.parent_datum_key()).or_default();
        if item.is_reverse() {
            pair.reverse = Some(item.datum_key().clone());
        } else {
            pair.forward = Some(item.datum_key().clone());
        }
    }

    let mut asymmetric = HashSet::new();
    for pair in pairs.values() {
        let (Some(forward_key), Some(reverse_key)) = (&pair.forward, &pair.reverse) else {
            continue;
        };
        let Some(forward_stats) = review_stats.get(forward_key).copied() else {
            continue;
        };
        let Some(reverse_stats) = review_stats.get(reverse_key).copied() else {
            continue;
        };
        if forward_stats.attempts < ReadyStudyItemReviewStats::MIN_ASYMMETRY_ATTEMPTS
            || reverse_stats.attempts < ReadyStudyItemReviewStats::MIN_ASYMMETRY_ATTEMPTS
        {
            continue;
        }

        let forward_recall = forward_stats.strict_recall_rate_with_smoothing();
        let reverse_recall = reverse_stats.strict_recall_rate_with_smoothing();
        if (forward_recall - reverse_recall).abs() < ReadyStudyItemReviewStats::ASYMMETRY_RECALL_GAP
        {
            continue;
        }
        if forward_recall < reverse_recall {
            asymmetric.insert(forward_key.clone());
        } else {
            asymmetric.insert(reverse_key.clone());
        }
    }
    asymmetric
}

fn diagnostic_study_items(
    items: &[StudyItem],
    review_stats: &HashMap<DatumKey, ReadyStudyItemReviewStats>,
    matches_diagnostic: impl Fn(ReadyStudyItemReviewStats) -> bool,
) -> HashSet<DatumKey> {
    items
        .iter()
        .filter_map(|item| {
            let stats = review_stats.get(item.datum_key()).copied()?;
            matches_diagnostic(stats).then(|| item.datum_key().clone())
        })
        .collect()
}

#[derive(Default)]
struct DirectionPair {
    forward: Option<DatumKey>,
    reverse: Option<DatumKey>,
}

fn problem_threshold(mut values: Vec<f64>, absolute_minimum: f64) -> f64 {
    if values.is_empty() {
        return absolute_minimum;
    }
    values.sort_by(f64::total_cmp);
    let index = ((values.len() as f64 * 0.80).ceil() as usize)
        .saturating_sub(1)
        .min(values.len() - 1);
    values[index].max(absolute_minimum)
}

#[derive(Debug, Clone, Copy)]
enum DuplicateField {
    Prompt,
    Response,
}

#[cfg(test)]
fn duplicate_card_fields(data: &[Datum], field: DuplicateField) -> HashSet<DatumKey> {
    let mut counts = HashMap::new();
    for datum in data {
        *counts
            .entry(duplicate_field_text(datum, field))
            .or_insert(0usize) += 1;
    }

    data.iter()
        .filter(|datum| {
            counts
                .get(&duplicate_field_text(datum, field))
                .copied()
                .unwrap_or(0)
                > 1
        })
        .map(DatumKey::for_datum)
        .collect()
}

fn duplicate_study_item_fields(items: &[StudyItem], field: DuplicateField) -> HashSet<DatumKey> {
    let mut counts = HashMap::new();
    for item in items {
        *counts
            .entry(duplicate_field_text(item.datum(), field))
            .or_insert(0usize) += 1;
    }

    items
        .iter()
        .filter(|item| {
            counts
                .get(&duplicate_field_text(item.datum(), field))
                .copied()
                .unwrap_or(0)
                > 1
        })
        .map(|item| item.datum_key().clone())
        .collect()
}

fn duplicate_field_text(datum: &Datum, field: DuplicateField) -> String {
    let value = match field {
        DuplicateField::Prompt => datum.prompt(),
        DuplicateField::Response => datum.response(),
    };
    value
        .replace("\r\n", "\n")
        .replace('\r', "\n")
        .trim()
        .to_string()
}

pub(crate) fn ready_card_matches_text_filter(datum: &Datum, text_filter: &str) -> bool {
    let text_filter = text_filter.trim();
    if text_filter.is_empty() {
        return true;
    }

    let query = text_filter.to_lowercase();
    datum.prompt().to_lowercase().contains(&query)
        || datum.response().to_lowercase().contains(&query)
}

pub(crate) fn normalize_ready_selection(
    selected_index: Option<usize>,
    visible_indices: &[usize],
) -> Option<usize> {
    selected_index
        .filter(|selected_index| visible_indices.contains(selected_index))
        .or_else(|| visible_indices.first().copied())
}

pub(crate) fn ready_list_update(current: &[String], expected: &[String]) -> ReadyListUpdate {
    if current == expected {
        return ReadyListUpdate::NoChange;
    }

    if expected.len() == current.len() + 1
        && current
            .iter()
            .zip(expected.iter())
            .all(|(current_label, expected_label)| current_label == expected_label)
    {
        return ReadyListUpdate::Append(
            expected
                .last()
                .expect("expected has one more item than current")
                .clone(),
        );
    }

    if current.len() == expected.len() + 1 {
        for index in 0..current.len() {
            if current[..index] == expected[..index] && current[index + 1..] == expected[index..] {
                return ReadyListUpdate::Delete(ReadyListRow::new(index));
            }
        }
    }

    ReadyListUpdate::Rebuild(expected.to_vec())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::study_item::study_items_for_deck;
    use proptest::collection::hash_set;
    use proptest::prelude::*;

    #[derive(Clone, Debug)]
    struct GeneratedCard {
        prompt: u8,
        response: u8,
        reversible: bool,
        reversal_prohibited: bool,
        forward_state: GeneratedItemState,
        reverse_state: GeneratedItemState,
    }

    #[derive(Clone, Copy, Debug)]
    struct GeneratedItemState {
        suspended: bool,
        flagged: bool,
        deferred: bool,
        non_reversible: bool,
        schedule: GeneratedSchedule,
        problem: bool,
        high_confidence_failure: bool,
        low_confidence_success: bool,
    }

    #[derive(Clone, Copy, Debug)]
    enum GeneratedSchedule {
        Missing,
        New,
        DueReview,
        FutureReview,
    }

    #[derive(Debug)]
    struct GeneratedReadyList {
        items: Vec<StudyItem>,
        schedules: HashMap<DatumKey, CardSchedule>,
        suspended: HashSet<DatumKey>,
        flagged: HashSet<DatumKey>,
        deferred: HashSet<DatumKey>,
        non_reversible: HashSet<DatumKey>,
        review_stats: HashMap<DatumKey, ReadyStudyItemReviewStats>,
        now_unix_seconds: i64,
    }

    impl GeneratedReadyList {
        fn filter_state(&self) -> ReadyStudyItemFilterState<'_> {
            ReadyStudyItemFilterState {
                schedules: &self.schedules,
                suspended: &self.suspended,
                flagged: &self.flagged,
                deferred: &self.deferred,
                non_reversible: &self.non_reversible,
                scheduled_study_due: None,
                review_stats: Some(&self.review_stats),
                now_unix_seconds: self.now_unix_seconds,
            }
        }
    }

    fn generated_item_state() -> impl Strategy<Value = GeneratedItemState> {
        (
            any::<bool>(),
            any::<bool>(),
            any::<bool>(),
            any::<bool>(),
            prop_oneof![
                Just(GeneratedSchedule::Missing),
                Just(GeneratedSchedule::New),
                Just(GeneratedSchedule::DueReview),
                Just(GeneratedSchedule::FutureReview),
            ],
            any::<bool>(),
            any::<bool>(),
            any::<bool>(),
        )
            .prop_map(
                |(
                    suspended,
                    flagged,
                    deferred,
                    non_reversible,
                    schedule,
                    problem,
                    high_confidence_failure,
                    low_confidence_success,
                )| GeneratedItemState {
                    suspended,
                    flagged,
                    deferred,
                    non_reversible,
                    schedule,
                    problem,
                    high_confidence_failure,
                    low_confidence_success,
                },
            )
    }

    fn generated_card() -> impl Strategy<Value = GeneratedCard> {
        (
            0u8..8,
            0u8..8,
            any::<bool>(),
            any::<bool>(),
            generated_item_state(),
            generated_item_state(),
        )
            .prop_map(
                |(
                    prompt,
                    response,
                    reversible,
                    reversal_prohibited,
                    forward_state,
                    reverse_state,
                )| GeneratedCard {
                    prompt,
                    response,
                    reversible,
                    reversal_prohibited,
                    forward_state,
                    reverse_state,
                },
            )
    }

    fn generated_ready_list() -> impl Strategy<Value = GeneratedReadyList> {
        prop::collection::vec(generated_card(), 0..24).prop_map(|cards| {
            let now_unix_seconds = 10_000;
            let data: Vec<_> = cards
                .iter()
                .enumerate()
                .map(|(index, card)| {
                    Datum::new(
                        format!("prompt {}", card.prompt),
                        format!("response {}", card.response),
                    )
                    .unwrap()
                    .with_card_id(index as u64 + 1)
                    .with_reversal_prohibited(card.reversal_prohibited)
                })
                .collect();
            let reversible_keys: HashSet<_> = data
                .iter()
                .zip(cards.iter())
                .filter(|(_, card)| card.reversible)
                .map(|(datum, _)| DatumKey::for_datum(datum))
                .collect();
            let items = study_items_for_deck(&data, &reversible_keys);
            let mut generated = GeneratedReadyList {
                items,
                schedules: HashMap::new(),
                suspended: HashSet::new(),
                flagged: HashSet::new(),
                deferred: HashSet::new(),
                non_reversible: HashSet::new(),
                review_stats: HashMap::new(),
                now_unix_seconds,
            };

            for item in &generated.items {
                let card = &cards[item.deck_index()];
                let item_state = if item.is_reverse() {
                    card.reverse_state
                } else {
                    card.forward_state
                };
                let key = item.datum_key().clone();
                if item_state.suspended {
                    generated.suspended.insert(key.clone());
                }
                if item_state.flagged {
                    generated.flagged.insert(key.clone());
                }
                if item_state.deferred {
                    generated.deferred.insert(key.clone());
                }
                if item_state.non_reversible {
                    generated.non_reversible.insert(key.clone());
                }
                if let Some(schedule) =
                    generated_schedule(key.clone(), item_state.schedule, now_unix_seconds)
                {
                    generated.schedules.insert(key.clone(), schedule);
                }
                generated.review_stats.insert(
                    key,
                    generated_review_stats(
                        item_state.problem,
                        item_state.high_confidence_failure,
                        item_state.low_confidence_success,
                    ),
                );
            }

            generated
        })
    }

    fn generated_schedule(
        key: DatumKey,
        schedule: GeneratedSchedule,
        now_unix_seconds: i64,
    ) -> Option<CardSchedule> {
        match schedule {
            GeneratedSchedule::Missing => None,
            GeneratedSchedule::New => Some(CardSchedule::new(key)),
            GeneratedSchedule::DueReview | GeneratedSchedule::FutureReview => {
                let due_at_unix_seconds = match schedule {
                    GeneratedSchedule::DueReview => now_unix_seconds - 60,
                    GeneratedSchedule::FutureReview => now_unix_seconds + 60,
                    GeneratedSchedule::Missing | GeneratedSchedule::New => unreachable!(),
                };
                Some(CardSchedule::from_storage(
                    key,
                    CardMemoryState::Review,
                    due_at_unix_seconds,
                    1.0,
                    5.0,
                    1,
                    1,
                    86_400,
                    1,
                    0,
                    Some(now_unix_seconds - 86_400),
                    Some(RecallFeedback::Good),
                ))
            }
        }
    }

    fn generated_review_stats(
        problem: bool,
        high_confidence_failure: bool,
        low_confidence_success: bool,
    ) -> ReadyStudyItemReviewStats {
        let stats = if problem {
            ReadyStudyItemReviewStats::from_counts(4, 2, 1, 4, 2)
        } else {
            ReadyStudyItemReviewStats::from_counts(2, 0, 2, 2, 0)
        };
        stats.with_confidence_diagnostics(
            usize::from(high_confidence_failure),
            high_confidence_failure,
            usize::from(low_confidence_success),
        )
    }

    fn generated_text_filter() -> impl Strategy<Value = String> {
        prop_oneof![
            Just(String::new()),
            Just("prompt".to_string()),
            Just("response".to_string()),
            (0u8..8).prop_map(|index| format!("prompt {index}")),
            (0u8..8).prop_map(|index| format!("response {index}")),
            Just("absent".to_string()),
        ]
    }

    fn generated_sort() -> impl Strategy<Value = ReadyCardSort> {
        prop_oneof![
            Just(ReadyCardSort::DeckOrder),
            Just(ReadyCardSort::NextScheduledReview),
            Just(ReadyCardSort::LastReviewed),
            Just(ReadyCardSort::PromptText),
            Just(ReadyCardSort::ResponseText),
            Just(ReadyCardSort::EstimatedRecall),
        ]
    }

    fn generated_sort_direction() -> impl Strategy<Value = ReadyCardSortDirection> {
        prop_oneof![
            Just(ReadyCardSortDirection::Ascending),
            Just(ReadyCardSortDirection::Descending),
        ]
    }

    fn index_set(indices: &[usize]) -> HashSet<usize> {
        indices.iter().copied().collect()
    }

    fn apply_ready_list_update(current: &[String], update: ReadyListUpdate) -> Option<Vec<String>> {
        let mut updated = current.to_vec();
        match update {
            ReadyListUpdate::NoChange => {}
            ReadyListUpdate::Append(label) => updated.push(label),
            ReadyListUpdate::Delete(row) => {
                if row.index_usize() >= updated.len() {
                    return None;
                }
                updated.remove(row.index_usize());
            }
            ReadyListUpdate::Rebuild(labels) => updated = labels,
        }
        Some(updated)
    }

    #[test]
    fn ready_card_filter_selection_indices_follow_filter_order() {
        assert_eq!(ReadyCardFilter::COUNT, ReadyCardFilter::ALL.len());

        for (index, filter) in ReadyCardFilter::ALL.iter().copied().enumerate() {
            let selection = ReadyCardFilterSelection::new(index as u32).unwrap();
            assert_eq!(filter.selection_index(), selection);
            assert_eq!(ReadyCardFilter::from_selection(Some(selection)), filter);
        }
        assert_eq!(
            ReadyCardFilterSelection::new(ReadyCardFilter::COUNT as u32),
            None
        );
    }

    #[test]
    fn ready_card_filter_selections_reject_negative_wx_event_values() {
        assert_eq!(
            ReadyCardFilterSelection::from_wx_event_selection(Some(-1)),
            None
        );
        assert_eq!(
            ReadyCardFilterSelection::from_wx_event_selection(Some(1)),
            ReadyCardFilterSelection::new(1)
        );
    }

    #[test]
    fn ready_list_rows_reject_negative_wx_event_selections() {
        assert_eq!(ReadyListRow::from_wx_event_selection(Some(-1)), None);
        assert_eq!(
            ReadyListRow::from_wx_event_selection(Some(2)),
            Some(ReadyListRow::new(2))
        );
    }

    #[test]
    fn ready_card_sort_codes_round_trip_for_all_sort_options() {
        for sort in ReadyCardSort::ALL.iter().copied() {
            assert_eq!(ReadyCardSort::from_code(sort.code()), Some(sort));
        }

        for direction in ReadyCardSortDirection::ALL.iter().copied() {
            assert_eq!(
                ReadyCardSortDirection::from_code(direction.code()),
                Some(direction)
            );
        }
    }

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

        #[test]
        fn ready_study_item_filter_counts_match_visible_indices(
            generated in generated_ready_list(),
            text_filter in generated_text_filter(),
            sort in generated_sort(),
            sort_direction in generated_sort_direction(),
        ) {
            let state = generated.filter_state();
            let counts = ready_study_item_filter_counts(&generated.items, &text_filter, &state);

            for (filter_index, filter) in ReadyCardFilter::ALL.iter().copied().enumerate() {
                let indices = ready_study_item_indices_for_filter(
                    &generated.items,
                    filter,
                    &text_filter,
                    sort,
                    sort_direction,
                    &state,
                );
                prop_assert_eq!(
                    indices.len(),
                    counts[filter_index],
                    "visible index length disagrees with count for {}",
                    filter.code()
                );
                prop_assert!(
                    indices.iter().all(|index| *index < generated.items.len()),
                    "filter {} returned an out-of-range item index",
                    filter.code()
                );
                prop_assert_eq!(
                    index_set(&indices).len(),
                    indices.len(),
                    "filter {} returned duplicate item indices",
                    filter.code()
                );
                let labels = ready_study_item_labels_for_indices(
                    &generated.items,
                    &indices,
                    &generated.suspended,
                    &generated.flagged,
                    &generated.deferred,
                    &generated.non_reversible,
                    Localizer::default(),
                );
                prop_assert_eq!(
                    labels.len(),
                    indices.len(),
                    "filter {} produced a label count mismatch",
                    filter.code()
                );
            }
        }

        #[test]
        fn due_new_and_not_due_filters_cover_available_items_without_overlap(
            generated in generated_ready_list(),
            text_filter in generated_text_filter(),
            sort in generated_sort(),
            sort_direction in generated_sort_direction(),
        ) {
            let state = generated.filter_state();
            let due = index_set(&ready_study_item_indices_for_filter(
                &generated.items,
                ReadyCardFilter::Due,
                &text_filter,
                sort,
                sort_direction,
                &state,
            ));
            let new = index_set(&ready_study_item_indices_for_filter(
                &generated.items,
                ReadyCardFilter::New,
                &text_filter,
                sort,
                sort_direction,
                &state,
            ));
            let not_due = index_set(&ready_study_item_indices_for_filter(
                &generated.items,
                ReadyCardFilter::NotDue,
                &text_filter,
                sort,
                sort_direction,
                &state,
            ));
            let available: HashSet<_> = generated
                .items
                .iter()
                .enumerate()
                .filter_map(|(index, item)| {
                    (ready_card_matches_text_filter(item.datum(), &text_filter)
                        && !generated.suspended.contains(item.datum_key())
                        && !generated.deferred.contains(item.datum_key()))
                    .then_some(index)
                })
                .collect();

            prop_assert!(
                new.is_subset(&due),
                "new items should also be due"
            );
            prop_assert!(
                due.is_disjoint(&not_due),
                "due and not-due filters must not overlap"
            );
            prop_assert!(
                new.is_disjoint(&not_due),
                "new and not-due filters must not overlap"
            );
            let due_or_not_due: HashSet<_> = due.union(&not_due).copied().collect();
            prop_assert_eq!(
                due_or_not_due,
                available,
                "due plus not-due should cover every non-suspended, non-deferred matching item"
            );
        }

        #[test]
        fn ready_selection_normalization_prefers_visible_selection_then_first_visible(
            generated in generated_ready_list(),
            text_filter in generated_text_filter(),
            filter in prop::sample::select(&ReadyCardFilter::ALL),
            sort in generated_sort(),
            sort_direction in generated_sort_direction(),
            selected_index in prop::option::of(0usize..32),
        ) {
            let state = generated.filter_state();
            let visible = ready_study_item_indices_for_filter(
                &generated.items,
                filter,
                &text_filter,
                sort,
                sort_direction,
                &state,
            );
            let expected = selected_index
                .filter(|index| visible.contains(index))
                .or_else(|| visible.first().copied());

            prop_assert_eq!(
                normalize_ready_selection(selected_index, &visible),
                expected
            );
        }

        #[test]
        fn ready_list_updates_transform_current_labels_into_expected_labels(
            current in prop::collection::vec("[a-z]{1,8}", 0..24),
            expected in prop::collection::vec("[a-z]{1,8}", 0..24),
        ) {
            let current: Vec<String> = current;
            let expected: Vec<String> = expected;
            let update = ready_list_update(&current, &expected);

            prop_assert_eq!(
                apply_ready_list_update(&current, update),
                Some(expected)
            );
        }

        #[test]
        fn ready_list_updates_use_incremental_append_for_tail_insertions(
            current in prop::collection::vec("[a-z]{1,8}", 0..24),
            appended in "[a-z]{1,8}",
        ) {
            let current: Vec<String> = current;
            let mut expected = current.clone();
            expected.push(appended.clone());

            prop_assert_eq!(
                ready_list_update(&current, &expected),
                ReadyListUpdate::Append(appended)
            );
        }

        #[test]
        fn ready_list_updates_use_incremental_delete_for_single_row_removals(
            current in hash_set(0u16..512, 1..24)
                .prop_map(|rows| {
                    let mut rows: Vec<_> = rows.into_iter().collect();
                    rows.sort_unstable();
                    rows.into_iter().map(|row| format!("row {row}")).collect::<Vec<_>>()
                }),
            deleted_index in 0usize..24,
        ) {
            let deleted_index = deleted_index % current.len();
            let mut expected = current.clone();
            expected.remove(deleted_index);

            prop_assert_eq!(
                ready_list_update(&current, &expected),
                ReadyListUpdate::Delete(ReadyListRow::new(deleted_index))
            );
        }
    }
}
