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::localization::Localizer;
use crate::time_format::format_relative_duration_rounded;

const DAY_IN_SECONDS: i64 = 24 * 60 * 60;

#[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) new: usize,
    pub(crate) learning: usize,
    pub(crate) review: usize,
    pub(crate) relearning: usize,
    pub(crate) due_now: usize,
    pub(crate) overdue: usize,
}

#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub(crate) struct RecallEstimate {
    pub(crate) estimated_cards: usize,
    pub(crate) average_recall: Option<f64>,
    pub(crate) likely_known: usize,
    pub(crate) at_risk: 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: [Self; 2] = [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",
        }
    }

    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) buckets: [CalibrationBucket; 4],
}

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

#[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) questions_answered: usize,
    pub(crate) unique_cards_reviewed_in_passes: usize,
    pub(crate) total_review_seconds: 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>,
}

#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub(crate) struct LearningDashboardSummary {
    pub(crate) states: CardStateCounts,
    pub(crate) recall: RecallEstimate,
    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) calibration_recall_rule: CalibrationRecallRule,
}

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

#[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 {
    learning_dashboard_report_with_calibration_rule(
        cards,
        schedules,
        suspended,
        reviews_by_key,
        review_passes,
        now_unix_seconds,
        CalibrationRecallRule::default(),
    )
}

pub(crate) fn learning_dashboard_report_with_calibration_rule(
    cards: &[LearningDashboardCard],
    schedules: &HashMap<DatumKey, CardSchedule>,
    suspended: &HashSet<DatumKey>,
    reviews_by_key: &HashMap<DatumKey, Vec<ReviewEvent>>,
    review_passes: &[ReviewPassSummary],
    now_unix_seconds: i64,
    calibration_recall_rule: CalibrationRecallRule,
) -> LearningDashboardReport {
    let total = summarize_dashboard_cards(
        cards.iter(),
        schedules,
        suspended,
        reviews_by_key,
        now_unix_seconds,
        calibration_recall_rule,
    );
    let standard = summarize_dashboard_cards(
        cards.iter().filter(|card| !card.is_reverse),
        schedules,
        suspended,
        reviews_by_key,
        now_unix_seconds,
        calibration_recall_rule,
    );
    let reverse_count = cards.iter().filter(|card| card.is_reverse).count();
    let reverse = (reverse_count > 0).then(|| {
        summarize_dashboard_cards(
            cards.iter().filter(|card| card.is_reverse),
            schedules,
            suspended,
            reviews_by_key,
            now_unix_seconds,
            calibration_recall_rule,
        )
    });

    LearningDashboardReport {
        total,
        standard,
        reverse,
        activity: summarize_review_activity(reviews_by_key, review_passes),
        calibration_recall_rule,
    }
}

fn summarize_dashboard_cards<'a>(
    cards: impl Iterator<Item = &'a LearningDashboardCard>,
    schedules: &HashMap<DatumKey, CardSchedule>,
    suspended: &HashSet<DatumKey>,
    reviews_by_key: &HashMap<DatumKey, Vec<ReviewEvent>>,
    now_unix_seconds: i64,
    calibration_recall_rule: CalibrationRecallRule,
) -> LearningDashboardSummary {
    let mut summary = LearningDashboardSummary::default();

    let mut estimated_recall_total = 0.0;
    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);
        if is_suspended {
            summary.states.suspended += 1;
        } else {
            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_suspended {
            let scheduled = schedule_datum_for_key(datum_key.clone(), schedule, now_unix_seconds);
            if scheduled.is_due() {
                summary.states.due_now += 1;
                if schedule.is_some() && scheduled.due_at_unix_seconds() < now_unix_seconds {
                    summary.states.overdue += 1;
                }
            }
        }

        if !is_suspended
            && let Some(schedule) = schedule
            && let Some(recall) = estimated_recall_now(schedule, now_unix_seconds)
        {
            summary.recall.estimated_cards += 1;
            estimated_recall_total += recall;
            if recall >= 0.9 {
                summary.recall.likely_known += 1;
            } else if recall < 0.7 {
                summary.recall.at_risk += 1;
            }
        }

        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 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;
            }
        }

        for review in reviews {
            add_calibration_event(&mut summary.calibration, review, calibration_recall_rule);
        }
    }

    if summary.recall.estimated_cards > 0 {
        summary.recall.average_recall =
            Some(estimated_recall_total / summary.recall.estimated_cards as f64);
    }
    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 summarize_review_activity(
    reviews_by_key: &HashMap<DatumKey, Vec<ReviewEvent>>,
    review_passes: &[ReviewPassSummary],
) -> 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();
    for pass in review_passes {
        summary.questions_answered += pass.total_questions();
        summary.unique_cards_reviewed_in_passes += pass.unique_cards_reviewed();
        summary.total_review_seconds += pass_duration_seconds(pass);
    }
    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),
        );
    }

    summary
}

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

pub(crate) fn learning_dashboard_text(
    localizer: Localizer,
    report: &LearningDashboardReport,
) -> String {
    if let Some(reverse) = &report.reverse {
        return [
            learning_dashboard_section_text(
                localizer,
                localizer.learning_dashboard_standard_heading(),
                &report.standard,
                report.calibration_recall_rule,
            ),
            learning_dashboard_section_text(
                localizer,
                localizer.learning_dashboard_reversed_heading(),
                reverse,
                report.calibration_recall_rule,
            ),
            learning_dashboard_section_text(
                localizer,
                localizer.learning_dashboard_total_heading(),
                &report.total,
                report.calibration_recall_rule,
            ),
            learning_dashboard_activity_text(localizer, &report.activity),
        ]
        .join("\n\n");
    }

    [
        learning_dashboard_summary_text(localizer, &report.total, report.calibration_recall_rule),
        learning_dashboard_activity_text(localizer, &report.activity),
    ]
    .join("\n\n")
}

fn learning_dashboard_section_text(
    localizer: Localizer,
    heading: &str,
    summary: &LearningDashboardSummary,
    calibration_recall_rule: CalibrationRecallRule,
) -> String {
    format!(
        "{}\n\n{}",
        heading,
        learning_dashboard_summary_text(localizer, summary, calibration_recall_rule)
    )
}

fn learning_dashboard_activity_text(
    localizer: Localizer,
    summary: &ReviewActivitySummary,
) -> String {
    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());
    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 older_answers_without_pass_timing = summary
        .review_events
        .saturating_sub(summary.questions_answered);

    localizer.learning_dashboard_activity_text(
        summary.review_events,
        summary.review_days,
        &average_reviews_per_review_day,
        summary.review_passes,
        summary.questions_answered,
        summary.unique_cards_reviewed_in_passes,
        &average_questions_per_pass,
        &total_review_time,
        &average_time_per_pass,
        &average_time_per_question,
        older_answers_without_pass_timing,
    )
}

fn learning_dashboard_summary_text(
    localizer: Localizer,
    summary: &LearningDashboardSummary,
    calibration_recall_rule: CalibrationRecallRule,
) -> String {
    let average_recall = summary
        .recall
        .average_recall
        .map(format_percent)
        .unwrap_or_else(|| localizer.card_details_not_available().to_string());
    let average_reviews_per_reviewed_card = summary
        .pace
        .average_reviews_per_reviewed_card
        .map(format_decimal)
        .unwrap_or_else(|| localizer.card_details_not_available().to_string());
    let average_reviews_to_review_state = summary
        .pace
        .average_reviews_to_first_review_state
        .map(format_decimal)
        .unwrap_or_else(|| localizer.card_details_not_available().to_string());
    let average_time_to_review_state = summary
        .pace
        .average_seconds_to_first_review_state
        .map(|seconds| format_relative_duration_rounded(localizer, seconds))
        .unwrap_or_else(|| localizer.card_details_not_available().to_string());
    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());
    let calibration_recall_rule_label =
        calibration_recall_rule_description(localizer, calibration_recall_rule);
    let calibration_bands = [
        calibration_band_line(localizer, summary, PromptGuess::DontKnow),
        calibration_band_line(localizer, summary, PromptGuess::ProbablyDontKnow),
        calibration_band_line(localizer, summary, PromptGuess::ProbablyKnow),
        calibration_band_line(localizer, summary, PromptGuess::CertainlyKnow),
    ];

    localizer.learning_dashboard_summary_text(
        summary.states.total,
        summary.states.active,
        summary.states.suspended,
        summary.states.new,
        summary.states.learning,
        summary.states.review,
        summary.states.relearning,
        summary.states.due_now,
        summary.states.overdue,
        summary.recall.estimated_cards,
        &average_recall,
        summary.recall.likely_known,
        summary.recall.at_risk,
        calibration_recall_rule_label,
        summary.calibration.answered_with_confidence,
        &target_recall_range,
        &observed_recall_rate,
        &calibration_status,
        &calibration_bands[0],
        &calibration_bands[1],
        &calibration_bands[2],
        &calibration_bands[3],
        summary.calibration.not_answered,
        summary.pace.reviewed_cards,
        summary.pace.cards_in_review,
        &average_reviews_per_reviewed_card,
        &average_reviews_to_review_state,
        &average_time_to_review_state,
    )
}

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

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

fn recall_estimate_stability_days(schedule: &CardSchedule) -> f64 {
    let stability_days = schedule.stability_days();
    let interval_days = schedule.interval_seconds() as f64 / DAY_IN_SECONDS as f64;
    if interval_days > 0.0 {
        // Learning/relearning steps can be deliberately shorter than the FSRS
        // stability. Use the actual scheduled interval as the dashboard's 90%
        // point so due learning cards do not appear perfectly remembered.
        stability_days.min(interval_days)
    } else {
        stability_days
    }
}

fn add_calibration_event(
    summary: &mut CalibrationSummary,
    event: &ReviewEvent,
    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;
    if recall_rule.is_recalled(event.review().recall_feedback()) {
        summary.recalled_with_confidence += 1;
        bucket.recalled += 1;
    }
}

fn calibration_bucket_index(prompt_guess: PromptGuess) -> Option<usize> {
    match prompt_guess {
        PromptGuess::NotAnswered => None,
        PromptGuess::DontKnow => Some(0),
        PromptGuess::ProbablyDontKnow => Some(1),
        PromptGuess::ProbablyKnow => Some(2),
        PromptGuess::CertainlyKnow => Some(3),
    }
}

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 {
        CalibrationStatus::OverConfident(target.lower - observed)
    } else if observed > target.upper {
        CalibrationStatus::UnderConfident(observed - target.upper)
    } else {
        CalibrationStatus::WithinTarget
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct FirstReviewStateTransition {
    review_count: usize,
    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,
            elapsed_seconds: review
                .reviewed_at_unix_seconds()
                .saturating_sub(first_reviewed_at),
        })
}

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 cosimo::{Review, ReviewEvent};

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

    #[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, &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.overdue, 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);
        assert_eq!(summary.calibration.buckets[1].answers, 1);
        assert_eq!(summary.calibration.buckets[1].recalled, 0);
        assert_eq!(summary.calibration.buckets[2].answers, 1);
        assert_eq!(summary.calibration.buckets[2].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.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 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(), &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 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 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,
            )],
        )]);

        let report = learning_dashboard_report_with_calibration_rule(
            &cards,
            &HashMap::new(),
            &HashSet::new(),
            &reviews_by_key,
            &[],
            10,
            CalibrationRecallRule::Strict,
        );

        assert_eq!(
            report.calibration_recall_rule,
            CalibrationRecallRule::Strict
        );
        assert_eq!(report.total.calibration.answered_with_confidence, 1);
        assert_eq!(report.total.calibration.recalled_with_confidence, 0);
        assert_eq!(report.total.calibration.buckets[1].answers, 1);
        assert_eq!(report.total.calibration.buckets[1].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(), &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(), &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.questions_answered, 3);
        assert_eq!(report.activity.total_review_seconds, 180);
        assert!(text.contains("Review activity:"));
        assert!(text.contains("Stored answers: 3"));
        assert!(text.contains("Days with answers: 2"));
        assert!(text.contains("Average answers per active day: 1.5"));
        assert!(text.contains("Recorded passes: 2"));
        assert!(text.contains("Answers in recorded passes: 3"));
        assert!(text.contains("Average answers per recorded pass: 1.5"));
        assert!(text.contains("Total review time: 3 minutes"));
        assert!(text.contains("Average time per answer in recorded passes: 1 minute"));
        assert!(!text.contains("Older answers without pass timing"));
    }

    #[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 pass 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(),
            calibration_recall_rule: CalibrationRecallRule::default(),
        };
        let split_report = LearningDashboardReport {
            total: LearningDashboardSummary::default(),
            standard: LearningDashboardSummary::default(),
            reverse: Some(LearningDashboardSummary::default()),
            activity: ReviewActivitySummary::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 cards"));
        assert!(split_text.contains("Standard cards"));
        assert!(split_text.contains("Reversed cards"));
        assert!(split_text.contains("Total cards"));
    }
}
