use std::collections::{HashMap, HashSet};

#[cfg(test)]
use cosimo::Datum;
use cosimo::{
    CardMemoryState, CardSchedule, DatumKey, PromptGuess, RecallFeedback, ReviewEvent,
    ReviewPassSummary, schedule_datum_for_key,
};

use crate::confidence_diagnostics::{
    RecentConfidenceDiagnostics, is_high_confidence_failure, is_low_confidence_success,
};
use crate::localization::Localizer;
use crate::recall_estimate::estimated_recall_now;
const DAY_IN_SECONDS: i64 = 24 * 60 * 60;
const NEW_CARD_INTRODUCTION_WINDOW_SECONDS: i64 = DAY_IN_SECONDS;
const CALIBRATION_BUCKET_COUNT: usize = PromptGuess::COUNT - 1;
const TREND_WINDOW_SECONDS: i64 = 7 * DAY_IN_SECONDS;
const MIN_TREND_ANSWERS: usize = 5;
const MIN_WORKLOAD_SHAPE_CARDS: usize = 5;
const MEANINGFUL_TREND_DELTA: f64 = 0.05;
const STUDY_TIME_TREND_MIN_SECONDS: u64 = 5 * 60;
const STUDY_TIME_TREND_RELATIVE_DELTA: f64 = 0.20;
// Half of one displayed tenth of a percentage point.
const CALIBRATION_STATUS_TOLERANCE: f64 = 0.0005;

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CardStateCounts {
    pub(crate) total: usize,
    pub(crate) active: usize,
    pub(crate) suspended: usize,
    pub(crate) deferred: usize,
    pub(crate) new: usize,
    pub(crate) learning: usize,
    pub(crate) review: usize,
    pub(crate) relearning: usize,
    pub(crate) due_now: usize,
    pub(crate) scheduled_due_now: usize,
}

#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub(crate) struct RecallEstimate {
    pub(crate) estimated_cards: usize,
    pub(crate) average_recall: Option<f64>,
    pub(crate) median_recall: Option<f64>,
    pub(crate) weakest_decile_recall: Option<f64>,
    pub(crate) likely_known: usize,
    pub(crate) at_risk: usize,
    pub(crate) mostly_stable: usize,
    pub(crate) fragile: usize,
    pub(crate) very_low: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CalibrationRecallRule {
    Lenient,
    Strict,
}

impl Default for CalibrationRecallRule {
    fn default() -> Self {
        Self::available()[0]
    }
}

impl CalibrationRecallRule {
    const ALL: &'static [Self] = &[Self::Lenient, Self::Strict];

    pub(crate) fn available() -> &'static [Self] {
        Self::ALL
    }

    pub(crate) fn from_code(code: &str) -> Option<Self> {
        match code {
            "lenient" => Some(Self::Lenient),
            "strict" => Some(Self::Strict),
            _ => None,
        }
    }

    pub(crate) fn code(self) -> &'static str {
        match self {
            Self::Lenient => "lenient",
            Self::Strict => "strict",
        }
    }

    pub(crate) fn is_recalled(self, feedback: RecallFeedback) -> bool {
        match self {
            Self::Lenient => feedback != RecallFeedback::Again,
            Self::Strict => matches!(feedback, RecallFeedback::Good | RecallFeedback::Easy),
        }
    }
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CalibrationBucket {
    pub(crate) answers: usize,
    pub(crate) recalled: usize,
}

#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub(crate) struct CalibrationSummary {
    pub(crate) answered_with_confidence: usize,
    pub(crate) recalled_with_confidence: usize,
    pub(crate) target_recall_lower_total: f64,
    pub(crate) target_recall_upper_total: f64,
    pub(crate) not_answered: usize,
    pub(crate) high_confidence_failures: usize,
    pub(crate) recent_high_confidence_failures: usize,
    pub(crate) high_confidence_failure_cards: usize,
    pub(crate) recent_high_confidence_failure_cards: usize,
    pub(crate) significant_recent_overconfidence_cards: usize,
    pub(crate) low_confidence_successes: usize,
    pub(crate) recent_low_confidence_successes: usize,
    pub(crate) low_confidence_success_cards: usize,
    pub(crate) recent_low_confidence_success_cards: usize,
    pub(crate) significant_recent_underconfidence_cards: usize,
    pub(crate) buckets: [CalibrationBucket; CALIBRATION_BUCKET_COUNT],
}

impl CalibrationSummary {
    fn target_recall_range(self) -> Option<CalibrationTargetRange> {
        if self.answered_with_confidence == 0 {
            return None;
        }

        let answers = self.answered_with_confidence as f64;
        Some(CalibrationTargetRange {
            lower: self.target_recall_lower_total / answers,
            upper: self.target_recall_upper_total / answers,
        })
    }

    fn observed_recall_rate(self) -> Option<f64> {
        (self.answered_with_confidence > 0)
            .then_some(self.recalled_with_confidence as f64 / self.answered_with_confidence as f64)
    }

    fn calibration_status(self) -> Option<CalibrationStatus> {
        Some(calibration_status_for(
            self.observed_recall_rate()?,
            self.target_recall_range()?,
        ))
    }
}

#[derive(Debug, Clone, Copy, PartialEq)]
struct CalibrationTargetRange {
    lower: f64,
    upper: f64,
}

#[derive(Debug, Clone, Copy, PartialEq)]
enum CalibrationStatus {
    WithinTarget,
    UnderConfident(f64),
    OverConfident(f64),
}

#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub(crate) struct LearningPaceSummary {
    pub(crate) reviewed_cards: usize,
    pub(crate) cards_in_review: usize,
    pub(crate) average_reviews_per_reviewed_card: Option<f64>,
    pub(crate) average_reviews_to_first_review_state: Option<f64>,
    pub(crate) average_seconds_to_first_review_state: Option<i64>,
    pub(crate) graduated_last_7_days: usize,
    pub(crate) graduated_previous_7_days: usize,
    pub(crate) recent_graduated_cards: usize,
    pub(crate) recent_graduated_without_lapse: usize,
}

#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub(crate) struct ReviewActivitySummary {
    pub(crate) review_events: usize,
    pub(crate) review_days: usize,
    pub(crate) average_reviews_per_review_day: Option<f64>,
    pub(crate) review_passes: usize,
    pub(crate) review_passes_last_7_days: usize,
    pub(crate) review_passes_previous_7_days: usize,
    pub(crate) recorded_session_days: usize,
    pub(crate) questions_answered: usize,
    pub(crate) questions_last_7_days: usize,
    pub(crate) questions_previous_7_days: usize,
    pub(crate) unique_cards_reviewed_in_passes: usize,
    pub(crate) total_review_seconds: u64,
    pub(crate) review_seconds_last_7_days: u64,
    pub(crate) review_seconds_previous_7_days: u64,
    pub(crate) average_questions_per_pass: Option<f64>,
    pub(crate) average_seconds_per_pass: Option<u64>,
    pub(crate) average_seconds_per_question: Option<u64>,
    pub(crate) average_seconds_per_unique_card: Option<u64>,
    pub(crate) average_seconds_per_recorded_session_day: Option<u64>,
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DeckGrowthSummary {
    pub(crate) observed_standard_cards: usize,
    pub(crate) first_seen_last_7_days: usize,
    pub(crate) first_seen_previous_7_days: usize,
    pub(crate) first_seen_older: usize,
    pub(crate) unstudied_active_standard_cards: usize,
}

impl DeckGrowthSummary {
    fn tracking_has_history(self) -> bool {
        self.first_seen_previous_7_days > 0 || self.first_seen_older > 0
    }

    fn tracking_appears_new(self) -> bool {
        self.observed_standard_cards > 0
            && self.first_seen_last_7_days == self.observed_standard_cards
            && !self.tracking_has_history()
    }
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct WorkloadForecastSummary {
    pub(crate) due_now: usize,
    pub(crate) due_within_24_hours: usize,
    pub(crate) due_within_3_days: usize,
    pub(crate) due_within_7_days: usize,
    pub(crate) due_within_30_days: usize,
}

#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub(crate) struct RecallTrendSummary {
    pub(crate) answers: usize,
    pub(crate) recalled_answers: usize,
    pub(crate) again_answers: usize,
    pub(crate) hard_answers: usize,
    pub(crate) good_answers: usize,
    pub(crate) easy_answers: usize,
    pub(crate) answers_last_7_days: usize,
    pub(crate) recalled_last_7_days: usize,
    pub(crate) again_last_7_days: usize,
    pub(crate) answers_previous_7_days: usize,
    pub(crate) recalled_previous_7_days: usize,
    pub(crate) again_previous_7_days: usize,
    pub(crate) answers_last_30_days: usize,
    pub(crate) recalled_last_30_days: usize,
    pub(crate) total_lapses: usize,
    pub(crate) cards_with_lapses: usize,
    pub(crate) cards_with_multiple_lapses: usize,
}

#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub(crate) struct LearningDashboardSummary {
    pub(crate) states: CardStateCounts,
    pub(crate) workload: WorkloadForecastSummary,
    pub(crate) recall: RecallEstimate,
    pub(crate) recall_trend: RecallTrendSummary,
    pub(crate) calibration: CalibrationSummary,
    pub(crate) pace: LearningPaceSummary,
}

#[derive(Debug, Clone)]
pub(crate) struct LearningDashboardCard {
    datum_key: DatumKey,
    is_reverse: bool,
}

impl LearningDashboardCard {
    pub(crate) fn new(datum_key: DatumKey, is_reverse: bool) -> Self {
        Self {
            datum_key,
            is_reverse,
        }
    }

    pub(crate) fn datum_key(&self) -> &DatumKey {
        &self.datum_key
    }

    #[cfg(test)]
    pub(crate) fn standard(datum: &Datum) -> Self {
        Self::new(DatumKey::for_datum(datum), false)
    }
}

#[derive(Debug, Clone, PartialEq)]
pub(crate) struct LearningDashboardReport {
    pub(crate) total: LearningDashboardSummary,
    pub(crate) standard: LearningDashboardSummary,
    pub(crate) reverse: Option<LearningDashboardSummary>,
    pub(crate) activity: ReviewActivitySummary,
    pub(crate) deck_growth: DeckGrowthSummary,
    pub(crate) calibration_recall_rule: CalibrationRecallRule,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LearningDashboardTextSection {
    heading: String,
    body: String,
}

impl LearningDashboardTextSection {
    pub(crate) fn heading(&self) -> &str {
        &self.heading
    }

    pub(crate) fn body(&self) -> &str {
        &self.body
    }
}

#[cfg(test)]
pub(crate) fn learning_dashboard_summary(
    data: &[Datum],
    schedules: &HashMap<DatumKey, CardSchedule>,
    suspended: &HashSet<DatumKey>,
    deferred: &HashSet<DatumKey>,
    reviews_by_key: &HashMap<DatumKey, Vec<ReviewEvent>>,
    now_unix_seconds: i64,
) -> LearningDashboardSummary {
    let cards: Vec<_> = data.iter().map(LearningDashboardCard::standard).collect();
    let new_card_workload_parent_keys = new_card_workload_parent_keys(
        &cards,
        schedules,
        suspended,
        deferred,
        reviews_by_key,
        now_unix_seconds,
        usize::MAX,
    );
    let summary_context = DashboardCardSummaryContext {
        schedules,
        suspended,
        deferred,
        reviews_by_key,
        now_unix_seconds,
        new_card_workload_parent_keys: &new_card_workload_parent_keys,
        calibration_recall_rule: CalibrationRecallRule::default(),
    };
    summarize_dashboard_cards(cards.iter(), &summary_context)
}

#[cfg(test)]
pub(crate) fn learning_dashboard_report(
    cards: &[LearningDashboardCard],
    schedules: &HashMap<DatumKey, CardSchedule>,
    suspended: &HashSet<DatumKey>,
    reviews_by_key: &HashMap<DatumKey, Vec<ReviewEvent>>,
    review_passes: &[ReviewPassSummary],
    now_unix_seconds: i64,
) -> LearningDashboardReport {
    let first_seen_by_key = HashMap::new();
    learning_dashboard_report_with_calibration_rule(
        LearningDashboardReportInput {
            cards,
            schedules,
            suspended,
            deferred: &HashSet::new(),
            reviews_by_key,
            review_passes,
            first_seen_by_key: &first_seen_by_key,
            now_unix_seconds,
            new_cards_per_day: usize::MAX,
        },
        CalibrationRecallRule::default(),
    )
}

pub(crate) struct LearningDashboardReportInput<'a> {
    pub(crate) cards: &'a [LearningDashboardCard],
    pub(crate) schedules: &'a HashMap<DatumKey, CardSchedule>,
    pub(crate) suspended: &'a HashSet<DatumKey>,
    pub(crate) deferred: &'a HashSet<DatumKey>,
    pub(crate) reviews_by_key: &'a HashMap<DatumKey, Vec<ReviewEvent>>,
    pub(crate) review_passes: &'a [ReviewPassSummary],
    pub(crate) first_seen_by_key: &'a HashMap<DatumKey, i64>,
    pub(crate) now_unix_seconds: i64,
    pub(crate) new_cards_per_day: usize,
}

struct DashboardCardSummaryContext<'a> {
    schedules: &'a HashMap<DatumKey, CardSchedule>,
    suspended: &'a HashSet<DatumKey>,
    deferred: &'a HashSet<DatumKey>,
    reviews_by_key: &'a HashMap<DatumKey, Vec<ReviewEvent>>,
    now_unix_seconds: i64,
    new_card_workload_parent_keys: &'a HashSet<DatumKey>,
    calibration_recall_rule: CalibrationRecallRule,
}

pub(crate) fn learning_dashboard_report_with_calibration_rule(
    input: LearningDashboardReportInput<'_>,
    calibration_recall_rule: CalibrationRecallRule,
) -> LearningDashboardReport {
    let new_card_workload_parent_keys = new_card_workload_parent_keys(
        input.cards,
        input.schedules,
        input.suspended,
        input.deferred,
        input.reviews_by_key,
        input.now_unix_seconds,
        input.new_cards_per_day,
    );
    let summary_context = DashboardCardSummaryContext {
        schedules: input.schedules,
        suspended: input.suspended,
        deferred: input.deferred,
        reviews_by_key: input.reviews_by_key,
        now_unix_seconds: input.now_unix_seconds,
        new_card_workload_parent_keys: &new_card_workload_parent_keys,
        calibration_recall_rule,
    };
    let total = summarize_dashboard_cards(input.cards.iter(), &summary_context);
    let standard = summarize_dashboard_cards(
        input.cards.iter().filter(|card| !card.is_reverse),
        &summary_context,
    );
    let reverse_count = input.cards.iter().filter(|card| card.is_reverse).count();
    let reverse = (reverse_count > 0).then(|| {
        summarize_dashboard_cards(
            input.cards.iter().filter(|card| card.is_reverse),
            &summary_context,
        )
    });

    LearningDashboardReport {
        total,
        standard,
        reverse,
        activity: summarize_review_activity(
            input.reviews_by_key,
            input.review_passes,
            input.now_unix_seconds,
        ),
        deck_growth: summarize_deck_growth(
            input.cards,
            input.schedules,
            input.suspended,
            input.deferred,
            input.first_seen_by_key,
            input.now_unix_seconds,
        ),
        calibration_recall_rule,
    }
}

fn summarize_dashboard_cards<'a>(
    cards: impl Iterator<Item = &'a LearningDashboardCard>,
    context: &DashboardCardSummaryContext<'_>,
) -> LearningDashboardSummary {
    let schedules = context.schedules;
    let suspended = context.suspended;
    let deferred = context.deferred;
    let reviews_by_key = context.reviews_by_key;
    let now_unix_seconds = context.now_unix_seconds;
    let new_card_workload_parent_keys = context.new_card_workload_parent_keys;
    let calibration_recall_rule = context.calibration_recall_rule;
    let mut summary = LearningDashboardSummary::default();

    let mut estimated_recall_total = 0.0;
    let mut estimated_recall_values = Vec::new();
    let mut total_review_count = 0usize;
    let mut total_reviews_to_first_review_state = 0usize;
    let mut graduated_cards = 0usize;
    let mut total_seconds_to_first_review_state = 0i64;

    for card in cards {
        summary.states.total += 1;
        let datum_key = &card.datum_key;
        let is_suspended = suspended.contains(datum_key);
        let is_deferred = deferred.contains(datum_key);
        let is_unavailable = is_suspended || is_deferred;
        if is_suspended {
            summary.states.suspended += 1;
        }
        if is_deferred {
            summary.states.deferred += 1;
        }
        if !is_unavailable {
            summary.states.active += 1;
        }

        let schedule = schedules.get(datum_key);
        match schedule.map(CardSchedule::memory_state) {
            None | Some(CardMemoryState::New) => summary.states.new += 1,
            Some(CardMemoryState::Learning) => summary.states.learning += 1,
            Some(CardMemoryState::Review) => summary.states.review += 1,
            Some(CardMemoryState::Relearning) => summary.states.relearning += 1,
        }

        if !is_unavailable
            && card_counts_toward_workload(datum_key, schedule, new_card_workload_parent_keys)
        {
            let scheduled = schedule_datum_for_key(datum_key.clone(), schedule, now_unix_seconds);
            add_workload_forecast(
                &mut summary.workload,
                scheduled.due_at_unix_seconds(),
                now_unix_seconds,
            );
            if scheduled.is_due() {
                summary.states.due_now += 1;
                if schedule.is_some() {
                    summary.states.scheduled_due_now += 1;
                }
            }
        }

        if !is_unavailable
            && let Some(schedule) = schedule
            && let Some(recall) = estimated_recall_now(schedule, now_unix_seconds)
        {
            summary.recall.estimated_cards += 1;
            estimated_recall_total += recall;
            estimated_recall_values.push(recall);
            if recall >= 0.9 {
                summary.recall.likely_known += 1;
            } else if recall >= 0.7 {
                summary.recall.mostly_stable += 1;
            } else if recall >= 0.5 {
                summary.recall.fragile += 1;
                summary.recall.at_risk += 1;
            } else {
                summary.recall.very_low += 1;
                summary.recall.at_risk += 1;
            }
        }

        if !is_unavailable && let Some(schedule) = schedule {
            add_schedule_lapses(&mut summary.recall_trend, schedule);
        }

        let reviews = reviews_by_key
            .get(datum_key)
            .map(Vec::as_slice)
            .unwrap_or(&[]);
        if !reviews.is_empty() {
            summary.pace.reviewed_cards += 1;
            total_review_count += reviews.len();
        }
        if schedule.map(CardSchedule::memory_state) == Some(CardMemoryState::Review) {
            summary.pace.cards_in_review += 1;
        }
        if schedule.is_some()
            && let Some(graduation) = first_review_state_transition(reviews)
        {
            graduated_cards += 1;
            total_reviews_to_first_review_state += graduation.review_count;
            total_seconds_to_first_review_state += graduation.elapsed_seconds;
            let graduation_age_seconds =
                now_unix_seconds.saturating_sub(graduation.reviewed_at_unix_seconds);
            if graduation_age_seconds <= TREND_WINDOW_SECONDS {
                summary.pace.graduated_last_7_days += 1;
            } else if graduation_age_seconds <= 2 * TREND_WINDOW_SECONDS {
                summary.pace.graduated_previous_7_days += 1;
            }
            if graduation_age_seconds <= 30 * DAY_IN_SECONDS {
                summary.pace.recent_graduated_cards += 1;
                if !reviews_after_index(reviews, graduation.review_index)
                    .iter()
                    .any(|review| review.review().recall_feedback() == RecallFeedback::Again)
                {
                    summary.pace.recent_graduated_without_lapse += 1;
                }
            }
        }

        let is_recalled = |feedback| calibration_recall_rule.is_recalled(feedback);
        let mut has_high_confidence_failure = false;
        let mut has_recent_high_confidence_failure = false;
        let mut has_low_confidence_success = false;
        let mut has_recent_low_confidence_success = false;
        for review in reviews {
            if is_high_confidence_failure(review, &is_recalled) {
                has_high_confidence_failure = true;
                if event_is_recent(review, now_unix_seconds) {
                    has_recent_high_confidence_failure = true;
                }
            }
            if is_low_confidence_success(review, &is_recalled) {
                has_low_confidence_success = true;
                if event_is_recent(review, now_unix_seconds) {
                    has_recent_low_confidence_success = true;
                }
            }
            add_calibration_event(
                &mut summary.calibration,
                review,
                now_unix_seconds,
                calibration_recall_rule,
            );
            add_recall_trend_event(&mut summary.recall_trend, review, now_unix_seconds);
        }
        if has_high_confidence_failure {
            summary.calibration.high_confidence_failure_cards += 1;
        }
        if has_recent_high_confidence_failure {
            summary.calibration.recent_high_confidence_failure_cards += 1;
        }
        if has_low_confidence_success {
            summary.calibration.low_confidence_success_cards += 1;
        }
        if has_recent_low_confidence_success {
            summary.calibration.recent_low_confidence_success_cards += 1;
        }
        let diagnostics = RecentConfidenceDiagnostics::from_reviews(reviews, |feedback| {
            calibration_recall_rule.is_recalled(feedback)
        });
        if diagnostics.matches_high_confidence_failure_filter() {
            summary.calibration.significant_recent_overconfidence_cards += 1;
        }
        if diagnostics.matches_low_confidence_success_filter() {
            summary.calibration.significant_recent_underconfidence_cards += 1;
        }
    }

    if summary.recall.estimated_cards > 0 {
        summary.recall.average_recall =
            Some(estimated_recall_total / summary.recall.estimated_cards as f64);
        estimated_recall_values.sort_by(f64::total_cmp);
        summary.recall.median_recall = median(&estimated_recall_values);
        summary.recall.weakest_decile_recall = weakest_decile_average(&estimated_recall_values);
    }
    if summary.pace.reviewed_cards > 0 {
        summary.pace.average_reviews_per_reviewed_card =
            Some(total_review_count as f64 / summary.pace.reviewed_cards as f64);
    }
    if graduated_cards > 0 {
        summary.pace.average_reviews_to_first_review_state =
            Some(total_reviews_to_first_review_state as f64 / graduated_cards as f64);
        summary.pace.average_seconds_to_first_review_state =
            Some(total_seconds_to_first_review_state / graduated_cards as i64);
    }

    summary
}

fn new_card_workload_parent_keys(
    cards: &[LearningDashboardCard],
    schedules: &HashMap<DatumKey, CardSchedule>,
    suspended: &HashSet<DatumKey>,
    deferred: &HashSet<DatumKey>,
    reviews_by_key: &HashMap<DatumKey, Vec<ReviewEvent>>,
    now_unix_seconds: i64,
    new_cards_per_day: usize,
) -> HashSet<DatumKey> {
    let first_reviewed_by_parent =
        first_reviewed_by_parent_key(cards, suspended, deferred, reviews_by_key);
    let introduction_window_start =
        now_unix_seconds.saturating_sub(NEW_CARD_INTRODUCTION_WINDOW_SECONDS);
    let introduced_recently = first_reviewed_by_parent
        .values()
        .filter(|reviewed_at| **reviewed_at >= introduction_window_start)
        .count();
    let remaining_new_cards = new_cards_per_day.saturating_sub(introduced_recently);

    let mut included_parent_keys = first_reviewed_by_parent.into_keys().collect::<HashSet<_>>();
    let mut selected_unintroduced_parent_keys = HashSet::new();
    for card in cards {
        let datum_key = card.datum_key();
        if suspended.contains(datum_key)
            || deferred.contains(datum_key)
            || schedules.contains_key(datum_key)
        {
            continue;
        }

        let parent_key = parent_key_for_new_card_introduction(datum_key);
        if included_parent_keys.contains(&parent_key)
            || selected_unintroduced_parent_keys.contains(&parent_key)
        {
            continue;
        }

        if selected_unintroduced_parent_keys.len() < remaining_new_cards {
            selected_unintroduced_parent_keys.insert(parent_key.clone());
            included_parent_keys.insert(parent_key);
        }
    }

    included_parent_keys
}

fn first_reviewed_by_parent_key(
    cards: &[LearningDashboardCard],
    suspended: &HashSet<DatumKey>,
    deferred: &HashSet<DatumKey>,
    reviews_by_key: &HashMap<DatumKey, Vec<ReviewEvent>>,
) -> HashMap<DatumKey, i64> {
    let mut first_reviewed_by_parent: HashMap<DatumKey, i64> = HashMap::new();
    for card in cards {
        let datum_key = card.datum_key();
        if suspended.contains(datum_key) || deferred.contains(datum_key) {
            continue;
        }

        let Some(first_reviewed_at) = reviews_by_key.get(datum_key).and_then(|reviews| {
            reviews
                .iter()
                .map(ReviewEvent::reviewed_at_unix_seconds)
                .min()
        }) else {
            continue;
        };
        let parent_key = parent_key_for_new_card_introduction(datum_key);
        first_reviewed_by_parent
            .entry(parent_key)
            .and_modify(|existing| *existing = (*existing).min(first_reviewed_at))
            .or_insert(first_reviewed_at);
    }

    first_reviewed_by_parent
}

fn card_counts_toward_workload(
    datum_key: &DatumKey,
    schedule: Option<&CardSchedule>,
    new_card_workload_parent_keys: &HashSet<DatumKey>,
) -> bool {
    schedule.is_some()
        || new_card_workload_parent_keys.contains(&parent_key_for_new_card_introduction(datum_key))
}

fn parent_key_for_new_card_introduction(datum_key: &DatumKey) -> DatumKey {
    datum_key
        .parent_key_for_reverse()
        .unwrap_or_else(|| datum_key.clone())
}

fn summarize_review_activity(
    reviews_by_key: &HashMap<DatumKey, Vec<ReviewEvent>>,
    review_passes: &[ReviewPassSummary],
    now_unix_seconds: i64,
) -> ReviewActivitySummary {
    let mut summary = ReviewActivitySummary::default();
    let mut review_days = HashSet::new();

    for review in reviews_by_key.values().flatten() {
        summary.review_events += 1;
        review_days.insert(review.reviewed_at_unix_seconds().div_euclid(DAY_IN_SECONDS));
    }
    summary.review_days = review_days.len();
    if summary.review_days > 0 {
        summary.average_reviews_per_review_day =
            Some(summary.review_events as f64 / summary.review_days as f64);
    }

    summary.review_passes = review_passes.len();
    let mut recorded_session_days = HashSet::new();
    for pass in review_passes {
        recorded_session_days.insert(pass.started_at_unix_seconds().div_euclid(DAY_IN_SECONDS));
        summary.questions_answered += pass.total_questions();
        summary.unique_cards_reviewed_in_passes += pass.unique_cards_reviewed();
        let pass_seconds = pass_duration_seconds(pass);
        summary.total_review_seconds += pass_seconds;
        add_review_pass_window_activity(&mut summary, pass, pass_seconds, now_unix_seconds);
    }
    summary.recorded_session_days = recorded_session_days.len();
    if summary.review_passes > 0 {
        summary.average_questions_per_pass =
            Some(summary.questions_answered as f64 / summary.review_passes as f64);
        summary.average_seconds_per_pass = Some(
            summary
                .total_review_seconds
                .div_ceil(summary.review_passes as u64),
        );
    }
    if summary.questions_answered > 0 {
        summary.average_seconds_per_question = Some(
            summary
                .total_review_seconds
                .div_ceil(summary.questions_answered as u64),
        );
    }
    if summary.unique_cards_reviewed_in_passes > 0 {
        summary.average_seconds_per_unique_card = Some(
            summary
                .total_review_seconds
                .div_ceil(summary.unique_cards_reviewed_in_passes as u64),
        );
    }
    if summary.recorded_session_days > 0 {
        summary.average_seconds_per_recorded_session_day = Some(
            summary
                .total_review_seconds
                .div_ceil(summary.recorded_session_days as u64),
        );
    }

    summary
}

fn summarize_deck_growth(
    cards: &[LearningDashboardCard],
    schedules: &HashMap<DatumKey, CardSchedule>,
    suspended: &HashSet<DatumKey>,
    deferred: &HashSet<DatumKey>,
    first_seen_by_key: &HashMap<DatumKey, i64>,
    now_unix_seconds: i64,
) -> DeckGrowthSummary {
    let mut summary = DeckGrowthSummary::default();

    for card in cards.iter().filter(|card| !card.is_reverse) {
        let datum_key = card.datum_key();
        if suspended.contains(datum_key) || deferred.contains(datum_key) {
            continue;
        }
        if schedules
            .get(datum_key)
            .map(CardSchedule::memory_state)
            .is_none_or(|state| state == CardMemoryState::New)
        {
            summary.unstudied_active_standard_cards += 1;
        }
    }

    for card in cards.iter().filter(|card| !card.is_reverse) {
        if let Some(first_seen_at) = first_seen_by_key.get(card.datum_key()).copied() {
            summary.observed_standard_cards += 1;
            let age_seconds = now_unix_seconds.saturating_sub(first_seen_at);
            if age_seconds <= TREND_WINDOW_SECONDS {
                summary.first_seen_last_7_days += 1;
            } else if age_seconds <= 2 * TREND_WINDOW_SECONDS {
                summary.first_seen_previous_7_days += 1;
            } else {
                summary.first_seen_older += 1;
            }
        }
    }

    summary
}

fn add_review_pass_window_activity(
    summary: &mut ReviewActivitySummary,
    pass: &ReviewPassSummary,
    pass_seconds: u64,
    now_unix_seconds: i64,
) {
    let age_seconds = now_unix_seconds.saturating_sub(pass.started_at_unix_seconds());
    if age_seconds <= TREND_WINDOW_SECONDS {
        summary.review_passes_last_7_days += 1;
        summary.questions_last_7_days += pass.total_questions();
        summary.review_seconds_last_7_days += pass_seconds;
    } else if age_seconds <= 2 * TREND_WINDOW_SECONDS {
        summary.review_passes_previous_7_days += 1;
        summary.questions_previous_7_days += pass.total_questions();
        summary.review_seconds_previous_7_days += pass_seconds;
    }
}

fn add_workload_forecast(
    summary: &mut WorkloadForecastSummary,
    due_at_unix_seconds: i64,
    now_unix_seconds: i64,
) {
    if due_at_unix_seconds <= now_unix_seconds {
        summary.due_now += 1;
    }
    if due_at_unix_seconds <= now_unix_seconds.saturating_add(DAY_IN_SECONDS) {
        summary.due_within_24_hours += 1;
    }
    if due_at_unix_seconds <= now_unix_seconds.saturating_add(3 * DAY_IN_SECONDS) {
        summary.due_within_3_days += 1;
    }
    if due_at_unix_seconds <= now_unix_seconds.saturating_add(7 * DAY_IN_SECONDS) {
        summary.due_within_7_days += 1;
    }
    if due_at_unix_seconds <= now_unix_seconds.saturating_add(30 * DAY_IN_SECONDS) {
        summary.due_within_30_days += 1;
    }
}

fn add_schedule_lapses(summary: &mut RecallTrendSummary, schedule: &CardSchedule) {
    let lapses = schedule.lapses() as usize;
    summary.total_lapses += lapses;
    if lapses > 0 {
        summary.cards_with_lapses += 1;
    }
    if lapses > 1 {
        summary.cards_with_multiple_lapses += 1;
    }
}

fn add_recall_trend_event(
    summary: &mut RecallTrendSummary,
    event: &ReviewEvent,
    now_unix_seconds: i64,
) {
    summary.answers += 1;
    match event.review().recall_feedback() {
        RecallFeedback::Again => summary.again_answers += 1,
        RecallFeedback::Hard => {
            summary.hard_answers += 1;
            summary.recalled_answers += 1;
        }
        RecallFeedback::Good => {
            summary.good_answers += 1;
            summary.recalled_answers += 1;
        }
        RecallFeedback::Easy => {
            summary.easy_answers += 1;
            summary.recalled_answers += 1;
        }
    }

    let age_seconds = now_unix_seconds.saturating_sub(event.reviewed_at_unix_seconds());
    if age_seconds <= TREND_WINDOW_SECONDS {
        summary.answers_last_7_days += 1;
        if event.review().recall_feedback() != RecallFeedback::Again {
            summary.recalled_last_7_days += 1;
        }
        if event.review().recall_feedback() == RecallFeedback::Again {
            summary.again_last_7_days += 1;
        }
    } else if age_seconds <= 2 * TREND_WINDOW_SECONDS {
        summary.answers_previous_7_days += 1;
        if event.review().recall_feedback() != RecallFeedback::Again {
            summary.recalled_previous_7_days += 1;
        }
        if event.review().recall_feedback() == RecallFeedback::Again {
            summary.again_previous_7_days += 1;
        }
    }
    if age_seconds <= 30 * DAY_IN_SECONDS {
        summary.answers_last_30_days += 1;
        if event.review().recall_feedback() != RecallFeedback::Again {
            summary.recalled_last_30_days += 1;
        }
    }
}

fn pass_duration_seconds(pass: &ReviewPassSummary) -> u64 {
    pass.finished_at_unix_seconds()
        .saturating_sub(pass.started_at_unix_seconds())
        .max(0) as u64
}

#[cfg(test)]
pub(crate) fn learning_dashboard_text(
    localizer: Localizer,
    report: &LearningDashboardReport,
) -> String {
    learning_dashboard_markdown_text(&learning_dashboard_sections(localizer, report))
}

pub(crate) fn learning_dashboard_markdown_text(
    sections: &[LearningDashboardTextSection],
) -> String {
    sections
        .iter()
        .map(|section| format!("## {}\n\n{}", section.heading, section.body))
        .collect::<Vec<_>>()
        .join("\n\n")
}

pub(crate) fn learning_dashboard_sections(
    localizer: Localizer,
    report: &LearningDashboardReport,
) -> Vec<LearningDashboardTextSection> {
    let mut sections = Vec::new();
    sections.push(dashboard_section(
        localizer,
        "Summary",
        learning_dashboard_overview_text(localizer, report),
    ));
    if let Some(reverse) = &report.reverse {
        sections.push(dashboard_section(
            localizer,
            "Standard and reversed cards",
            learning_dashboard_standard_reverse_text(localizer, &report.standard, reverse),
        ));
    }
    sections.push(dashboard_section(
        localizer,
        "Cards",
        learning_dashboard_card_state_text(localizer, report),
    ));
    sections.push(dashboard_section(
        localizer,
        "Distribution",
        learning_dashboard_distribution_text(localizer, &report.total),
    ));
    sections.push(dashboard_section(
        localizer,
        "Recall and calibration",
        learning_dashboard_recall_calibration_text(
            localizer,
            &report.total,
            report.calibration_recall_rule,
        ),
    ));
    sections.push(dashboard_section(
        localizer,
        "Workload forecast",
        learning_dashboard_workload_text(
            localizer,
            &report.total,
            report.activity.average_seconds_per_unique_card,
        ),
    ));
    sections.push(dashboard_section(
        localizer,
        "Diagnostics",
        learning_dashboard_diagnostics_text(localizer, &report.total),
    ));
    sections.push(dashboard_section(
        localizer,
        "Study activity",
        learning_dashboard_activity_text(
            localizer,
            &report.activity,
            &report.deck_growth,
            &report.standard.pace,
            report.reverse.as_ref().map(|reverse| &reverse.pace),
        ),
    ));

    sections
}

fn dashboard_section(
    localizer: Localizer,
    heading: &'static str,
    body: String,
) -> LearningDashboardTextSection {
    LearningDashboardTextSection {
        heading: localizer.gettext(heading).to_string(),
        body,
    }
}

fn learning_dashboard_overview_text(
    localizer: Localizer,
    report: &LearningDashboardReport,
) -> String {
    let summary = &report.total;
    let mut lines = vec![
        dashboard_summary_line(localizer, report, &summary.states),
        availability_line(localizer, &summary.states),
        estimated_recall_summary_line(localizer, summary),
        at_risk_summary_line(localizer, summary),
        workload_nearest_line(
            localizer,
            &summary.workload,
            report.activity.average_seconds_per_unique_card,
        ),
    ];
    if let Some(line) = workload_shape_summary_line(localizer, &summary.workload) {
        lines.push(line);
    }
    if let Some(line) =
        workload_pressure_summary_line(localizer, &summary.workload, &report.activity)
    {
        lines.push(line);
    }
    if let Some(line) = average_study_time_summary_line(localizer, &report.activity) {
        lines.push(line);
    }
    if let Some(line) = deck_growth_summary_line(
        localizer,
        &report.deck_growth,
        &report.standard.pace,
        report.reverse.as_ref().map(|reverse| &reverse.pace),
    ) {
        lines.push(line);
    }
    if let Some(line) = study_time_consistency_summary_line(localizer, &report.activity) {
        lines.push(line);
    }
    if let Some(reverse) = &report.reverse
        && let Some(line) = reverse_comparison_summary_line(localizer, &report.standard, reverse)
    {
        lines.push(line);
    }
    lines.push(recent_recall_trend_sentence(
        localizer,
        &summary.recall_trend,
    ));

    lines.join("\n")
}

fn learning_dashboard_standard_reverse_text(
    localizer: Localizer,
    standard: &LearningDashboardSummary,
    reverse: &LearningDashboardSummary,
) -> String {
    let lines = [
        comparison_line(localizer, "Standard average recall", standard),
        comparison_line(localizer, "Reversed average recall", reverse),
        localizer.format_message(
            "Standard at-risk cards: {count}",
            &[(
                "count",
                count_and_percent(localizer, standard.recall.at_risk, standard.states.active),
            )],
        ),
        localizer.format_message(
            "Reversed at-risk cards: {count}",
            &[(
                "count",
                count_and_percent(localizer, reverse.recall.at_risk, reverse.states.active),
            )],
        ),
        localizer.format_message(
            "Standard recent Again rate: {rate}",
            &[(
                "rate",
                count_and_percent(
                    localizer,
                    standard.recall_trend.again_last_7_days,
                    standard.recall_trend.answers_last_7_days,
                ),
            )],
        ),
        localizer.format_message(
            "Reversed recent Again rate: {rate}",
            &[(
                "rate",
                count_and_percent(
                    localizer,
                    reverse.recall_trend.again_last_7_days,
                    reverse.recall_trend.answers_last_7_days,
                ),
            )],
        ),
        reverse_again_trend_sentence(localizer, &reverse.recall_trend),
    ];
    lines.join("\n")
}

fn learning_dashboard_card_state_text(
    localizer: Localizer,
    report: &LearningDashboardReport,
) -> String {
    let summary = &report.total;
    let mut lines = Vec::new();
    lines.push(dashboard_summary_line(localizer, report, &summary.states));
    if let Some(reverse) = &report.reverse {
        if let Some(line) = question_state_line(localizer, "Total cards", &report.total.states) {
            lines.push(line);
        }
        if let Some(line) =
            question_state_line(localizer, "Standard cards", &report.standard.states)
        {
            lines.push(line);
        }
        if let Some(line) = question_state_line(localizer, "Reversed cards", &reverse.states) {
            lines.push(line);
        }
    } else {
        let state_parts = non_zero_card_states(localizer, &summary.states);
        if state_parts.len() == 1 && summary.states.total > 0 {
            lines.push(localizer.format_message(
                "State: {total} in {state} state.",
                &[
                    ("total", summary.states.total.to_string()),
                    ("state", state_parts[0].0.to_string()),
                ],
            ));
        } else if !state_parts.is_empty() {
            lines.push(
                localizer.format_message(
                    "State: {states}.",
                    &[(
                        "states",
                        state_parts
                            .into_iter()
                            .map(|(label, count)| format!("{label}: {count}"))
                            .collect::<Vec<_>>()
                            .join("; "),
                    )],
                ),
            );
        }
    }
    lines.push(availability_line(localizer, &summary.states));
    lines.join("\n")
}

fn question_state_line(
    localizer: Localizer,
    label: &'static str,
    states: &CardStateCounts,
) -> Option<String> {
    let state_parts = non_zero_card_states(localizer, states);
    (!state_parts.is_empty()).then(|| {
        localizer.format_message(
            "{label}: {states}.",
            &[
                ("label", localizer.gettext(label).to_string()),
                (
                    "states",
                    state_parts
                        .into_iter()
                        .map(|(label, count)| format!("{label}: {count}"))
                        .collect::<Vec<_>>()
                        .join("; "),
                ),
            ],
        )
    })
}

fn learning_dashboard_distribution_text(
    localizer: Localizer,
    summary: &LearningDashboardSummary,
) -> String {
    let mut lines = Vec::new();
    if summary.recall.estimated_cards == 0 {
        lines.push(
            localizer
                .gettext("No estimated recall distribution is available yet.")
                .to_string(),
        );
        return lines.join("\n");
    }

    lines.push(localizer.format_message(
        "Average estimated recall: {recall} ({estimated_cards} cards with estimates).",
        &[
            (
                "recall",
                format_optional_percent(localizer, summary.recall.average_recall),
            ),
            (
                "estimated_cards",
                summary.recall.estimated_cards.to_string(),
            ),
        ],
    ));
    lines.push(localizer.format_message(
        "Median estimated recall: {recall}.",
        &[(
            "recall",
            format_optional_percent(localizer, summary.recall.median_recall),
        )],
    ));
    lines.push(localizer.format_message(
        "Weakest estimated cards average (lowest 10%): {recall}.",
        &[(
            "recall",
            format_optional_percent(localizer, summary.recall.weakest_decile_recall),
        )],
    ));
    lines.push(localizer.format_message(
        "Likely known, 90% or higher: {count}.",
        &[(
            "count",
            count_and_percent(
                localizer,
                summary.recall.likely_known,
                summary.recall.estimated_cards,
            ),
        )],
    ));
    lines.push(localizer.format_message(
        "Mostly stable, 70% to 89%: {count}.",
        &[(
            "count",
            count_and_percent(
                localizer,
                summary.recall.mostly_stable,
                summary.recall.estimated_cards,
            ),
        )],
    ));
    lines.push(localizer.format_message(
        "Fragile, 50% to 69%: {count}.",
        &[(
            "count",
            count_and_percent(
                localizer,
                summary.recall.fragile,
                summary.recall.estimated_cards,
            ),
        )],
    ));
    lines.push(localizer.format_message(
        "Very low, below 50%: {count}.",
        &[(
            "count",
            count_and_percent(
                localizer,
                summary.recall.very_low,
                summary.recall.estimated_cards,
            ),
        )],
    ));

    lines.join("\n")
}

fn learning_dashboard_recall_calibration_text(
    localizer: Localizer,
    summary: &LearningDashboardSummary,
    calibration_recall_rule: CalibrationRecallRule,
) -> String {
    let mut lines = Vec::new();
    lines.push(learning_dashboard_recall_trend_text(
        localizer,
        &summary.recall_trend,
    ));
    lines.push(String::new());
    lines.push(localizer.format_message(
        "Recall rule: {rule}",
        &[(
            "rule",
            calibration_recall_rule_description(localizer, calibration_recall_rule).to_string(),
        )],
    ));
    if summary.calibration.answered_with_confidence == 0 {
        lines.push(
            localizer
                .gettext("No confidence calibration data yet.")
                .to_string(),
        );
    } else {
        let target_recall_range = summary
            .calibration
            .target_recall_range()
            .map(|range| format_calibration_target_range(localizer, range))
            .unwrap_or_else(|| localizer.card_details_not_available().to_string());
        let observed_recall_rate = summary
            .calibration
            .observed_recall_rate()
            .map(format_percent)
            .unwrap_or_else(|| localizer.card_details_not_available().to_string());
        let calibration_status = summary
            .calibration
            .calibration_status()
            .map(|status| format_calibration_status(localizer, status))
            .unwrap_or_else(|| localizer.card_details_not_available().to_string());
        lines.push(localizer.format_message(
            "Confidence-rated answers: {answers}.",
            &[(
                "answers",
                summary.calibration.answered_with_confidence.to_string(),
            )],
        ));
        lines.push(localizer.format_message(
            "Target recall range: {target_recall_range}.",
            &[("target_recall_range", target_recall_range)],
        ));
        lines.push(localizer.format_message(
            "Observed recall rate: {observed_recall_rate}.",
            &[("observed_recall_rate", observed_recall_rate)],
        ));
        lines.push(localizer.format_message(
            "Calibration: {calibration_status}.",
            &[("calibration_status", calibration_status)],
        ));
        lines.push(localizer.gettext("By confidence:").to_string());
        for guess in [
            PromptGuess::DontKnow,
            PromptGuess::ProbablyDontKnow,
            PromptGuess::ProbablyKnow,
            PromptGuess::CertainlyKnow,
        ] {
            let line = calibration_band_line(localizer, summary, guess);
            if !line.contains("answers: 0;") {
                lines.push(line);
            }
        }
        if summary.calibration.not_answered > 0 {
            lines.push(localizer.format_message(
                "Revealed without confidence: {count}.",
                &[("count", summary.calibration.not_answered.to_string())],
            ));
        }
    }

    lines.join("\n")
}

fn learning_dashboard_workload_text(
    localizer: Localizer,
    summary: &LearningDashboardSummary,
    average_seconds_per_unique_card: Option<u64>,
) -> String {
    localizer.learning_dashboard_workload_text(
        &workload_count_with_estimate(
            localizer,
            summary.workload.due_now,
            average_seconds_per_unique_card,
        ),
        &workload_count_with_estimate(
            localizer,
            summary.workload.due_within_24_hours,
            average_seconds_per_unique_card,
        ),
        &workload_count_with_estimate(
            localizer,
            summary.workload.due_within_3_days,
            average_seconds_per_unique_card,
        ),
        &workload_count_with_estimate(
            localizer,
            summary.workload.due_within_7_days,
            average_seconds_per_unique_card,
        ),
        &workload_count_with_estimate(
            localizer,
            summary.workload.due_within_30_days,
            average_seconds_per_unique_card,
        ),
    )
}

fn learning_dashboard_diagnostics_text(
    localizer: Localizer,
    summary: &LearningDashboardSummary,
) -> String {
    let mut lines = Vec::new();
    if summary.recall.at_risk == 0 {
        lines.push(
            localizer
                .gettext("No at-risk cards by estimated recall.")
                .to_string(),
        );
    } else {
        lines.push(localizer.format_message(
            "At-risk cards by estimated recall: {count}.",
            &[(
                "count",
                count_and_percent(localizer, summary.recall.at_risk, summary.states.active),
            )],
        ));
    }
    if summary.recall_trend.cards_with_multiple_lapses == 0 {
        lines.push(
            localizer
                .gettext("No active cards have multiple scheduled lapses.")
                .to_string(),
        );
    } else {
        lines.push(localizer.format_message(
            "Active cards with multiple scheduled lapses: {count}.",
            &[(
                "count",
                summary.recall_trend.cards_with_multiple_lapses.to_string(),
            )],
        ));
    }
    if summary.calibration.high_confidence_failures == 0 {
        lines.push(
            localizer
                .gettext("No high-confidence failures recorded.")
                .to_string(),
        );
    } else {
        lines.push(localizer.gettext("High-confidence failures").to_string());
        lines.push(localizer.format_message(
            "Answers: {count}.",
            &[(
                "count",
                diagnostic_count_with_recent(
                    localizer,
                    summary.calibration.high_confidence_failures,
                    summary.calibration.recent_high_confidence_failures,
                ),
            )],
        ));
        lines.push(localizer.format_message(
            "Cards affected: {count}.",
            &[(
                "count",
                diagnostic_count_with_recent(
                    localizer,
                    summary.calibration.high_confidence_failure_cards,
                    summary.calibration.recent_high_confidence_failure_cards,
                ),
            )],
        ));
        lines.push(
            localizer.format_message(
                "Cards with significant recent overconfidence: {count}.",
                &[(
                    "count",
                    summary
                        .calibration
                        .significant_recent_overconfidence_cards
                        .to_string(),
                )],
            ),
        );
    }
    if summary.calibration.low_confidence_successes > 0 {
        lines.push(localizer.gettext("Low-confidence successes").to_string());
        lines.push(localizer.format_message(
            "Answers: {count}.",
            &[(
                "count",
                diagnostic_count_with_recent(
                    localizer,
                    summary.calibration.low_confidence_successes,
                    summary.calibration.recent_low_confidence_successes,
                ),
            )],
        ));
        lines.push(localizer.format_message(
            "Cards affected: {count}.",
            &[(
                "count",
                diagnostic_count_with_recent(
                    localizer,
                    summary.calibration.low_confidence_success_cards,
                    summary.calibration.recent_low_confidence_success_cards,
                ),
            )],
        ));
        lines.push(
            localizer.format_message(
                "Cards with significant recent underconfidence: {count}.",
                &[(
                    "count",
                    summary
                        .calibration
                        .significant_recent_underconfidence_cards
                        .to_string(),
                )],
            ),
        );
    }
    lines.push(recent_learning_retention_sentence(localizer, &summary.pace));
    lines.join("\n")
}

fn diagnostic_count_with_recent(localizer: Localizer, total: usize, recent: usize) -> String {
    let recent_text = if recent == 0 {
        localizer.gettext("none recent").to_string()
    } else if recent == total {
        localizer.gettext("all recent").to_string()
    } else {
        localizer.format_message("{count} recent", &[("count", recent.to_string())])
    };
    localizer.format_message(
        "{total} ({recent})",
        &[("total", total.to_string()), ("recent", recent_text)],
    )
}

fn cards_summary_line(localizer: Localizer, states: &CardStateCounts) -> String {
    if states.suspended == 0 && states.deferred == 0 && states.active == states.total {
        localizer.format_message(
            "Cards: {total} total, all active.",
            &[("total", states.total.to_string())],
        )
    } else if states.deferred == 0 {
        localizer.format_message(
            "Cards: {total} total, {active} active, {suspended} suspended.",
            &[
                ("total", states.total.to_string()),
                ("active", states.active.to_string()),
                ("suspended", states.suspended.to_string()),
            ],
        )
    } else if states.suspended == 0 {
        localizer.format_message(
            "Cards: {total} total, {active} active, {deferred} deferred.",
            &[
                ("total", states.total.to_string()),
                ("active", states.active.to_string()),
                ("deferred", states.deferred.to_string()),
            ],
        )
    } else {
        localizer.format_message(
            "Cards: {total} total, {active} active, {suspended} suspended, {deferred} deferred.",
            &[
                ("total", states.total.to_string()),
                ("active", states.active.to_string()),
                ("suspended", states.suspended.to_string()),
                ("deferred", states.deferred.to_string()),
            ],
        )
    }
}

fn dashboard_summary_line(
    localizer: Localizer,
    report: &LearningDashboardReport,
    states: &CardStateCounts,
) -> String {
    if report.reverse.is_some() {
        total_cards_summary_line(localizer, states)
    } else {
        cards_summary_line(localizer, states)
    }
}

fn total_cards_summary_line(localizer: Localizer, states: &CardStateCounts) -> String {
    if states.suspended == 0 && states.deferred == 0 && states.active == states.total {
        localizer.format_message(
            "Total cards: {total} total, all active.",
            &[("total", states.total.to_string())],
        )
    } else if states.deferred == 0 {
        localizer.format_message(
            "Total cards: {total} total, {active} active, {suspended} suspended.",
            &[
                ("total", states.total.to_string()),
                ("active", states.active.to_string()),
                ("suspended", states.suspended.to_string()),
            ],
        )
    } else if states.suspended == 0 {
        localizer.format_message(
            "Total cards: {total} total, {active} active, {deferred} deferred.",
            &[
                ("total", states.total.to_string()),
                ("active", states.active.to_string()),
                ("deferred", states.deferred.to_string()),
            ],
        )
    } else {
        localizer.format_message(
            "Total cards: {total} total, {active} active, {suspended} suspended, {deferred} deferred.",
            &[
                ("total", states.total.to_string()),
                ("active", states.active.to_string()),
                ("suspended", states.suspended.to_string()),
                ("deferred", states.deferred.to_string()),
            ],
        )
    }
}

fn availability_line(localizer: Localizer, states: &CardStateCounts) -> String {
    if states.due_now == 0 {
        localizer.gettext("Nothing available now.").to_string()
    } else if states.scheduled_due_now == 0 {
        localizer.format_message(
            "Available now: {due_now}.",
            &[("due_now", states.due_now.to_string())],
        )
    } else {
        localizer.format_message(
            "Available now: {due_now}; of these, already scheduled and due now: {scheduled_due}.",
            &[
                ("due_now", states.due_now.to_string()),
                ("scheduled_due", states.scheduled_due_now.to_string()),
            ],
        )
    }
}

fn non_zero_card_states(localizer: Localizer, states: &CardStateCounts) -> Vec<(String, usize)> {
    [
        (
            localizer
                .card_memory_state_label(CardMemoryState::New)
                .to_string(),
            states.new,
        ),
        (
            localizer
                .card_memory_state_label(CardMemoryState::Learning)
                .to_string(),
            states.learning,
        ),
        (
            localizer
                .card_memory_state_label(CardMemoryState::Review)
                .to_string(),
            states.review,
        ),
        (
            localizer
                .card_memory_state_label(CardMemoryState::Relearning)
                .to_string(),
            states.relearning,
        ),
    ]
    .into_iter()
    .filter(|(_, count)| *count > 0)
    .collect()
}

fn estimated_recall_summary_line(
    localizer: Localizer,
    summary: &LearningDashboardSummary,
) -> String {
    if summary.recall.estimated_cards == 0 {
        return localizer
            .gettext("Estimated recall is not available yet.")
            .to_string();
    }
    localizer.format_message(
        "Estimated recall: average {average}, median {median}.",
        &[
            (
                "average",
                format_optional_percent(localizer, summary.recall.average_recall),
            ),
            (
                "median",
                format_optional_percent(localizer, summary.recall.median_recall),
            ),
        ],
    )
}

fn at_risk_summary_line(localizer: Localizer, summary: &LearningDashboardSummary) -> String {
    if summary.recall.estimated_cards == 0 {
        return localizer
            .gettext("No at-risk estimate is available yet.")
            .to_string();
    }
    if summary.recall.at_risk == 0 {
        localizer
            .gettext("No cards are currently at risk by estimated recall.")
            .to_string()
    } else {
        localizer.format_message(
            "At-risk cards: {count}.",
            &[(
                "count",
                count_and_percent(
                    localizer,
                    summary.recall.at_risk,
                    summary.recall.estimated_cards,
                ),
            )],
        )
    }
}

fn workload_nearest_line(
    localizer: Localizer,
    workload: &WorkloadForecastSummary,
    average_seconds_per_unique_card: Option<u64>,
) -> String {
    if workload.due_now > 0 {
        return localizer.format_message(
            "Workload now: {count}.",
            &[(
                "count",
                workload_count_with_estimate(
                    localizer,
                    workload.due_now,
                    average_seconds_per_unique_card,
                ),
            )],
        );
    }
    if workload.due_within_24_hours > 0 {
        return localizer.format_message(
            "Next 24 hours: {count}.",
            &[(
                "count",
                workload_count_with_estimate(
                    localizer,
                    workload.due_within_24_hours,
                    average_seconds_per_unique_card,
                ),
            )],
        );
    }
    if workload.due_within_7_days > 0 {
        return localizer.format_message(
            "Next 7 days: {count}.",
            &[(
                "count",
                workload_count_with_estimate(
                    localizer,
                    workload.due_within_7_days,
                    average_seconds_per_unique_card,
                ),
            )],
        );
    }
    localizer
        .gettext("No workload is forecast in the next 7 days.")
        .to_string()
}

fn workload_shape_summary_line(
    localizer: Localizer,
    workload: &WorkloadForecastSummary,
) -> Option<String> {
    let next_7_days = workload.due_within_7_days;
    if next_7_days < MIN_WORKLOAD_SHAPE_CARDS {
        return None;
    }

    if workload.due_within_24_hours.saturating_mul(2) >= next_7_days {
        Some(
            localizer
                .gettext("Most of the 7-day workload is due within 24 hours.")
                .to_string(),
        )
    } else if workload.due_within_3_days.saturating_mul(4) >= next_7_days.saturating_mul(3) {
        Some(
            localizer
                .gettext("Most of the 7-day workload is due within 3 days.")
                .to_string(),
        )
    } else {
        Some(
            localizer
                .gettext("The 7-day workload is spread across the week.")
                .to_string(),
        )
    }
}

fn workload_pressure_summary_line(
    localizer: Localizer,
    workload: &WorkloadForecastSummary,
    activity: &ReviewActivitySummary,
) -> Option<String> {
    let average_seconds_per_unique_card = activity.average_seconds_per_unique_card?;
    let recent_seconds = activity.review_seconds_last_7_days;
    if workload.due_within_7_days == 0
        || average_seconds_per_unique_card == 0
        || recent_seconds == 0
    {
        return None;
    }

    let forecast_seconds =
        average_seconds_per_unique_card.saturating_mul(workload.due_within_7_days as u64);
    let forecast = localizer.format_review_time(forecast_seconds);
    let recent = localizer.format_review_time(recent_seconds);
    let meaningful_delta = STUDY_TIME_TREND_MIN_SECONDS
        .max((recent_seconds as f64 * STUDY_TIME_TREND_RELATIVE_DELTA).round() as u64);
    let delta = forecast_seconds.abs_diff(recent_seconds);

    if delta < meaningful_delta {
        Some(localizer.format_message(
            "Next 7-day workload estimate is close to recent study time: {forecast}; last 7 days recorded {recent}.",
            &[("forecast", forecast), ("recent", recent)],
        ))
    } else if forecast_seconds > recent_seconds {
        Some(localizer.format_message(
            "Next 7-day workload estimate is higher than recent study time: {forecast}; last 7 days recorded {recent}.",
            &[("forecast", forecast), ("recent", recent)],
        ))
    } else {
        Some(localizer.format_message(
            "Next 7-day workload estimate is lower than recent study time: {forecast}; last 7 days recorded {recent}.",
            &[("forecast", forecast), ("recent", recent)],
        ))
    }
}

fn reverse_comparison_summary_line(
    localizer: Localizer,
    standard: &LearningDashboardSummary,
    reverse: &LearningDashboardSummary,
) -> Option<String> {
    let standard_recall = standard.recall.average_recall?;
    let reverse_recall = reverse.recall.average_recall?;
    let difference = reverse_recall - standard_recall;
    if difference.abs() < 0.05 {
        Some(
            localizer
                .gettext("Standard and reversed cards have similar estimated recall.")
                .to_string(),
        )
    } else if difference < 0.0 {
        Some(localizer.format_message(
            "Reversed cards look weaker by {difference}.",
            &[(
                "difference",
                format_percentage_points(localizer, difference.abs()),
            )],
        ))
    } else {
        Some(localizer.format_message(
            "Reversed cards look stronger by {difference}.",
            &[(
                "difference",
                format_percentage_points(localizer, difference),
            )],
        ))
    }
}

fn comparison_line(
    localizer: Localizer,
    label: &'static str,
    summary: &LearningDashboardSummary,
) -> String {
    localizer.format_message(
        "{label}: {recall} ({estimated_cards} cards with estimates).",
        &[
            ("label", localizer.gettext(label).to_string()),
            (
                "recall",
                format_optional_percent(localizer, summary.recall.average_recall),
            ),
            (
                "estimated_cards",
                summary.recall.estimated_cards.to_string(),
            ),
        ],
    )
}

fn average_study_time_summary_line(
    localizer: Localizer,
    activity: &ReviewActivitySummary,
) -> Option<String> {
    activity
        .average_seconds_per_recorded_session_day
        .map(|seconds| {
            localizer.format_message(
                "Average study time per active study day: {average}.",
                &[("average", localizer.format_review_time(seconds))],
            )
        })
}

fn deck_growth_summary_line(
    localizer: Localizer,
    growth: &DeckGrowthSummary,
    standard_pace: &LearningPaceSummary,
    reverse_pace: Option<&LearningPaceSummary>,
) -> Option<String> {
    if growth.observed_standard_cards == 0 && growth.unstudied_active_standard_cards == 0 {
        return None;
    }
    if growth.tracking_appears_new() {
        return Some(
            localizer
                .gettext("Card first-seen tracking has just started for this review database.")
                .to_string(),
        );
    }

    if let Some(reverse_pace) = reverse_pace {
        Some(localizer.format_message(
            "Deck flow, last 7 days: {first_seen} deck cards first seen; {graduated} total cards reached review state (standard: {standard}; reversed: {reverse}).",
            &[
                ("first_seen", growth.first_seen_last_7_days.to_string()),
                (
                    "graduated",
                    (standard_pace.graduated_last_7_days + reverse_pace.graduated_last_7_days)
                        .to_string(),
                ),
                ("standard", standard_pace.graduated_last_7_days.to_string()),
                ("reverse", reverse_pace.graduated_last_7_days.to_string()),
            ],
        ))
    } else {
        Some(localizer.format_message(
            "Deck flow, last 7 days: {first_seen} deck cards first seen; {graduated} cards reached review state.",
            &[
                ("first_seen", growth.first_seen_last_7_days.to_string()),
                ("graduated", standard_pace.graduated_last_7_days.to_string()),
            ],
        ))
    }
}

fn study_time_consistency_summary_line(
    localizer: Localizer,
    activity: &ReviewActivitySummary,
) -> Option<String> {
    let recent = activity.review_seconds_last_7_days;
    let previous = activity.review_seconds_previous_7_days;
    if recent == 0 && previous == 0 {
        return None;
    }
    if recent > 0 && previous == 0 {
        return Some(
            localizer
                .gettext(
                    "Recorded study time appears in the last 7 days; none was recorded in the previous 7 days.",
                )
                .to_string(),
        );
    }
    if recent == 0 {
        return Some(
            localizer
                .gettext("No recorded study time in the last 7 days.")
                .to_string(),
        );
    }

    let meaningful_delta = STUDY_TIME_TREND_MIN_SECONDS
        .max((previous as f64 * STUDY_TIME_TREND_RELATIVE_DELTA).round() as u64);
    let delta = recent.abs_diff(previous);
    if delta < meaningful_delta {
        Some(
            localizer
                .gettext("Recorded study time is broadly similar to the previous 7 days.")
                .to_string(),
        )
    } else if recent > previous {
        Some(
            localizer
                .gettext("Recorded study time is higher than the previous 7 days.")
                .to_string(),
        )
    } else {
        Some(
            localizer
                .gettext("Recorded study time is lower than the previous 7 days.")
                .to_string(),
        )
    }
}

fn recent_recall_trend_sentence(localizer: Localizer, summary: &RecallTrendSummary) -> String {
    match recall_trend_direction(summary) {
        TrendDirection::Improving => localizer
            .gettext("Recall has improved over the last 7 days.")
            .to_string(),
        TrendDirection::Declining => localizer
            .gettext("Recall has declined over the last 7 days.")
            .to_string(),
        TrendDirection::Stable => localizer
            .gettext(
                "Recent recall is broadly stable (last 7 days are close to the previous 7 days).",
            )
            .to_string(),
        TrendDirection::InsufficientData => localizer
            .gettext("Not enough recent answers to describe a recall trend.")
            .to_string(),
    }
}

fn reverse_again_trend_sentence(localizer: Localizer, summary: &RecallTrendSummary) -> String {
    match again_trend_direction(summary) {
        TrendDirection::Improving => localizer
            .gettext("Reverse-card Again rate is falling.")
            .to_string(),
        TrendDirection::Declining => localizer
            .gettext("Reverse-card Again rate is rising.")
            .to_string(),
        TrendDirection::Stable => localizer
            .gettext("Reverse-card Again rate is broadly stable.")
            .to_string(),
        TrendDirection::InsufficientData => localizer
            .gettext("Not enough reverse-card answers to describe an Again-rate trend.")
            .to_string(),
    }
}

fn recent_learning_retention_sentence(
    localizer: Localizer,
    summary: &LearningPaceSummary,
) -> String {
    if summary.recent_graduated_cards < MIN_TREND_ANSWERS {
        return localizer
            .gettext("Not enough recently learned cards to describe recent retention.")
            .to_string();
    }
    let retained_rate =
        summary.recent_graduated_without_lapse as f64 / summary.recent_graduated_cards as f64;
    if retained_rate >= 0.9 {
        localizer.format_message(
            "Most recent learning has been retained well: {count} retained without later Again answers.",
            &[(
                "count",
                count_and_percent(
                    localizer,
                    summary.recent_graduated_without_lapse,
                    summary.recent_graduated_cards,
                ),
            )],
        )
    } else if retained_rate >= 0.7 {
        localizer.format_message(
            "Most recent learning has been mostly retained: {count} retained without later Again answers.",
            &[(
                "count",
                count_and_percent(
                    localizer,
                    summary.recent_graduated_without_lapse,
                    summary.recent_graduated_cards,
                ),
            )],
        )
    } else {
        localizer.format_message(
            "Recent learned cards are showing follow-up lapses: {count} retained without lapse.",
            &[(
                "count",
                count_and_percent(
                    localizer,
                    summary.recent_graduated_without_lapse,
                    summary.recent_graduated_cards,
                ),
            )],
        )
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TrendDirection {
    Improving,
    Stable,
    Declining,
    InsufficientData,
}

fn recall_trend_direction(summary: &RecallTrendSummary) -> TrendDirection {
    trend_direction(
        recall_rate(summary.recalled_last_7_days, summary.answers_last_7_days),
        summary.answers_last_7_days,
        recall_rate(
            summary.recalled_previous_7_days,
            summary.answers_previous_7_days,
        ),
        summary.answers_previous_7_days,
    )
}

fn again_trend_direction(summary: &RecallTrendSummary) -> TrendDirection {
    match trend_direction(
        recall_rate(summary.again_last_7_days, summary.answers_last_7_days),
        summary.answers_last_7_days,
        recall_rate(
            summary.again_previous_7_days,
            summary.answers_previous_7_days,
        ),
        summary.answers_previous_7_days,
    ) {
        TrendDirection::Improving => TrendDirection::Declining,
        TrendDirection::Declining => TrendDirection::Improving,
        other => other,
    }
}

fn trend_direction(
    recent: Option<f64>,
    recent_count: usize,
    previous: Option<f64>,
    previous_count: usize,
) -> TrendDirection {
    if recent_count < MIN_TREND_ANSWERS || previous_count < MIN_TREND_ANSWERS {
        return TrendDirection::InsufficientData;
    }
    let Some(recent) = recent else {
        return TrendDirection::InsufficientData;
    };
    let Some(previous) = previous else {
        return TrendDirection::InsufficientData;
    };
    let delta = recent - previous;
    if delta >= MEANINGFUL_TREND_DELTA {
        TrendDirection::Improving
    } else if delta <= -MEANINGFUL_TREND_DELTA {
        TrendDirection::Declining
    } else {
        TrendDirection::Stable
    }
}

fn format_optional_percent(localizer: Localizer, value: Option<f64>) -> String {
    value
        .map(format_percent)
        .unwrap_or_else(|| localizer.card_details_not_available().to_string())
}

fn format_percentage_points(localizer: Localizer, value: f64) -> String {
    format!(
        "{:.1} {}",
        value * 100.0,
        localizer.percentage_points_unit()
    )
}

fn count_and_percent(localizer: Localizer, count: usize, total: usize) -> String {
    let percent = if total == 0 {
        "0.0%".to_string()
    } else {
        format!("{:.1}%", count as f64 * 100.0 / total as f64)
    };
    localizer.format_message(
        "{percent} ({count} of {total})",
        &[
            ("percent", percent),
            ("count", count.to_string()),
            ("total", total.to_string()),
        ],
    )
}

fn cards_reaching_review_state_lines(
    localizer: Localizer,
    standard_pace: &LearningPaceSummary,
    reverse_pace: Option<&LearningPaceSummary>,
) -> Vec<String> {
    if let Some(reverse_pace) = reverse_pace {
        vec![
            localizer.format_message(
                "Cards reaching review state, last 7 days: standard: {standard}; reversed: {reverse}; total: {total}.",
                &[
                    ("standard", standard_pace.graduated_last_7_days.to_string()),
                    ("reverse", reverse_pace.graduated_last_7_days.to_string()),
                    (
                        "total",
                        (standard_pace.graduated_last_7_days
                            + reverse_pace.graduated_last_7_days)
                            .to_string(),
                    ),
                ],
            ),
            localizer.format_message(
                "Cards reaching review state, previous 7 days: standard: {standard}; reversed: {reverse}; total: {total}.",
                &[
                    (
                        "standard",
                        standard_pace.graduated_previous_7_days.to_string(),
                    ),
                    ("reverse", reverse_pace.graduated_previous_7_days.to_string()),
                    (
                        "total",
                        (standard_pace.graduated_previous_7_days
                            + reverse_pace.graduated_previous_7_days)
                            .to_string(),
                    ),
                ],
            ),
        ]
    } else {
        vec![localizer.format_message(
            "Cards reaching review state, last 7 days: {recent}; previous 7 days: {previous}.",
            &[
                ("recent", standard_pace.graduated_last_7_days.to_string()),
                (
                    "previous",
                    standard_pace.graduated_previous_7_days.to_string(),
                ),
            ],
        )]
    }
}

fn learning_dashboard_activity_text(
    localizer: Localizer,
    summary: &ReviewActivitySummary,
    growth: &DeckGrowthSummary,
    standard_pace: &LearningPaceSummary,
    reverse_pace: Option<&LearningPaceSummary>,
) -> String {
    if summary.review_events == 0 && growth.observed_standard_cards == 0 {
        return localizer.gettext("No stored answers yet.").to_string();
    }

    let mut lines = Vec::new();
    if summary.review_events == 0 {
        lines.push(localizer.gettext("No stored answers yet.").to_string());
    } else {
        lines.push(localizer.format_message(
            "Stored answers: {count}.",
            &[("count", summary.review_events.to_string())],
        ));
    }
    if growth.observed_standard_cards > 0 {
        lines.push(localizer.format_message(
            "Deck cards first seen by this review database: {count}.",
            &[("count", growth.observed_standard_cards.to_string())],
        ));
        if growth.tracking_appears_new() {
            lines.push(
                localizer
                    .gettext(
                        "All tracked deck cards were first seen in the current 7-day window, so intake trends will become more useful after more time passes.",
                    )
                    .to_string(),
            );
        } else {
            lines.push(localizer.format_message(
                "Deck cards first seen in the last 7 days: {recent}; previous 7 days: {previous}; older: {older}.",
                &[
                    ("recent", growth.first_seen_last_7_days.to_string()),
                    ("previous", growth.first_seen_previous_7_days.to_string()),
                    ("older", growth.first_seen_older.to_string()),
                ],
            ));
            lines.extend(cards_reaching_review_state_lines(
                localizer,
                standard_pace,
                reverse_pace,
            ));
        }
    }
    if growth.unstudied_active_standard_cards > 0 {
        lines.push(localizer.format_message(
            "Unstudied active deck cards: {count}.",
            &[("count", growth.unstudied_active_standard_cards.to_string())],
        ));
    }
    if summary.review_days > 0 {
        let average_reviews_per_review_day = summary
            .average_reviews_per_review_day
            .map(format_decimal)
            .unwrap_or_else(|| localizer.card_details_not_available().to_string());
        lines.push(localizer.format_message(
            "Days with answers: {days}; average answers per active day: {average}.",
            &[
                ("days", summary.review_days.to_string()),
                ("average", average_reviews_per_review_day),
            ],
        ));
    }
    if summary.review_passes > 0 {
        let average_questions_per_pass = summary
            .average_questions_per_pass
            .map(format_decimal)
            .unwrap_or_else(|| localizer.card_details_not_available().to_string());
        let total_review_time = localizer.format_review_time(summary.total_review_seconds);
        let average_time_per_pass = summary
            .average_seconds_per_pass
            .map(|seconds| localizer.format_review_time(seconds))
            .unwrap_or_else(|| localizer.card_details_not_available().to_string());
        let average_time_per_question = summary
            .average_seconds_per_question
            .map(|seconds| localizer.format_review_time(seconds))
            .unwrap_or_else(|| localizer.card_details_not_available().to_string());
        let average_time_per_recorded_session_day = summary
            .average_seconds_per_recorded_session_day
            .map(|seconds| localizer.format_review_time(seconds))
            .unwrap_or_else(|| localizer.card_details_not_available().to_string());
        lines.push(localizer.format_message(
            "Recorded sessions: {count}.",
            &[("count", summary.review_passes.to_string())],
        ));
        lines.push(localizer.format_message(
            "Timed study days: {days}; average study time per active study day: {average}.",
            &[
                ("days", summary.recorded_session_days.to_string()),
                ("average", average_time_per_recorded_session_day),
            ],
        ));
        if summary.questions_answered == summary.unique_cards_reviewed_in_passes {
            lines.push(localizer.format_message(
                "Answers in recorded sessions: {answers}.",
                &[("answers", summary.questions_answered.to_string())],
            ));
        } else {
            lines.push(localizer.format_message(
                "Answers in recorded sessions: {answers}; unique cards studied: {unique_cards}.",
                &[
                    ("answers", summary.questions_answered.to_string()),
                    (
                        "unique_cards",
                        summary.unique_cards_reviewed_in_passes.to_string(),
                    ),
                ],
            ));
        }
        lines.push(localizer.format_message(
            "Average answers per recorded session: {average}.",
            &[("average", average_questions_per_pass)],
        ));
        lines.push(localizer.format_message(
            "Total study time: {total}; average time per recorded session: {average}.",
            &[
                ("total", total_review_time),
                ("average", average_time_per_pass),
            ],
        ));
        if summary.review_passes_last_7_days > 0 || summary.review_passes_previous_7_days > 0 {
            lines.push(localizer.format_message(
                "Recent recorded study time: {recent}; previous 7 days: {previous}.",
                &[
                    (
                        "recent",
                        localizer.format_review_time(summary.review_seconds_last_7_days),
                    ),
                    (
                        "previous",
                        localizer.format_review_time(summary.review_seconds_previous_7_days),
                    ),
                ],
            ));
            lines.push(localizer.format_message(
                "Recent recorded questions: {recent}; previous 7 days: {previous}.",
                &[
                    ("recent", summary.questions_last_7_days.to_string()),
                    ("previous", summary.questions_previous_7_days.to_string()),
                ],
            ));
        }
        lines.push(localizer.format_message(
            "Average time per answer in recorded sessions: {average}.",
            &[("average", average_time_per_question)],
        ));
    }
    let older_answers_without_pass_timing = summary
        .review_events
        .saturating_sub(summary.questions_answered);
    if older_answers_without_pass_timing > 0 {
        lines.push(localizer.format_message(
            "Older answers without session timing: {count}.",
            &[("count", older_answers_without_pass_timing.to_string())],
        ));
    }

    lines.join("\n")
}

fn workload_count_with_estimate(
    localizer: Localizer,
    count: usize,
    average_seconds_per_unique_card: Option<u64>,
) -> String {
    let Some(average_seconds_per_unique_card) = average_seconds_per_unique_card else {
        return count.to_string();
    };
    if count == 0 {
        return count.to_string();
    }

    let estimated_time =
        localizer.format_review_time(average_seconds_per_unique_card.saturating_mul(count as u64));
    localizer.learning_dashboard_forecast_count_with_time(count, &estimated_time)
}

fn learning_dashboard_recall_trend_text(
    localizer: Localizer,
    summary: &RecallTrendSummary,
) -> String {
    let mut lines = vec![
        localizer.format_message(
            "Overall recall (Hard, Good, and Easy): {rate}.",
            &[(
                "rate",
                count_and_percent(localizer, summary.recalled_answers, summary.answers),
            )],
        ),
        localizer.format_message(
            "Again answers: {rate}.",
            &[(
                "rate",
                count_and_percent(localizer, summary.again_answers, summary.answers),
            )],
        ),
        localizer.format_message(
            "Hard answers: {rate}.",
            &[(
                "rate",
                count_and_percent(localizer, summary.hard_answers, summary.answers),
            )],
        ),
        localizer.format_message(
            "Good answers: {rate}.",
            &[(
                "rate",
                count_and_percent(localizer, summary.good_answers, summary.answers),
            )],
        ),
        localizer.format_message(
            "Easy answers: {rate}.",
            &[(
                "rate",
                count_and_percent(localizer, summary.easy_answers, summary.answers),
            )],
        ),
        localizer.format_message(
            "Last 7 days: {rate}.",
            &[(
                "rate",
                count_and_percent(
                    localizer,
                    summary.recalled_last_7_days,
                    summary.answers_last_7_days,
                ),
            )],
        ),
        localizer.format_message(
            "Previous 7 days: {rate}.",
            &[(
                "rate",
                count_and_percent(
                    localizer,
                    summary.recalled_previous_7_days,
                    summary.answers_previous_7_days,
                ),
            )],
        ),
        localizer.format_message(
            "Last 30 days: {rate}.",
            &[(
                "rate",
                count_and_percent(
                    localizer,
                    summary.recalled_last_30_days,
                    summary.answers_last_30_days,
                ),
            )],
        ),
        recent_recall_trend_sentence(localizer, summary),
    ];
    if summary.total_lapses > 0 {
        lines.push(localizer.format_message(
            "Scheduled lapses on active cards: {count}.",
            &[("count", summary.total_lapses.to_string())],
        ));
        lines.push(localizer.format_message(
            "Active cards with lapses: {count}.",
            &[("count", summary.cards_with_lapses.to_string())],
        ));
        if summary.cards_with_multiple_lapses > 0 {
            lines.push(localizer.format_message(
                "Active cards with multiple lapses: {count}.",
                &[("count", summary.cards_with_multiple_lapses.to_string())],
            ));
        }
    }
    lines.join("\n")
}

fn recall_rate(recalled_answers: usize, answers: usize) -> Option<f64> {
    (answers > 0).then_some(recalled_answers as f64 / answers as f64)
}

fn calibration_recall_rule_description(
    localizer: Localizer,
    rule: CalibrationRecallRule,
) -> &'static str {
    match rule {
        CalibrationRecallRule::Lenient => localizer.learning_dashboard_lenient_recall_rule(),
        CalibrationRecallRule::Strict => localizer.learning_dashboard_strict_recall_rule(),
    }
}

fn calibration_band_line(
    localizer: Localizer,
    summary: &LearningDashboardSummary,
    guess: PromptGuess,
) -> String {
    let target = calibration_target_range(guess)
        .map(|range| format_calibration_target_range(localizer, range))
        .unwrap_or_else(|| localizer.card_details_not_available().to_string());
    let bucket = summary.calibration.buckets[calibration_bucket_index(guess).unwrap_or_default()];
    let observed_rate = if bucket.answers > 0 {
        bucket.recalled as f64 / bucket.answers as f64
    } else {
        f64::default()
    };
    let observed = (bucket.answers > 0)
        .then_some(observed_rate)
        .map(format_percent)
        .unwrap_or_else(|| localizer.card_details_not_available().to_string());
    let status = if bucket.answers > 0 {
        calibration_target_range(guess)
            .map(|target| {
                format_calibration_status(localizer, calibration_status_for(observed_rate, target))
            })
            .unwrap_or_else(|| localizer.card_details_not_available().to_string())
    } else {
        localizer.card_details_not_available().to_string()
    };

    localizer.learning_dashboard_calibration_band_line(
        localizer.prompt_guess_button_label(guess),
        &target,
        &observed,
        bucket.answers,
        &status,
    )
}

fn add_calibration_event(
    summary: &mut CalibrationSummary,
    event: &ReviewEvent,
    now_unix_seconds: i64,
    recall_rule: CalibrationRecallRule,
) {
    let prompt_guess = event.review().prompt_guess();
    if prompt_guess == PromptGuess::NotAnswered {
        summary.not_answered += 1;
        return;
    }

    let Some(bucket_index) = calibration_bucket_index(prompt_guess) else {
        return;
    };
    summary.answered_with_confidence += 1;
    if let Some(target_range) = calibration_target_range(prompt_guess) {
        summary.target_recall_lower_total += target_range.lower;
        summary.target_recall_upper_total += target_range.upper;
    }

    let bucket = &mut summary.buckets[bucket_index];
    bucket.answers += 1;
    let is_recalled = |feedback| recall_rule.is_recalled(feedback);
    let recalled = is_recalled(event.review().recall_feedback());
    if recalled {
        summary.recalled_with_confidence += 1;
        bucket.recalled += 1;
    }
    if is_high_confidence_failure(event, &is_recalled) {
        summary.high_confidence_failures += 1;
        if event_is_recent(event, now_unix_seconds) {
            summary.recent_high_confidence_failures += 1;
        }
    }
    if is_low_confidence_success(event, &is_recalled) {
        summary.low_confidence_successes += 1;
        if event_is_recent(event, now_unix_seconds) {
            summary.recent_low_confidence_successes += 1;
        }
    }
}

fn event_is_recent(event: &ReviewEvent, now_unix_seconds: i64) -> bool {
    now_unix_seconds.saturating_sub(event.reviewed_at_unix_seconds()) <= TREND_WINDOW_SECONDS
}

fn calibration_bucket_index(prompt_guess: PromptGuess) -> Option<usize> {
    (prompt_guess != PromptGuess::NotAnswered).then(|| prompt_guess.index() - 1)
}

fn calibration_target_range(prompt_guess: PromptGuess) -> Option<CalibrationTargetRange> {
    match prompt_guess {
        PromptGuess::NotAnswered => None,
        PromptGuess::DontKnow => Some(CalibrationTargetRange {
            lower: 0.0,
            upper: 0.05,
        }),
        PromptGuess::ProbablyDontKnow => Some(CalibrationTargetRange {
            lower: 0.05,
            upper: 0.32,
        }),
        PromptGuess::ProbablyKnow => Some(CalibrationTargetRange {
            lower: 0.68,
            upper: 0.95,
        }),
        PromptGuess::CertainlyKnow => Some(CalibrationTargetRange {
            lower: 0.95,
            upper: 1.0,
        }),
    }
}

fn calibration_status_for(observed: f64, target: CalibrationTargetRange) -> CalibrationStatus {
    if observed < target.lower - CALIBRATION_STATUS_TOLERANCE {
        CalibrationStatus::OverConfident(target.lower - observed)
    } else if observed > target.upper + CALIBRATION_STATUS_TOLERANCE {
        CalibrationStatus::UnderConfident(observed - target.upper)
    } else {
        CalibrationStatus::WithinTarget
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct FirstReviewStateTransition {
    review_count: usize,
    review_index: usize,
    reviewed_at_unix_seconds: i64,
    elapsed_seconds: i64,
}

fn first_review_state_transition(reviews: &[ReviewEvent]) -> Option<FirstReviewStateTransition> {
    let first_reviewed_at = reviews.first()?.reviewed_at_unix_seconds();
    reviews
        .iter()
        .enumerate()
        .find(|(_, review)| review.review().recall_feedback() != RecallFeedback::Again)
        .map(|(index, review)| FirstReviewStateTransition {
            review_count: index + 1,
            review_index: index,
            reviewed_at_unix_seconds: review.reviewed_at_unix_seconds(),
            elapsed_seconds: review
                .reviewed_at_unix_seconds()
                .saturating_sub(first_reviewed_at),
        })
}

fn reviews_after_index(reviews: &[ReviewEvent], index: usize) -> &[ReviewEvent] {
    reviews.get(index.saturating_add(1)..).unwrap_or(&[])
}

fn median(values: &[f64]) -> Option<f64> {
    if values.is_empty() {
        return None;
    }
    let midpoint = values.len() / 2;
    if values.len().is_multiple_of(2) {
        Some((values[midpoint - 1] + values[midpoint]) / 2.0)
    } else {
        Some(values[midpoint])
    }
}

fn weakest_decile_average(values: &[f64]) -> Option<f64> {
    if values.is_empty() {
        return None;
    }
    let count = values.len().div_ceil(10).max(1);
    Some(values.iter().take(count).sum::<f64>() / count as f64)
}

fn format_percent(value: f64) -> String {
    format!("{:.1}%", value * 100.0)
}

fn format_calibration_target_range(localizer: Localizer, range: CalibrationTargetRange) -> String {
    localizer.learning_dashboard_calibration_target_range(
        &format_percent(range.lower),
        &format_percent(range.upper),
    )
}

fn format_calibration_status(localizer: Localizer, status: CalibrationStatus) -> String {
    match status {
        CalibrationStatus::WithinTarget => localizer
            .learning_dashboard_calibration_within_target()
            .to_string(),
        CalibrationStatus::UnderConfident(difference) => localizer
            .learning_dashboard_calibration_under_confident(&format_percentage_points_unsigned(
                localizer, difference,
            )),
        CalibrationStatus::OverConfident(difference) => localizer
            .learning_dashboard_calibration_over_confident(&format_percentage_points_unsigned(
                localizer, difference,
            )),
    }
}

fn format_percentage_points_unsigned(localizer: Localizer, value: f64) -> String {
    format!(
        "{:.1} {}",
        value.abs() * 100.0,
        localizer.percentage_points_unit()
    )
}

fn format_decimal(value: f64) -> String {
    format!("{value:.1}")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::localization::Language;
    use cosimo::{Review, ReviewEvent};
    use proptest::prelude::*;

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

    fn review_event(
        datum: &Datum,
        when: i64,
        guess: PromptGuess,
        feedback: RecallFeedback,
    ) -> ReviewEvent {
        review_event_for_key(DatumKey::for_datum(datum), when, guess, feedback)
    }

    fn review_event_for_key(
        datum_key: DatumKey,
        when: i64,
        guess: PromptGuess,
        feedback: RecallFeedback,
    ) -> ReviewEvent {
        ReviewEvent::new(when, datum_key, when, Review::new(guess, feedback))
    }

    fn stored_schedule(
        datum: &Datum,
        state: CardMemoryState,
        due_at: i64,
        last_reviewed_at: i64,
        feedback: RecallFeedback,
    ) -> CardSchedule {
        stored_schedule_for_key(
            DatumKey::for_datum(datum),
            state,
            due_at,
            last_reviewed_at,
            feedback,
        )
    }

    fn stored_schedule_for_key(
        datum_key: DatumKey,
        state: CardMemoryState,
        due_at: i64,
        last_reviewed_at: i64,
        feedback: RecallFeedback,
    ) -> CardSchedule {
        CardSchedule::from_storage(
            datum_key,
            state,
            due_at,
            5.0,
            4.0,
            1,
            5,
            5 * DAY_IN_SECONDS,
            1,
            0,
            Some(last_reviewed_at),
            Some(feedback),
        )
    }

    fn stored_schedule_with_lapses_for_key(
        datum_key: DatumKey,
        state: CardMemoryState,
        due_at: i64,
        last_reviewed_at: i64,
        feedback: RecallFeedback,
        lapses: u32,
    ) -> CardSchedule {
        CardSchedule::from_storage(
            datum_key,
            state,
            due_at,
            5.0,
            4.0,
            1,
            5,
            5 * DAY_IN_SECONDS,
            1,
            lapses,
            Some(last_reviewed_at),
            Some(feedback),
        )
    }

    #[allow(clippy::too_many_arguments)]
    fn stored_schedule_with_values_for_key(
        datum_key: DatumKey,
        state: CardMemoryState,
        due_at: i64,
        stability_days: f64,
        interval_seconds: i64,
        last_reviewed_at: i64,
        feedback: RecallFeedback,
    ) -> CardSchedule {
        CardSchedule::from_storage(
            datum_key,
            state,
            due_at,
            stability_days,
            4.0,
            0,
            0,
            interval_seconds,
            1,
            0,
            Some(last_reviewed_at),
            Some(feedback),
        )
    }

    #[test]
    fn summarizes_card_states_recall_calibration_and_learning_pace() {
        let now = 2_000_000;
        let new_card = datum("New");
        let review_card = datum("Review");
        let relearning_card = datum("Relearning");
        let suspended_card = datum("Suspended");
        let data = vec![
            new_card.clone(),
            review_card.clone(),
            relearning_card.clone(),
            suspended_card.clone(),
        ];
        let schedules = HashMap::from([
            (
                DatumKey::for_datum(&review_card),
                stored_schedule(
                    &review_card,
                    CardMemoryState::Review,
                    now - 60,
                    now - DAY_IN_SECONDS,
                    RecallFeedback::Good,
                ),
            ),
            (
                DatumKey::for_datum(&relearning_card),
                stored_schedule(
                    &relearning_card,
                    CardMemoryState::Relearning,
                    now + 600,
                    now - 60,
                    RecallFeedback::Again,
                ),
            ),
            (
                DatumKey::for_datum(&suspended_card),
                stored_schedule(
                    &suspended_card,
                    CardMemoryState::Learning,
                    now - 60,
                    now - 60,
                    RecallFeedback::Again,
                ),
            ),
        ]);
        let suspended = HashSet::from([DatumKey::for_datum(&suspended_card)]);
        let reviews_by_key = HashMap::from([
            (
                DatumKey::for_datum(&review_card),
                vec![
                    review_event(
                        &review_card,
                        now - DAY_IN_SECONDS,
                        PromptGuess::ProbablyDontKnow,
                        RecallFeedback::Again,
                    ),
                    review_event(
                        &review_card,
                        now,
                        PromptGuess::ProbablyKnow,
                        RecallFeedback::Good,
                    ),
                ],
            ),
            (
                DatumKey::for_datum(&new_card),
                vec![review_event(
                    &new_card,
                    now,
                    PromptGuess::NotAnswered,
                    RecallFeedback::Easy,
                )],
            ),
        ]);

        let summary = learning_dashboard_summary(
            &data,
            &schedules,
            &suspended,
            &HashSet::new(),
            &reviews_by_key,
            now,
        );

        assert_eq!(summary.states.total, 4);
        assert_eq!(summary.states.active, 3);
        assert_eq!(summary.states.suspended, 1);
        assert_eq!(summary.states.new, 1);
        assert_eq!(summary.states.learning, 1);
        assert_eq!(summary.states.review, 1);
        assert_eq!(summary.states.relearning, 1);
        assert_eq!(summary.states.due_now, 2);
        assert_eq!(summary.states.scheduled_due_now, 1);
        assert_eq!(summary.recall.estimated_cards, 2);
        assert_eq!(summary.calibration.answered_with_confidence, 2);
        assert_eq!(summary.calibration.recalled_with_confidence, 1);
        assert!((summary.calibration.target_recall_lower_total - 0.73).abs() < 1e-12);
        assert!((summary.calibration.target_recall_upper_total - 1.27).abs() < 1e-12);
        let probably_dont_know_bucket =
            calibration_bucket_index(PromptGuess::ProbablyDontKnow).unwrap();
        let probably_know_bucket = calibration_bucket_index(PromptGuess::ProbablyKnow).unwrap();
        assert_eq!(
            summary.calibration.buckets[probably_dont_know_bucket].answers,
            1
        );
        assert_eq!(
            summary.calibration.buckets[probably_dont_know_bucket].recalled,
            0
        );
        assert_eq!(summary.calibration.buckets[probably_know_bucket].answers, 1);
        assert_eq!(
            summary.calibration.buckets[probably_know_bucket].recalled,
            1
        );
        assert_eq!(summary.calibration.not_answered, 1);
        assert_eq!(summary.pace.reviewed_cards, 2);
        assert_eq!(summary.pace.cards_in_review, 1);
        assert_eq!(summary.pace.recent_graduated_cards, 1);
        assert_eq!(summary.pace.recent_graduated_without_lapse, 1);
        assert_eq!(
            summary.pace.average_reviews_to_first_review_state,
            Some(2.0)
        );
        assert_eq!(
            summary.pace.average_seconds_to_first_review_state,
            Some(DAY_IN_SECONDS)
        );
    }

    #[test]
    fn deferred_cards_are_excluded_from_dashboard_availability_and_estimates() {
        let now = 2_000_000;
        let available_card = datum("Available");
        let deferred_card = datum("Deferred");
        let available_key = DatumKey::for_datum(&available_card);
        let deferred_key = DatumKey::for_datum(&deferred_card);
        let data = vec![available_card.clone(), deferred_card.clone()];
        let schedules = HashMap::from([
            (
                available_key.clone(),
                stored_schedule_with_values_for_key(
                    available_key,
                    CardMemoryState::Review,
                    now - 60,
                    0.9,
                    DAY_IN_SECONDS,
                    now - DAY_IN_SECONDS,
                    RecallFeedback::Good,
                ),
            ),
            (
                deferred_key.clone(),
                stored_schedule_with_values_for_key(
                    deferred_key.clone(),
                    CardMemoryState::Review,
                    now - 60,
                    0.9,
                    DAY_IN_SECONDS,
                    now - DAY_IN_SECONDS,
                    RecallFeedback::Good,
                ),
            ),
        ]);
        let deferred = HashSet::from([deferred_key]);

        let summary = learning_dashboard_summary(
            &data,
            &schedules,
            &HashSet::new(),
            &deferred,
            &HashMap::new(),
            now,
        );

        assert_eq!(summary.states.active, 1);
        assert_eq!(summary.states.deferred, 1);
        assert_eq!(summary.states.due_now, 1);
        assert_eq!(summary.states.scheduled_due_now, 1);
        assert_eq!(summary.recall.estimated_cards, 1);
    }

    #[test]
    fn dashboard_availability_counts_due_scheduled_cards_inclusively() {
        let now = 2_000_000;
        let due_now_card = datum("Due now");
        let past_due_card = datum("Past due");
        let data = vec![due_now_card.clone(), past_due_card.clone()];
        let schedules = HashMap::from([
            (
                DatumKey::for_datum(&due_now_card),
                stored_schedule(
                    &due_now_card,
                    CardMemoryState::Review,
                    now,
                    now - DAY_IN_SECONDS,
                    RecallFeedback::Good,
                ),
            ),
            (
                DatumKey::for_datum(&past_due_card),
                stored_schedule(
                    &past_due_card,
                    CardMemoryState::Review,
                    now - 60,
                    now - DAY_IN_SECONDS,
                    RecallFeedback::Good,
                ),
            ),
        ]);

        let summary = learning_dashboard_summary(
            &data,
            &schedules,
            &HashSet::new(),
            &HashSet::new(),
            &HashMap::new(),
            now,
        );
        let line = availability_line(
            Localizer::new(crate::localization::Language::English),
            &summary.states,
        );

        assert_eq!(summary.states.new, 0);
        assert_eq!(summary.states.due_now, 2);
        assert_eq!(summary.states.scheduled_due_now, 2);
        assert_eq!(
            line,
            "Available now: 2; of these, already scheduled and due now: 2."
        );
    }

    #[test]
    fn calibration_bucket_indices_follow_confidence_rating_order() {
        assert_eq!(CALIBRATION_BUCKET_COUNT, PromptGuess::COUNT - 1);
        assert_eq!(calibration_bucket_index(PromptGuess::NotAnswered), None);

        for guess in PromptGuess::ALL
            .into_iter()
            .filter(|guess| *guess != PromptGuess::NotAnswered)
        {
            assert_eq!(calibration_bucket_index(guess), Some(guess.index() - 1));
        }
    }

    #[test]
    fn ignores_review_data_for_cards_not_in_the_current_deck() {
        let now = 2_000_000;
        let live_card = datum("Live");
        let removed_card = datum("Removed");
        let data = vec![live_card.clone()];
        let schedules = HashMap::from([(
            DatumKey::for_datum(&removed_card),
            stored_schedule(
                &removed_card,
                CardMemoryState::Review,
                now - 60,
                now - DAY_IN_SECONDS,
                RecallFeedback::Good,
            ),
        )]);
        let reviews_by_key = HashMap::from([(
            DatumKey::for_datum(&removed_card),
            vec![review_event(
                &removed_card,
                now,
                PromptGuess::CertainlyKnow,
                RecallFeedback::Easy,
            )],
        )]);

        let summary = learning_dashboard_summary(
            &data,
            &schedules,
            &HashSet::new(),
            &HashSet::new(),
            &reviews_by_key,
            now,
        );

        assert_eq!(summary.states.total, 1);
        assert_eq!(summary.states.new, 1);
        assert_eq!(summary.states.review, 0);
        assert_eq!(summary.states.due_now, 1);
        assert_eq!(summary.recall.estimated_cards, 0);
        assert_eq!(summary.calibration.answered_with_confidence, 0);
        assert_eq!(summary.pace.reviewed_cards, 0);
    }

    #[test]
    fn dashboard_workload_respects_new_card_daily_limit_for_unstudied_backlog() {
        let now = 2_000_000;
        let data = (0..5)
            .map(|index| datum(&format!("New {index}")))
            .collect::<Vec<_>>();
        let cards = data
            .iter()
            .map(LearningDashboardCard::standard)
            .collect::<Vec<_>>();
        let schedules = HashMap::new();
        let suspended = HashSet::new();
        let deferred = HashSet::new();
        let reviews_by_key = HashMap::new();
        let first_seen_by_key = HashMap::new();

        let report = learning_dashboard_report_with_calibration_rule(
            LearningDashboardReportInput {
                cards: &cards,
                schedules: &schedules,
                suspended: &suspended,
                deferred: &deferred,
                reviews_by_key: &reviews_by_key,
                review_passes: &[],
                first_seen_by_key: &first_seen_by_key,
                now_unix_seconds: now,
                new_cards_per_day: 2,
            },
            CalibrationRecallRule::default(),
        );

        assert_eq!(report.total.states.new, 5);
        assert_eq!(report.total.states.due_now, 2);
        assert_eq!(report.total.states.scheduled_due_now, 0);
        assert_eq!(report.total.workload.due_now, 2);
        assert_eq!(report.total.workload.due_within_7_days, 2);
        assert_eq!(report.deck_growth.unstudied_active_standard_cards, 5);
    }

    #[test]
    fn dashboard_workload_counts_reverse_sibling_with_parent_daily_introduction() {
        let now = 2_000_000;
        let first = datum("one");
        let second = datum("two");
        let first_key = DatumKey::for_datum(&first);
        let second_key = DatumKey::for_datum(&second);
        let cards = vec![
            LearningDashboardCard::new(first_key.clone(), false),
            LearningDashboardCard::new(DatumKey::for_reverse_of(&first_key), true),
            LearningDashboardCard::new(second_key.clone(), false),
            LearningDashboardCard::new(DatumKey::for_reverse_of(&second_key), true),
        ];
        let schedules = HashMap::new();
        let suspended = HashSet::new();
        let deferred = HashSet::new();
        let reviews_by_key = HashMap::new();
        let first_seen_by_key = HashMap::new();

        let report = learning_dashboard_report_with_calibration_rule(
            LearningDashboardReportInput {
                cards: &cards,
                schedules: &schedules,
                suspended: &suspended,
                deferred: &deferred,
                reviews_by_key: &reviews_by_key,
                review_passes: &[],
                first_seen_by_key: &first_seen_by_key,
                now_unix_seconds: now,
                new_cards_per_day: 1,
            },
            CalibrationRecallRule::default(),
        );

        let reverse = report.reverse.expect("reverse summary");
        assert_eq!(report.total.states.new, 4);
        assert_eq!(report.total.states.due_now, 2);
        assert_eq!(report.standard.states.due_now, 1);
        assert_eq!(reverse.states.due_now, 1);
        assert_eq!(report.total.workload.due_now, 2);
        assert_eq!(report.standard.workload.due_now, 1);
        assert_eq!(reverse.workload.due_now, 1);
    }

    #[test]
    fn dashboard_new_card_limit_uses_rolling_first_review_window() {
        let now = 2_000_000;
        let reviewed = datum("reviewed");
        let first_candidate = datum("first candidate");
        let second_candidate = datum("second candidate");
        let reviewed_key = DatumKey::for_datum(&reviewed);
        let cards = vec![
            LearningDashboardCard::new(reviewed_key.clone(), false),
            LearningDashboardCard::standard(&first_candidate),
            LearningDashboardCard::standard(&second_candidate),
        ];
        let schedules = HashMap::from([(
            reviewed_key.clone(),
            stored_schedule_for_key(
                reviewed_key.clone(),
                CardMemoryState::Review,
                now + 10 * DAY_IN_SECONDS,
                now - NEW_CARD_INTRODUCTION_WINDOW_SECONDS,
                RecallFeedback::Good,
            ),
        )]);
        let suspended = HashSet::new();
        let deferred = HashSet::new();
        let first_seen_by_key = HashMap::new();
        let report_for_first_review = |first_reviewed_at| {
            let reviews_by_key = HashMap::from([(
                reviewed_key.clone(),
                vec![review_event_for_key(
                    reviewed_key.clone(),
                    first_reviewed_at,
                    PromptGuess::ProbablyKnow,
                    RecallFeedback::Good,
                )],
            )]);
            learning_dashboard_report_with_calibration_rule(
                LearningDashboardReportInput {
                    cards: &cards,
                    schedules: &schedules,
                    suspended: &suspended,
                    deferred: &deferred,
                    reviews_by_key: &reviews_by_key,
                    review_passes: &[],
                    first_seen_by_key: &first_seen_by_key,
                    now_unix_seconds: now,
                    new_cards_per_day: 1,
                },
                CalibrationRecallRule::default(),
            )
        };

        let boundary_report = report_for_first_review(now - NEW_CARD_INTRODUCTION_WINDOW_SECONDS);
        let older_report = report_for_first_review(now - NEW_CARD_INTRODUCTION_WINDOW_SECONDS - 1);

        assert_eq!(boundary_report.total.states.due_now, 0);
        assert_eq!(boundary_report.total.workload.due_now, 0);
        assert_eq!(older_report.total.states.due_now, 1);
        assert_eq!(older_report.total.workload.due_now, 1);
    }

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

        #[test]
        fn dashboard_new_card_workload_selects_unintroduced_parents_in_deck_order(
            reverse_flags in prop::collection::vec(any::<bool>(), 0usize..30),
            new_cards_per_day in 0usize..30,
        ) {
            let now = 2_000_000;
            let mut cards = Vec::new();
            for (index, has_reverse) in reverse_flags.iter().enumerate() {
                let parent_key = DatumKey::for_card_id((index + 1) as u64);
                cards.push(LearningDashboardCard::new(parent_key.clone(), false));
                if *has_reverse {
                    cards.push(LearningDashboardCard::new(
                        DatumKey::for_reverse_of(&parent_key),
                        true,
                    ));
                }
            }
            let schedules = HashMap::new();
            let suspended = HashSet::new();
            let deferred = HashSet::new();
            let reviews_by_key = HashMap::new();
            let first_seen_by_key = HashMap::new();

            let report = learning_dashboard_report_with_calibration_rule(
                LearningDashboardReportInput {
                    cards: &cards,
                    schedules: &schedules,
                    suspended: &suspended,
                    deferred: &deferred,
                    reviews_by_key: &reviews_by_key,
                    review_passes: &[],
                    first_seen_by_key: &first_seen_by_key,
                    now_unix_seconds: now,
                    new_cards_per_day,
                },
                CalibrationRecallRule::default(),
            );

            let selected_parent_count = new_cards_per_day.min(reverse_flags.len());
            let expected_total_due = reverse_flags
                .iter()
                .take(selected_parent_count)
                .map(|has_reverse| if *has_reverse { 2 } else { 1 })
                .sum::<usize>();
            let expected_reverse_due = reverse_flags
                .iter()
                .take(selected_parent_count)
                .filter(|has_reverse| **has_reverse)
                .count();

            prop_assert_eq!(report.total.states.due_now, expected_total_due);
            prop_assert_eq!(report.total.workload.due_now, expected_total_due);
            prop_assert_eq!(report.standard.states.due_now, selected_parent_count);
            if let Some(reverse) = report.reverse {
                prop_assert_eq!(reverse.states.due_now, expected_reverse_due);
                prop_assert_eq!(reverse.workload.due_now, expected_reverse_due);
            } else {
                prop_assert_eq!(expected_reverse_due, 0);
            }
            prop_assert_eq!(
                report.deck_growth.unstudied_active_standard_cards,
                reverse_flags.len()
            );
        }
    }

    #[test]
    fn dashboard_reports_workload_forecast_and_recall_trends() {
        let now = 2_000_000;
        let due_card = datum("Due");
        let tomorrow_card = datum("Tomorrow");
        let week_card = datum("Week");
        let month_card = datum("Month");
        let later_card = datum("Later");
        let suspended_card = datum("Suspended");
        let data = vec![
            due_card.clone(),
            tomorrow_card.clone(),
            week_card.clone(),
            month_card.clone(),
            later_card.clone(),
            suspended_card.clone(),
        ];
        let due_key = DatumKey::for_datum(&due_card);
        let tomorrow_key = DatumKey::for_datum(&tomorrow_card);
        let week_key = DatumKey::for_datum(&week_card);
        let month_key = DatumKey::for_datum(&month_card);
        let later_key = DatumKey::for_datum(&later_card);
        let suspended_key = DatumKey::for_datum(&suspended_card);
        let schedules = HashMap::from([
            (
                due_key.clone(),
                stored_schedule_with_lapses_for_key(
                    due_key.clone(),
                    CardMemoryState::Review,
                    now,
                    now - DAY_IN_SECONDS,
                    RecallFeedback::Good,
                    2,
                ),
            ),
            (
                tomorrow_key.clone(),
                stored_schedule_with_lapses_for_key(
                    tomorrow_key.clone(),
                    CardMemoryState::Review,
                    now + DAY_IN_SECONDS,
                    now - DAY_IN_SECONDS,
                    RecallFeedback::Hard,
                    1,
                ),
            ),
            (
                week_key.clone(),
                stored_schedule_with_lapses_for_key(
                    week_key.clone(),
                    CardMemoryState::Review,
                    now + 4 * DAY_IN_SECONDS,
                    now - DAY_IN_SECONDS,
                    RecallFeedback::Easy,
                    0,
                ),
            ),
            (
                month_key.clone(),
                stored_schedule_with_lapses_for_key(
                    month_key.clone(),
                    CardMemoryState::Review,
                    now + 20 * DAY_IN_SECONDS,
                    now - DAY_IN_SECONDS,
                    RecallFeedback::Easy,
                    0,
                ),
            ),
            (
                later_key.clone(),
                stored_schedule_with_lapses_for_key(
                    later_key.clone(),
                    CardMemoryState::Review,
                    now + 60 * DAY_IN_SECONDS,
                    now - DAY_IN_SECONDS,
                    RecallFeedback::Easy,
                    3,
                ),
            ),
            (
                suspended_key.clone(),
                stored_schedule_with_lapses_for_key(
                    suspended_key.clone(),
                    CardMemoryState::Review,
                    now,
                    now - DAY_IN_SECONDS,
                    RecallFeedback::Again,
                    4,
                ),
            ),
        ]);
        let suspended = HashSet::from([suspended_key.clone()]);
        let reviews_by_key = HashMap::from([(
            due_key.clone(),
            vec![
                review_event_for_key(
                    due_key.clone(),
                    now - 2 * DAY_IN_SECONDS,
                    PromptGuess::ProbablyKnow,
                    RecallFeedback::Again,
                ),
                review_event_for_key(
                    due_key.clone(),
                    now - 6 * DAY_IN_SECONDS,
                    PromptGuess::ProbablyKnow,
                    RecallFeedback::Hard,
                ),
                review_event_for_key(
                    due_key.clone(),
                    now - 20 * DAY_IN_SECONDS,
                    PromptGuess::CertainlyKnow,
                    RecallFeedback::Good,
                ),
                review_event_for_key(
                    due_key.clone(),
                    now - 40 * DAY_IN_SECONDS,
                    PromptGuess::CertainlyKnow,
                    RecallFeedback::Easy,
                ),
            ],
        )]);

        let summary = learning_dashboard_summary(
            &data,
            &schedules,
            &suspended,
            &HashSet::new(),
            &reviews_by_key,
            now,
        );
        let text = learning_dashboard_text(
            Localizer::default(),
            &LearningDashboardReport {
                total: summary,
                standard: summary,
                reverse: None,
                activity: ReviewActivitySummary {
                    average_seconds_per_question: Some(120),
                    average_seconds_per_unique_card: Some(180),
                    ..ReviewActivitySummary::default()
                },
                deck_growth: DeckGrowthSummary::default(),
                calibration_recall_rule: CalibrationRecallRule::default(),
            },
        );

        assert_eq!(summary.workload.due_now, 1);
        assert_eq!(summary.workload.due_within_24_hours, 2);
        assert_eq!(summary.workload.due_within_3_days, 2);
        assert_eq!(summary.workload.due_within_7_days, 3);
        assert_eq!(summary.workload.due_within_30_days, 4);
        assert_eq!(summary.recall_trend.answers, 4);
        assert_eq!(summary.recall_trend.recalled_answers, 3);
        assert_eq!(summary.recall_trend.again_answers, 1);
        assert_eq!(summary.recall_trend.hard_answers, 1);
        assert_eq!(summary.recall_trend.good_answers, 1);
        assert_eq!(summary.recall_trend.easy_answers, 1);
        assert_eq!(summary.recall_trend.answers_last_7_days, 2);
        assert_eq!(summary.recall_trend.recalled_last_7_days, 1);
        assert_eq!(summary.recall_trend.answers_last_30_days, 3);
        assert_eq!(summary.recall_trend.recalled_last_30_days, 2);
        assert_eq!(summary.recall_trend.total_lapses, 6);
        assert_eq!(summary.recall_trend.cards_with_lapses, 3);
        assert_eq!(summary.recall_trend.cards_with_multiple_lapses, 2);
        assert!(text.contains("Workload forecast"));
        assert!(text.contains("Due within 7 days: 3"));
        assert!(text.contains("Due within 7 days: 3 (estimated time: 9 minutes)"));
        assert!(text.contains("Overall recall (Hard, Good, and Easy): 75.0% (3 of 4)."));
        assert!(text.contains("Again answers: 25.0% (1 of 4)."));
        assert!(text.contains("Last 7 days: 50.0% (1 of 2)."));
        assert!(text.contains("Scheduled lapses on active cards: 6."));
    }

    #[test]
    fn calibration_compares_target_intervals_and_observed_recall_by_confidence_band() {
        let localizer = Localizer::default();
        let card = datum("Calibration");
        let card_key = DatumKey::for_datum(&card);
        let cards = vec![LearningDashboardCard::new(card_key.clone(), false)];
        let reviews_by_key = HashMap::from([(
            card_key.clone(),
            vec![
                review_event_for_key(
                    card_key.clone(),
                    1,
                    PromptGuess::DontKnow,
                    RecallFeedback::Again,
                ),
                review_event_for_key(
                    card_key.clone(),
                    2,
                    PromptGuess::ProbablyDontKnow,
                    RecallFeedback::Hard,
                ),
                review_event_for_key(
                    card_key.clone(),
                    3,
                    PromptGuess::ProbablyKnow,
                    RecallFeedback::Good,
                ),
                review_event_for_key(
                    card_key.clone(),
                    4,
                    PromptGuess::CertainlyKnow,
                    RecallFeedback::Easy,
                ),
                review_event_for_key(
                    card_key.clone(),
                    5,
                    PromptGuess::NotAnswered,
                    RecallFeedback::Easy,
                ),
            ],
        )]);

        let report = learning_dashboard_report(
            &cards,
            &HashMap::new(),
            &HashSet::new(),
            &reviews_by_key,
            &[],
            10,
        );
        let summary = report.total;
        let text = learning_dashboard_text(localizer, &report);

        assert_eq!(
            report.calibration_recall_rule,
            CalibrationRecallRule::Lenient
        );
        assert_eq!(summary.calibration.answered_with_confidence, 4);
        assert_eq!(summary.calibration.recalled_with_confidence, 3);
        assert!((summary.calibration.target_recall_lower_total - 1.68).abs() < 1e-12);
        assert!((summary.calibration.target_recall_upper_total - 2.32).abs() < 1e-12);
        let target_range = summary.calibration.target_recall_range().unwrap();
        assert!((target_range.lower - 0.42).abs() < 1e-12);
        assert!((target_range.upper - 0.58).abs() < 1e-12);
        assert_eq!(summary.calibration.observed_recall_rate(), Some(0.75));
        let Some(CalibrationStatus::UnderConfident(difference)) =
            summary.calibration.calibration_status()
        else {
            panic!("expected under-confident calibration status");
        };
        assert!((difference - 0.17).abs() < 1e-12);
        assert_eq!(summary.calibration.not_answered, 1);
        assert!(text.contains("Recall rule: Lenient: Hard, Good, and Easy count as recalled"));
        assert!(text.contains("Target recall range: 42.0% to 58.0%"));
        assert!(text.contains("Observed recall rate: 75.0%"));
        assert!(text.contains("Calibration: under-confident by 17.0 percentage points"));
        assert!(text.contains(
            "1. Don't know: target 0.0% to 5.0%; observed 0.0%; answers: 1; within target"
        ));
        assert!(text.contains(
            "2. Probably don't know: target 5.0% to 32.0%; observed 100.0%; answers: 1; under-confident by 68.0 percentage points"
        ));
        assert!(text.contains(
            "3. Probably know: target 68.0% to 95.0%; observed 100.0%; answers: 1; under-confident by 5.0 percentage points"
        ));
        assert!(text.contains(
            "4. Certainly know: target 95.0% to 100.0%; observed 100.0%; answers: 1; within target"
        ));
        assert!(text.contains("Revealed without confidence: 1"));
    }

    #[test]
    fn calibration_status_treats_unreportably_small_boundary_differences_as_target() {
        let target = CalibrationTargetRange {
            lower: 0.559,
            upper: 0.725,
        };

        assert_eq!(
            calibration_status_for(0.7254, target),
            CalibrationStatus::WithinTarget
        );
        assert_eq!(
            calibration_status_for(0.5586, target),
            CalibrationStatus::WithinTarget
        );
        assert!(matches!(
            calibration_status_for(0.7256, target),
            CalibrationStatus::UnderConfident(difference)
                if (difference - 0.0006).abs() < 1e-12
        ));
        assert!(matches!(
            calibration_status_for(0.5584, target),
            CalibrationStatus::OverConfident(difference)
                if (difference - 0.0006).abs() < 1e-12
        ));
    }

    #[test]
    fn dashboard_confidence_diagnostics_show_total_and_recent_counts() {
        let localizer = Localizer::default();
        let now = 2_000_000;
        let old = now - TREND_WINDOW_SECONDS - 1;
        let card = datum("Calibration diagnostics");
        let card_key = DatumKey::for_datum(&card);
        let cards = vec![LearningDashboardCard::new(card_key.clone(), false)];
        let reviews_by_key = HashMap::from([(
            card_key.clone(),
            vec![
                review_event_for_key(
                    card_key.clone(),
                    old,
                    PromptGuess::ProbablyKnow,
                    RecallFeedback::Again,
                ),
                review_event_for_key(
                    card_key.clone(),
                    now,
                    PromptGuess::CertainlyKnow,
                    RecallFeedback::Again,
                ),
                review_event_for_key(
                    card_key.clone(),
                    now,
                    PromptGuess::DontKnow,
                    RecallFeedback::Good,
                ),
                review_event_for_key(
                    card_key,
                    now,
                    PromptGuess::ProbablyDontKnow,
                    RecallFeedback::Hard,
                ),
            ],
        )]);

        let report = learning_dashboard_report(
            &cards,
            &HashMap::new(),
            &HashSet::new(),
            &reviews_by_key,
            &[],
            now,
        );
        let summary = report.total.calibration;
        let text = learning_dashboard_text(localizer, &report);

        assert_eq!(summary.high_confidence_failures, 2);
        assert_eq!(summary.recent_high_confidence_failures, 1);
        assert_eq!(summary.high_confidence_failure_cards, 1);
        assert_eq!(summary.recent_high_confidence_failure_cards, 1);
        assert_eq!(summary.significant_recent_overconfidence_cards, 1);
        assert_eq!(summary.low_confidence_successes, 2);
        assert_eq!(summary.recent_low_confidence_successes, 2);
        assert_eq!(summary.low_confidence_success_cards, 1);
        assert_eq!(summary.recent_low_confidence_success_cards, 1);
        assert_eq!(summary.significant_recent_underconfidence_cards, 1);
        assert!(text.contains("High-confidence failures\nAnswers: 2 (1 recent)."));
        assert!(text.contains("Cards affected: 1 (all recent)."));
        assert!(text.contains("Cards with significant recent overconfidence: 1."));
        assert!(text.contains("Low-confidence successes\nAnswers: 2 (all recent)."));
        assert!(text.contains("Cards with significant recent underconfidence: 1."));
        assert_eq!(
            diagnostic_count_with_recent(localizer, 3, 0),
            "3 (none recent)"
        );
    }

    #[test]
    fn strict_calibration_rule_counts_only_good_and_easy_as_recalled() {
        let card = datum("Strict calibration");
        let card_key = DatumKey::for_datum(&card);
        let cards = vec![LearningDashboardCard::new(card_key.clone(), false)];
        let reviews_by_key = HashMap::from([(
            card_key.clone(),
            vec![
                review_event_for_key(
                    card_key.clone(),
                    1,
                    PromptGuess::ProbablyDontKnow,
                    RecallFeedback::Hard,
                ),
                review_event_for_key(
                    card_key.clone(),
                    2,
                    PromptGuess::ProbablyKnow,
                    RecallFeedback::Hard,
                ),
            ],
        )]);

        let schedules = HashMap::new();
        let suspended = HashSet::new();
        let deferred = HashSet::new();
        let first_seen_by_key = HashMap::new();
        let report = learning_dashboard_report_with_calibration_rule(
            LearningDashboardReportInput {
                cards: &cards,
                schedules: &schedules,
                suspended: &suspended,
                deferred: &deferred,
                reviews_by_key: &reviews_by_key,
                review_passes: &[],
                first_seen_by_key: &first_seen_by_key,
                now_unix_seconds: 10,
                new_cards_per_day: usize::MAX,
            },
            CalibrationRecallRule::Strict,
        );

        assert_eq!(
            report.calibration_recall_rule,
            CalibrationRecallRule::Strict
        );
        assert_eq!(report.total.calibration.answered_with_confidence, 2);
        assert_eq!(report.total.calibration.recalled_with_confidence, 0);
        assert_eq!(report.total.calibration.high_confidence_failures, 1);
        assert_eq!(report.total.calibration.low_confidence_successes, 0);
        let bucket = calibration_bucket_index(PromptGuess::ProbablyDontKnow).unwrap();
        assert_eq!(report.total.calibration.buckets[bucket].answers, 1);
        assert_eq!(report.total.calibration.buckets[bucket].recalled, 0);
    }

    #[test]
    fn estimates_recall_with_fractional_elapsed_days() {
        let now = 2_000_000;
        let card = datum("Reviewed recently");
        let schedules = HashMap::from([(
            DatumKey::for_datum(&card),
            stored_schedule_with_values_for_key(
                DatumKey::for_datum(&card),
                CardMemoryState::Review,
                now + DAY_IN_SECONDS / 2,
                1.0,
                DAY_IN_SECONDS,
                now - DAY_IN_SECONDS / 2,
                RecallFeedback::Good,
            ),
        )]);

        let summary = learning_dashboard_summary(
            &[card],
            &schedules,
            &HashSet::new(),
            &HashSet::new(),
            &HashMap::new(),
            now,
        );

        let average_recall = summary.recall.average_recall.expect("recall estimate");
        assert_eq!(summary.recall.estimated_cards, 1);
        assert!(average_recall > 0.94);
        assert!(average_recall < 0.95);
    }

    #[test]
    fn short_learning_intervals_lower_recall_estimate_when_due() {
        let now = 2_000_000;
        let card = datum("Failed recently");
        let ten_minutes = 10 * 60;
        let schedules = HashMap::from([(
            DatumKey::for_datum(&card),
            stored_schedule_with_values_for_key(
                DatumKey::for_datum(&card),
                CardMemoryState::Relearning,
                now,
                0.4,
                ten_minutes,
                now - ten_minutes,
                RecallFeedback::Again,
            ),
        )]);

        let summary = learning_dashboard_summary(
            &[card],
            &schedules,
            &HashSet::new(),
            &HashSet::new(),
            &HashMap::new(),
            now,
        );

        let average_recall = summary.recall.average_recall.expect("recall estimate");
        assert_eq!(summary.recall.estimated_cards, 1);
        assert!(average_recall > 0.89);
        assert!(average_recall < 0.91);
    }

    #[test]
    fn report_splits_standard_reverse_and_total_when_reverse_cards_exist() {
        let now = 2_000_000;
        let standard_card = datum("one");
        let standard_key = DatumKey::for_datum(&standard_card);
        let reverse_key = DatumKey::for_reverse_of(&standard_key);
        let cards = vec![
            LearningDashboardCard::new(standard_key.clone(), false),
            LearningDashboardCard::new(reverse_key.clone(), true),
        ];
        let schedules = HashMap::from([(
            reverse_key.clone(),
            stored_schedule_for_key(
                reverse_key.clone(),
                CardMemoryState::Review,
                now - 60,
                now - DAY_IN_SECONDS,
                RecallFeedback::Good,
            ),
        )]);
        let reviews_by_key = HashMap::from([(
            reverse_key.clone(),
            vec![review_event_for_key(
                reverse_key.clone(),
                now,
                PromptGuess::ProbablyKnow,
                RecallFeedback::Good,
            )],
        )]);

        let report = learning_dashboard_report(
            &cards,
            &schedules,
            &HashSet::new(),
            &reviews_by_key,
            &[],
            now,
        );

        let reverse = report.reverse.expect("reverse summary");
        assert_eq!(report.standard.states.total, 1);
        assert_eq!(report.standard.states.new, 1);
        assert_eq!(report.standard.pace.reviewed_cards, 0);
        assert_eq!(reverse.states.total, 1);
        assert_eq!(reverse.states.review, 1);
        assert_eq!(reverse.pace.reviewed_cards, 1);
        assert_eq!(report.total.states.total, 2);
        assert_eq!(report.total.states.review, 1);
        assert_eq!(report.total.pace.reviewed_cards, 1);
    }

    #[test]
    fn dashboard_activity_summarizes_review_days_questions_and_time() {
        let localizer = Localizer::default();
        let first_card = datum("one");
        let second_card = datum("two");
        let first_key = DatumKey::for_datum(&first_card);
        let second_key = DatumKey::for_datum(&second_card);
        let cards = vec![
            LearningDashboardCard::new(first_key.clone(), false),
            LearningDashboardCard::new(second_key.clone(), false),
        ];
        let reviews_by_key = HashMap::from([
            (
                first_key,
                vec![
                    review_event(
                        &first_card,
                        2 * DAY_IN_SECONDS,
                        PromptGuess::NotAnswered,
                        RecallFeedback::Good,
                    ),
                    review_event(
                        &first_card,
                        2 * DAY_IN_SECONDS + 600,
                        PromptGuess::ProbablyKnow,
                        RecallFeedback::Good,
                    ),
                ],
            ),
            (
                second_key,
                vec![review_event(
                    &second_card,
                    3 * DAY_IN_SECONDS,
                    PromptGuess::ProbablyKnow,
                    RecallFeedback::Easy,
                )],
            ),
        ]);
        let review_passes = vec![
            ReviewPassSummary::new(
                2 * DAY_IN_SECONDS,
                2 * DAY_IN_SECONDS + 120,
                true,
                false,
                2,
                2,
                2,
            ),
            ReviewPassSummary::new(
                3 * DAY_IN_SECONDS,
                3 * DAY_IN_SECONDS + 60,
                true,
                false,
                1,
                1,
                1,
            ),
        ];

        let report = learning_dashboard_report(
            &cards,
            &HashMap::new(),
            &HashSet::new(),
            &reviews_by_key,
            &review_passes,
            4 * DAY_IN_SECONDS,
        );
        let text = learning_dashboard_text(localizer, &report);

        assert_eq!(report.activity.review_events, 3);
        assert_eq!(report.activity.review_days, 2);
        assert_eq!(report.activity.review_passes, 2);
        assert_eq!(report.activity.review_passes_last_7_days, 2);
        assert_eq!(report.activity.review_passes_previous_7_days, 0);
        assert_eq!(report.activity.recorded_session_days, 2);
        assert_eq!(report.activity.questions_answered, 3);
        assert_eq!(report.activity.questions_last_7_days, 3);
        assert_eq!(report.activity.questions_previous_7_days, 0);
        assert_eq!(report.activity.total_review_seconds, 180);
        assert_eq!(report.activity.review_seconds_last_7_days, 180);
        assert_eq!(report.activity.review_seconds_previous_7_days, 0);
        assert_eq!(
            report.activity.average_seconds_per_recorded_session_day,
            Some(90)
        );
        assert_eq!(report.activity.average_seconds_per_unique_card, Some(60));
        assert!(text.contains("Study activity"));
        assert!(text.contains("Stored answers: 3."));
        assert!(text.contains("Days with answers: 2; average answers per active day: 1.5."));
        assert!(text.contains("Recorded sessions: 2."));
        assert!(text.contains("Average study time per active study day: 1 minute, 30 seconds."));
        assert!(text.contains(
            "Timed study days: 2; average study time per active study day: 1 minute, 30 seconds."
        ));
        assert!(text.contains("Answers in recorded sessions: 3."));
        assert!(!text.contains("unique cards studied: 3."));
        assert!(text.contains("Average answers per recorded session: 1.5."));
        assert!(text.contains(
            "Total study time: 3 minutes; average time per recorded session: 1 minute, 30 seconds."
        ));
        assert!(
            text.contains("Recent recorded study time: 3 minutes; previous 7 days: 0 seconds.")
        );
        assert!(text.contains("Recent recorded questions: 3; previous 7 days: 0."));
        assert!(text.contains("Average time per answer in recorded sessions: 1 minute."));
        assert!(!text.contains("Older answers without session timing"));
    }

    #[test]
    fn dashboard_activity_keeps_unique_card_count_when_recorded_sessions_include_repeats() {
        let localizer = Localizer::default();
        let card = datum("one");
        let key = DatumKey::for_datum(&card);
        let cards = vec![LearningDashboardCard::new(key.clone(), false)];
        let reviews_by_key = HashMap::from([(
            key,
            vec![
                review_event(
                    &card,
                    2 * DAY_IN_SECONDS,
                    PromptGuess::ProbablyKnow,
                    RecallFeedback::Again,
                ),
                review_event(
                    &card,
                    2 * DAY_IN_SECONDS + 60,
                    PromptGuess::ProbablyKnow,
                    RecallFeedback::Good,
                ),
            ],
        )]);
        let review_passes = vec![ReviewPassSummary::new(
            2 * DAY_IN_SECONDS,
            2 * DAY_IN_SECONDS + 120,
            true,
            false,
            1,
            2,
            1,
        )];

        let report = learning_dashboard_report(
            &cards,
            &HashMap::new(),
            &HashSet::new(),
            &reviews_by_key,
            &review_passes,
            4 * DAY_IN_SECONDS,
        );
        let text = learning_dashboard_text(localizer, &report);

        assert!(text.contains("Answers in recorded sessions: 2; unique cards studied: 1."));
    }

    #[test]
    fn dashboard_reports_deck_first_seen_flow_without_counting_reverse_cards() {
        let localizer = Localizer::default();
        let now = 30 * DAY_IN_SECONDS;
        let recent_card = datum("recent");
        let previous_card = datum("previous");
        let unstudied_card = datum("unstudied");
        let recent_key = DatumKey::for_datum(&recent_card);
        let previous_key = DatumKey::for_datum(&previous_card);
        let unstudied_key = DatumKey::for_datum(&unstudied_card);
        let reverse_key = DatumKey::for_reverse_of(&recent_key);
        let cards = vec![
            LearningDashboardCard::new(recent_key.clone(), false),
            LearningDashboardCard::new(previous_key.clone(), false),
            LearningDashboardCard::new(unstudied_key.clone(), false),
            LearningDashboardCard::new(reverse_key.clone(), true),
        ];
        let schedules = HashMap::from([
            (
                recent_key.clone(),
                stored_schedule_for_key(
                    recent_key.clone(),
                    CardMemoryState::Review,
                    now + DAY_IN_SECONDS,
                    now - 3 * DAY_IN_SECONDS,
                    RecallFeedback::Good,
                ),
            ),
            (
                previous_key.clone(),
                stored_schedule_for_key(
                    previous_key.clone(),
                    CardMemoryState::Review,
                    now + DAY_IN_SECONDS,
                    now - 10 * DAY_IN_SECONDS,
                    RecallFeedback::Good,
                ),
            ),
        ]);
        let reviews_by_key = HashMap::from([
            (
                recent_key.clone(),
                vec![review_event_for_key(
                    recent_key.clone(),
                    now - 3 * DAY_IN_SECONDS,
                    PromptGuess::ProbablyKnow,
                    RecallFeedback::Good,
                )],
            ),
            (
                previous_key.clone(),
                vec![review_event_for_key(
                    previous_key.clone(),
                    now - 10 * DAY_IN_SECONDS,
                    PromptGuess::ProbablyKnow,
                    RecallFeedback::Good,
                )],
            ),
        ]);
        let first_seen_by_key = HashMap::from([
            (recent_key, now - 2 * DAY_IN_SECONDS),
            (previous_key, now - 9 * DAY_IN_SECONDS),
            (unstudied_key, now - 20 * DAY_IN_SECONDS),
            (reverse_key, now - DAY_IN_SECONDS),
        ]);
        let suspended = HashSet::new();
        let deferred = HashSet::new();

        let report = learning_dashboard_report_with_calibration_rule(
            LearningDashboardReportInput {
                cards: &cards,
                schedules: &schedules,
                suspended: &suspended,
                deferred: &deferred,
                reviews_by_key: &reviews_by_key,
                review_passes: &[],
                first_seen_by_key: &first_seen_by_key,
                now_unix_seconds: now,
                new_cards_per_day: usize::MAX,
            },
            CalibrationRecallRule::default(),
        );
        let text = learning_dashboard_text(localizer, &report);

        assert_eq!(report.deck_growth.observed_standard_cards, 3);
        assert_eq!(report.deck_growth.first_seen_last_7_days, 1);
        assert_eq!(report.deck_growth.first_seen_previous_7_days, 1);
        assert_eq!(report.deck_growth.first_seen_older, 1);
        assert_eq!(report.deck_growth.unstudied_active_standard_cards, 1);
        assert_eq!(report.standard.pace.graduated_last_7_days, 1);
        assert_eq!(report.standard.pace.graduated_previous_7_days, 1);
        assert!(text.contains("Total cards: 4 total, all active."));
        assert!(text.contains("Total cards: new: 2; review: 2."));
        assert!(text.contains("Standard cards: new: 1; review: 2."));
        assert!(text.contains("Reversed cards: new: 1."));
        assert!(text.contains(
            "Deck flow, last 7 days: 1 deck cards first seen; 1 total cards reached review state (standard: 1; reversed: 0)."
        ));
        assert!(text.contains(
            "Deck cards first seen in the last 7 days: 1; previous 7 days: 1; older: 1."
        ));
        assert!(text.contains(
            "Cards reaching review state, last 7 days: standard: 1; reversed: 0; total: 1."
        ));
        assert!(text.contains(
            "Cards reaching review state, previous 7 days: standard: 1; reversed: 0; total: 1."
        ));
        assert!(text.contains("Unstudied active deck cards: 1."));
    }

    #[test]
    fn dashboard_activity_shows_older_answers_without_pass_timing_only_when_needed() {
        let localizer = Localizer::default();
        let card = datum("one");
        let key = DatumKey::for_datum(&card);
        let cards = vec![LearningDashboardCard::new(key.clone(), false)];
        let reviews_by_key = HashMap::from([(
            key,
            vec![
                review_event(
                    &card,
                    DAY_IN_SECONDS,
                    PromptGuess::ProbablyKnow,
                    RecallFeedback::Good,
                ),
                review_event(
                    &card,
                    2 * DAY_IN_SECONDS,
                    PromptGuess::ProbablyKnow,
                    RecallFeedback::Good,
                ),
                review_event(
                    &card,
                    3 * DAY_IN_SECONDS,
                    PromptGuess::ProbablyKnow,
                    RecallFeedback::Good,
                ),
            ],
        )]);
        let review_passes = vec![ReviewPassSummary::new(
            3 * DAY_IN_SECONDS,
            3 * DAY_IN_SECONDS + 30,
            true,
            false,
            1,
            1,
            1,
        )];

        let report = learning_dashboard_report(
            &cards,
            &HashMap::new(),
            &HashSet::new(),
            &reviews_by_key,
            &review_passes,
            4 * DAY_IN_SECONDS,
        );
        let text = learning_dashboard_text(localizer, &report);

        assert_eq!(report.activity.review_events, 3);
        assert_eq!(report.activity.questions_answered, 1);
        assert!(text.contains("Older answers without session timing: 2"));
    }

    #[test]
    fn dashboard_text_splits_only_when_reverse_cards_exist() {
        let localizer = Localizer::default();
        let plain_report = LearningDashboardReport {
            total: LearningDashboardSummary::default(),
            standard: LearningDashboardSummary::default(),
            reverse: None,
            activity: ReviewActivitySummary::default(),
            deck_growth: DeckGrowthSummary::default(),
            calibration_recall_rule: CalibrationRecallRule::default(),
        };
        let split_report = LearningDashboardReport {
            total: LearningDashboardSummary::default(),
            standard: LearningDashboardSummary::default(),
            reverse: Some(LearningDashboardSummary::default()),
            activity: ReviewActivitySummary::default(),
            deck_growth: DeckGrowthSummary::default(),
            calibration_recall_rule: CalibrationRecallRule::default(),
        };

        let plain_text = learning_dashboard_text(localizer, &plain_report);
        let split_text = learning_dashboard_text(localizer, &split_report);

        assert!(!plain_text.contains("Standard and reversed cards"));
        assert!(split_text.contains("Standard and reversed cards"));
        assert!(split_text.contains("Standard average recall"));
        assert!(split_text.contains("Reversed average recall"));
    }

    #[test]
    fn dashboard_sections_are_separate_but_markdown_report_is_complete() {
        let localizer = Localizer::default();
        let report = LearningDashboardReport {
            total: LearningDashboardSummary::default(),
            standard: LearningDashboardSummary::default(),
            reverse: Some(LearningDashboardSummary::default()),
            activity: ReviewActivitySummary::default(),
            deck_growth: DeckGrowthSummary::default(),
            calibration_recall_rule: CalibrationRecallRule::default(),
        };

        let sections = learning_dashboard_sections(localizer, &report);
        let markdown = learning_dashboard_markdown_text(&sections);

        assert!(sections.len() > 1);
        assert_eq!(sections[0].heading(), "Summary");
        assert!(!sections[0].body().contains("## Summary"));
        assert!(markdown.contains("## Summary\n\n"));
        assert!(markdown.contains("## Standard and reversed cards\n\n"));
        assert!(markdown.contains("## Study activity\n\n"));
    }

    #[test]
    fn dashboard_summary_adds_workload_and_activity_quality_lines() {
        let report = LearningDashboardReport {
            total: LearningDashboardSummary {
                states: CardStateCounts {
                    total: 20,
                    active: 20,
                    review: 20,
                    ..CardStateCounts::default()
                },
                workload: WorkloadForecastSummary {
                    due_within_24_hours: 8,
                    due_within_3_days: 8,
                    due_within_7_days: 10,
                    due_within_30_days: 12,
                    ..WorkloadForecastSummary::default()
                },
                recall: RecallEstimate {
                    estimated_cards: 20,
                    average_recall: Some(0.82),
                    median_recall: Some(0.84),
                    likely_known: 5,
                    mostly_stable: 12,
                    fragile: 3,
                    at_risk: 3,
                    ..RecallEstimate::default()
                },
                ..LearningDashboardSummary::default()
            },
            standard: LearningDashboardSummary::default(),
            reverse: None,
            activity: ReviewActivitySummary {
                review_passes_last_7_days: 2,
                review_passes_previous_7_days: 2,
                review_seconds_last_7_days: 1_200,
                review_seconds_previous_7_days: 600,
                average_seconds_per_unique_card: Some(60),
                average_seconds_per_recorded_session_day: Some(300),
                ..ReviewActivitySummary::default()
            },
            deck_growth: DeckGrowthSummary::default(),
            calibration_recall_rule: CalibrationRecallRule::default(),
        };

        let sections = learning_dashboard_sections(Localizer::default(), &report);
        let summary = sections[0].body();

        assert!(summary.contains("Most of the 7-day workload is due within 24 hours."));
        assert!(
            summary.contains(
                "Next 7-day workload estimate is lower than recent study time: 10 minutes; last 7 days recorded 20 minutes."
            )
        );
        assert!(summary.contains("Recorded study time is higher than the previous 7 days."));
    }

    #[test]
    fn dashboard_redesign_copy_is_localized_for_canonical_languages() {
        let report = LearningDashboardReport {
            total: LearningDashboardSummary {
                states: CardStateCounts {
                    total: 2,
                    active: 2,
                    review: 2,
                    ..CardStateCounts::default()
                },
                recall: RecallEstimate {
                    estimated_cards: 2,
                    average_recall: Some(0.75),
                    median_recall: Some(0.75),
                    mostly_stable: 2,
                    ..RecallEstimate::default()
                },
                workload: WorkloadForecastSummary {
                    due_within_24_hours: 8,
                    due_within_3_days: 8,
                    due_within_7_days: 10,
                    due_within_30_days: 12,
                    ..WorkloadForecastSummary::default()
                },
                ..LearningDashboardSummary::default()
            },
            standard: LearningDashboardSummary::default(),
            reverse: Some(LearningDashboardSummary::default()),
            activity: ReviewActivitySummary {
                review_passes_last_7_days: 2,
                review_passes_previous_7_days: 2,
                review_seconds_last_7_days: 1_200,
                review_seconds_previous_7_days: 600,
                average_seconds_per_unique_card: Some(60),
                average_seconds_per_recorded_session_day: Some(300),
                ..ReviewActivitySummary::default()
            },
            deck_growth: DeckGrowthSummary::default(),
            calibration_recall_rule: CalibrationRecallRule::default(),
        };

        let spanish_text = learning_dashboard_text(Localizer::new(Language::Spanish), &report);
        let galician_text = learning_dashboard_text(Localizer::new(Language::Galician), &report);

        assert!(spanish_text.contains("Cartas estándar e inversas"));
        assert!(spanish_text.contains("Recuerdo medio estimado"));
        assert!(spanish_text.contains("La estimación de carga de los próximos 7 días es menor"));
        assert!(spanish_text.contains("El tiempo de estudio registrado es mayor"));
        assert!(spanish_text.contains("0.0% (0 de 0)"));
        assert!(!spanish_text.contains("Standard and reversed cards"));
        assert!(galician_text.contains("Cartas estándar e inversas"));
        assert!(galician_text.contains("Recordo medio estimado"));
        assert!(galician_text.contains("A estimación de carga dos próximos 7 días é menor"));
        assert!(galician_text.contains("O tempo de estudo rexistrado é maior"));
        assert!(galician_text.contains("0.0% (0 de 0)"));
        assert!(!galician_text.contains("Standard and reversed cards"));
    }

    #[test]
    fn distribution_helpers_report_median_and_weakest_decile() {
        let values = [0.1, 0.2, 0.5, 0.8, 0.9];

        assert_eq!(median(&values), Some(0.5));
        assert_eq!(weakest_decile_average(&values), Some(0.1));

        let values = [0.1, 0.2, 0.5, 0.8];

        assert_eq!(median(&values), Some(0.35));
        assert_eq!(weakest_decile_average(&values), Some(0.1));
    }

    #[test]
    fn recent_recall_trend_requires_sample_size_and_meaningful_delta() {
        let improving = RecallTrendSummary {
            answers_last_7_days: 10,
            recalled_last_7_days: 9,
            answers_previous_7_days: 10,
            recalled_previous_7_days: 7,
            ..RecallTrendSummary::default()
        };
        let stable = RecallTrendSummary {
            answers_last_7_days: 10,
            recalled_last_7_days: 8,
            answers_previous_7_days: 10,
            recalled_previous_7_days: 8,
            ..RecallTrendSummary::default()
        };
        let insufficient = RecallTrendSummary {
            answers_last_7_days: 4,
            recalled_last_7_days: 4,
            answers_previous_7_days: 10,
            recalled_previous_7_days: 1,
            ..RecallTrendSummary::default()
        };

        assert_eq!(
            recall_trend_direction(&improving),
            TrendDirection::Improving
        );
        assert_eq!(recall_trend_direction(&stable), TrendDirection::Stable);
        assert_eq!(
            recall_trend_direction(&insufficient),
            TrendDirection::InsufficientData
        );
    }
}
