use crate::localization::Localizer;
use crate::study_item::StudyItem;
#[cfg(test)]
use cosimo::schedule_datum;
use cosimo::{CardMemoryState, CardSchedule, Datum, DatumKey, schedule_datum_for_key};
use std::cmp::Ordering;
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};

const DAY_IN_SECONDS: i64 = 24 * 60 * 60;

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

#[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 {
    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 {
    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) 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>,
    duplicate_prompts: &'a HashSet<DatumKey>,
    duplicate_responses: &'a HashSet<DatumKey>,
    now_unix_seconds: i64,
}

impl<'a> ReadyFilterContext<'a> {
    fn from_study_items(
        state: &'a ReadyStudyItemFilterState<'a>,
        duplicate_prompts: &'a HashSet<DatumKey>,
        duplicate_responses: &'a HashSet<DatumKey>,
    ) -> Self {
        Self {
            schedules: state.schedules,
            suspended: state.suspended,
            flagged: state.flagged,
            deferred: state.deferred,
            non_reversible: state.non_reversible,
            duplicate_prompts,
            duplicate_responses,
            now_unix_seconds: state.now_unix_seconds,
        }
    }
}

impl ReadyCardFilter {
    pub(crate) const COUNT: usize = 11;

    pub(crate) const ALL: [Self; Self::COUNT] = [
        Self::All,
        Self::Due,
        Self::New,
        Self::NotDue,
        Self::Suspended,
        Self::Flagged,
        Self::Deferred,
        Self::NonReversible,
        Self::Reverse,
        Self::DuplicatePrompts,
        Self::DuplicateResponses,
    ];

    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),
            _ => 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",
        }
    }

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

    pub(crate) fn selection_index(self) -> u32 {
        match self {
            Self::All => 0,
            Self::Due => 1,
            Self::New => 2,
            Self::NotDue => 3,
            Self::Suspended => 4,
            Self::Flagged => 5,
            Self::Deferred => 6,
            Self::NonReversible => 7,
            Self::Reverse => 8,
            Self::DuplicatePrompts => 9,
            Self::DuplicateResponses => 10,
        }
    }

    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(),
        }
    }
}

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(usize),
    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,
        duplicate_prompts: &duplicate_prompts,
        duplicate_responses: &duplicate_responses,
        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(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),
    }
}

fn estimated_recall_now(schedule: &CardSchedule, now_unix_seconds: i64) -> Option<f64> {
    let last_reviewed_at = schedule.last_reviewed_at_unix_seconds()?;
    let stability_days = recall_estimate_stability_days(schedule);
    if stability_days <= 0.0 {
        return None;
    }

    let elapsed_seconds = now_unix_seconds.saturating_sub(last_reviewed_at);
    let elapsed_days = elapsed_seconds as f64 / DAY_IN_SECONDS as f64;
    Some((1.0 + elapsed_days / (9.0 * stability_days)).powf(-1.0))
}

fn recall_estimate_stability_days(schedule: &CardSchedule) -> f64 {
    let stability_days = schedule.stability_days();
    let interval_days = schedule.interval_seconds() as f64 / DAY_IN_SECONDS as f64;
    if interval_days > 0.0 {
        stability_days.min(interval_days)
    } else {
        stability_days
    }
}

#[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,
        duplicate_prompts: &duplicate_prompts,
        duplicate_responses: &duplicate_responses,
        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(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::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 => schedule_datum(
            datum,
            context.schedules.get(&datum_key),
            context.now_unix_seconds,
        )
        .is_due(),
        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::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 => schedule_datum_for_key(
            item.datum_key().clone(),
            context.schedules.get(item.datum_key()),
            context.now_unix_seconds,
        )
        .is_due(),
        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()
        }
    }
}

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

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