use std::path::Path;

use cosimo::{CardMemoryState, PromptGuess, RecallFeedback};

mod catalog {
    include!(concat!(env!("OUT_DIR"), "/localization_catalog.rs"));
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Language {
    English,
    Spanish,
    Galician,
}

pub(crate) struct ExamSummaryText<'a> {
    pub(crate) total_questions: usize,
    pub(crate) submitted_answers: usize,
    pub(crate) exact_matches: usize,
    pub(crate) accepted_non_exact: usize,
    pub(crate) correct_answers: usize,
    pub(crate) correct_percentage: usize,
    pub(crate) incorrect_answers: usize,
    pub(crate) elapsed: &'a str,
    pub(crate) time_per_question: &'a str,
    pub(crate) ended_early: bool,
}

impl Language {
    pub fn from_code(code: &str) -> Option<Self> {
        let normalized = code.trim().to_ascii_lowercase();
        let primary = normalized
            .split(['-', '_'])
            .next()
            .unwrap_or(normalized.as_str());

        match primary {
            "en" => Some(Self::English),
            "es" => Some(Self::Spanish),
            "gl" => Some(Self::Galician),
            _ => None,
        }
    }

    pub const fn code(self) -> &'static str {
        match self {
            Self::English => "en",
            Self::Spanish => "es",
            Self::Galician => "gl",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Localizer {
    language: Language,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CycleSummaryText {
    pub reviewed_cards: usize,
    pub unique_reviewed_cards: usize,
    pub total_cards: usize,
    pub elapsed_seconds: u64,
    pub ended_early: bool,
    pub was_scheduled: bool,
    pub prompt_guess_counts: [usize; 5],
    pub recall_feedback_counts: [usize; 4],
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CardDetailsLabel {
    Prompt,
    Response,
    CardId,
    DatumKey,
    State,
    NumberOfReviews,
    LastReview,
    LastConfidenceRating,
    LastRecallRating,
    DueNow,
    NextScheduledReview,
    CurrentInterval,
    MemoryState,
    Stability,
    Difficulty,
    ElapsedDaysAtLastReview,
    ScheduledDays,
    Repetitions,
    Lapses,
    ScheduleLastReviewed,
    ScheduleLastRecallRating,
    StoredSchedule,
    ReviewHistory,
    Confidence,
    Recall,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DeckPropertiesLabel {
    DeckVersion,
    Title,
    Author,
    Description,
    Url,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DurationUnit {
    Second,
    Minute,
    Hour,
    Day,
    Week,
    Month,
    Year,
}

impl Default for Localizer {
    fn default() -> Self {
        Self::new(Language::English)
    }
}

impl Localizer {
    pub const fn new(language: Language) -> Self {
        Self { language }
    }

    fn gettext(self, msgid: &'static str) -> &'static str {
        catalog::gettext(self.language.code(), msgid)
    }

    fn ngettext(
        self,
        singular_msgid: &'static str,
        plural_msgid: &'static str,
        count: usize,
    ) -> &'static str {
        catalog::ngettext(self.language.code(), singular_msgid, plural_msgid, count)
    }

    fn format_message(self, msgid: &'static str, replacements: &[(&str, String)]) -> String {
        let mut message = self.gettext(msgid).to_string();
        for (name, value) in replacements {
            message = message.replace(&format!("{{{name}}}"), value);
        }
        message
    }

    pub fn main_window_title(self) -> &'static str {
        self.gettext("Cosimo Review")
    }

    pub fn options_menu_item(self) -> &'static str {
        self.gettext("Opt&ions")
    }

    pub fn learning_dashboard_menu_item(self) -> &'static str {
        self.gettext("&Learning Dashboard\tCtrl-L")
    }

    pub fn deck_properties_menu_item(self) -> &'static str {
        self.gettext("Deck &Properties")
    }

    pub fn edit_deck_properties_menu_item(self) -> &'static str {
        self.gettext("Edit Deck &Metadata")
    }

    pub fn scrub_review_database_menu_item(self) -> &'static str {
        self.gettext("Scrub Review &Database")
    }

    pub fn reclaim_database_space_menu_item(self) -> &'static str {
        self.gettext("Reclaim Database &Space")
    }

    pub fn file_menu(self) -> &'static str {
        self.gettext("&File")
    }

    pub fn cards_menu(self) -> &'static str {
        self.gettext("&Cards")
    }

    pub fn view_menu(self) -> &'static str {
        self.gettext("&View")
    }

    pub fn help_menu(self) -> &'static str {
        self.gettext("&Help")
    }

    pub fn open_deck_menu_item(self) -> &'static str {
        self.gettext("&Open Deck\tCtrl-O")
    }

    pub fn recent_decks_menu_item(self) -> &'static str {
        self.gettext("&Recent Decks")
    }

    pub fn quit_menu_item(self) -> &'static str {
        self.gettext("&Quit")
    }

    pub fn add_card_menu_item(self) -> &'static str {
        self.gettext("&Add Card\tCtrl-N")
    }

    pub fn add_card_batch_menu_item(self) -> &'static str {
        self.gettext("Add Card &Batch")
    }

    pub fn edit_selected_card_menu_item(self) -> &'static str {
        self.gettext("&Edit Selected Card\tCtrl-E")
    }

    pub fn edit_current_card_menu_item(self) -> &'static str {
        self.gettext("&Edit Current Card\tCtrl-E")
    }

    pub fn remove_selected_card_menu_item(self) -> &'static str {
        self.gettext("&Remove Selected Card\tDelete")
    }

    pub fn suspend_selected_card_menu_item(self) -> &'static str {
        self.gettext("Su&spend Selected Card")
    }

    pub fn unsuspend_selected_card_menu_item(self) -> &'static str {
        self.gettext("Unsu&spend Selected Card")
    }

    pub fn flag_selected_card_menu_item(self) -> &'static str {
        self.gettext("&Flag Selected Card\tCtrl-F")
    }

    pub fn unflag_selected_card_menu_item(self) -> &'static str {
        self.gettext("Un&flag Selected Card\tCtrl-F")
    }

    pub fn flag_current_card_menu_item(self) -> &'static str {
        self.gettext("&Flag Current Card\tCtrl-F")
    }

    pub fn unflag_current_card_menu_item(self) -> &'static str {
        self.gettext("Un&flag Current Card\tCtrl-F")
    }

    pub fn review_flagged_cards_menu_item(self) -> &'static str {
        self.gettext("Review Flagged &Cards")
    }

    pub fn defer_selected_card_until_tomorrow_menu_item(self) -> &'static str {
        self.gettext("&Defer Selected Card until Tomorrow\tCtrl-D")
    }

    pub fn clear_selected_card_deferral_menu_item(self) -> &'static str {
        self.gettext("Clear Selected Card &Deferral\tCtrl-D")
    }

    pub fn make_selected_card_reversible_menu_item(self) -> &'static str {
        self.gettext("Make Selected Card Re&versible\tCtrl-R")
    }

    pub fn remove_reverse_card_menu_item(self) -> &'static str {
        self.gettext("Remove Re&verse Card\tCtrl-R")
    }

    pub fn mark_selected_card_non_reversible_menu_item(self) -> &'static str {
        self.gettext("Mark Selected Card &Non-Reversible")
    }

    pub fn allow_selected_card_reversal_menu_item(self) -> &'static str {
        self.gettext("Allow Selected Card Reversa&l")
    }

    pub fn make_eligible_cards_reversible_menu_item(self) -> &'static str {
        self.gettext("Make &Possible Cards Reversible")
    }

    pub fn undo_last_bulk_reversal_menu_item(self) -> &'static str {
        self.gettext("Undo Last B&ulk Reversal")
    }

    pub fn sort_deck_order_menu_item(self) -> &'static str {
        self.gettext("&Deck Order")
    }

    pub fn sort_next_scheduled_menu_item(self) -> &'static str {
        self.gettext("&Next Scheduled Review")
    }

    pub fn sort_last_reviewed_menu_item(self) -> &'static str {
        self.gettext("&Last Reviewed")
    }

    pub fn sort_prompt_text_menu_item(self) -> &'static str {
        self.gettext("&Prompt Text")
    }

    pub fn sort_response_text_menu_item(self) -> &'static str {
        self.gettext("&Response Text")
    }

    pub fn sort_estimated_recall_menu_item(self) -> &'static str {
        self.gettext("Estimated Re&call")
    }

    pub fn sort_ascending_menu_item(self) -> &'static str {
        self.gettext("&Ascending")
    }

    pub fn sort_descending_menu_item(self) -> &'static str {
        self.gettext("Descendin&g")
    }

    pub fn user_guide_menu_item(self) -> &'static str {
        self.gettext("&User Guide")
    }

    pub fn user_guide_html_path(self) -> &'static str {
        self.gettext("docs/user-guide.html")
    }

    pub fn report_bug_menu_item(self) -> &'static str {
        self.gettext("Report &Bug")
    }

    pub fn report_bug_url(self) -> &'static str {
        self.gettext("https://modulus.isonomia.net/cosimo.cgi/tktnew")
    }

    pub fn project_wiki_menu_item(self) -> &'static str {
        self.gettext("Project &Wiki")
    }

    pub fn project_wiki_url(self) -> &'static str {
        self.gettext("https://modulus.isonomia.net/cosimo.cgi/wiki?name=home")
    }

    pub fn about_menu_item(self) -> &'static str {
        self.gettext("&About Cosimo")
    }

    pub fn about_caption(self) -> &'static str {
        self.gettext("About Cosimo")
    }

    pub fn about_message(self) -> String {
        self.format_message(
            "Cosimo\n\nCurrent version: {version}\nStatus: beta\n\nAuthor: David Picón Álvarez\nSupport: @modulux@node.isonomia.net",
            &[("version", env!("CARGO_PKG_VERSION").to_string())],
        )
    }

    pub fn ready_cards_heading(self) -> &'static str {
        self.gettext("Cards in this pass")
    }

    pub fn ready_add_card_button(self) -> &'static str {
        self.gettext("&Add Card")
    }

    pub fn ready_edit_selected_button(self) -> &'static str {
        self.gettext("Edit Selected")
    }

    pub fn ready_remove_selected_button(self) -> &'static str {
        self.gettext("Remove Selected")
    }

    pub fn ready_card_filter_label(self) -> &'static str {
        self.gettext("Card filter")
    }

    pub fn ready_card_text_filter_label(self) -> &'static str {
        self.gettext("Fi&lter text")
    }

    pub fn ready_card_text_filter_name(self) -> &'static str {
        self.gettext("Filter text")
    }

    pub fn ready_card_filter_all(self) -> &'static str {
        self.gettext("All cards")
    }

    pub fn ready_card_filter_due(self) -> &'static str {
        self.gettext("Due cards")
    }

    pub fn ready_card_filter_new(self) -> &'static str {
        self.gettext("New cards")
    }

    pub fn ready_card_filter_not_due(self) -> &'static str {
        self.gettext("Not due cards")
    }

    pub fn ready_card_filter_suspended(self) -> &'static str {
        self.gettext("Suspended cards")
    }

    pub fn ready_card_filter_flagged(self) -> &'static str {
        self.gettext("Flagged cards")
    }

    pub fn ready_card_filter_deferred(self) -> &'static str {
        self.gettext("Deferred cards")
    }

    pub fn ready_card_filter_non_reversible(self) -> &'static str {
        self.gettext("Non-reversible cards")
    }

    pub fn ready_card_filter_reverse(self) -> &'static str {
        self.gettext("Reverse cards")
    }

    pub fn ready_card_filter_duplicate_prompts(self) -> &'static str {
        self.gettext("Duplicated prompts")
    }

    pub fn ready_card_filter_duplicate_responses(self) -> &'static str {
        self.gettext("Duplicated responses")
    }

    pub fn ready_card_reverse_suffix(self) -> &'static str {
        self.gettext("(reverse)")
    }

    pub fn ready_card_suspended_suffix(self) -> &'static str {
        self.gettext("(suspended)")
    }

    pub fn ready_card_flagged_suffix(self) -> &'static str {
        self.gettext("(flagged)")
    }

    pub fn ready_card_deferred_suffix(self) -> &'static str {
        self.gettext("(deferred)")
    }

    pub fn ready_card_non_reversible_suffix(self) -> &'static str {
        self.gettext("(non-reversible)")
    }

    pub fn start_button(self) -> &'static str {
        self.gettext("&Start")
    }

    pub fn force_start_button(self) -> &'static str {
        self.gettext("Force S&tart")
    }

    pub fn review_flagged_cards_button(self) -> &'static str {
        self.gettext("Review Flagged")
    }

    pub fn start_exam_button(self) -> &'static str {
        self.gettext("Start E&xam")
    }

    pub fn quit_button(self) -> &'static str {
        self.gettext("&Quit")
    }

    pub fn prompt_heading(self) -> &'static str {
        self.gettext("Prompt")
    }

    pub fn response_heading(self) -> &'static str {
        self.gettext("Response")
    }

    pub fn prompt_heading_with_audio(self) -> &'static str {
        self.gettext("Prompt; has audio")
    }

    pub fn response_heading_with_audio(self) -> &'static str {
        self.gettext("Response; has audio")
    }

    pub fn play_stop_review_audio_button_label(self) -> &'static str {
        self.gettext("Play/Stop Audio (Ctrl+P)")
    }

    pub fn could_not_play_review_audio(self, path: &str) -> String {
        self.format_message(
            "Could not play audio file: {path}",
            &[("path", path.to_string())],
        )
    }

    pub fn guess_instruction_label(self) -> &'static str {
        self.gettext("Guess before response.")
    }

    pub fn feedback_instruction_label(self) -> &'static str {
        self.gettext("Recall feedback.")
    }

    pub fn edit_revealed_card_button_label(self) -> &'static str {
        self.gettext("Edit Card")
    }

    pub fn card_batch_dialog_title(self) -> &'static str {
        self.gettext("Add Card Batch")
    }

    pub fn card_batch_status(self) -> &'static str {
        self.gettext("Ready to add cards.")
    }

    pub fn card_batch_add_button(self) -> &'static str {
        self.gettext("&Add")
    }

    pub fn card_batch_close_button(self) -> &'static str {
        self.gettext("Close")
    }

    pub fn card_batch_added_status(self, total_cards: usize) -> String {
        self.format_message(
            "Card added. {total_cards} cards in deck.",
            &[("total_cards", total_cards.to_string())],
        )
    }

    pub fn end_pass_now_button_label(self) -> &'static str {
        self.gettext("0. End this pass now")
    }

    pub fn prompt_guess_button_label(self, guess: PromptGuess) -> &'static str {
        self.gettext(match guess {
            PromptGuess::NotAnswered => "Not answered",
            PromptGuess::DontKnow => "1. Don't know",
            PromptGuess::ProbablyDontKnow => "2. Probably don't know",
            PromptGuess::ProbablyKnow => "3. Probably know",
            PromptGuess::CertainlyKnow => "4. Certainly know",
        })
    }

    pub fn reveal_response_button_label(self) -> &'static str {
        self.gettext("Reveal Response")
    }

    pub fn flag_card_button_label(self) -> &'static str {
        self.gettext("Flag Card")
    }

    pub fn defer_current_card_until_tomorrow_button_label(self) -> &'static str {
        self.gettext("Defer Card until Tomorrow")
    }

    pub fn unflag_card_button_label(self) -> &'static str {
        self.gettext("Unflag Card")
    }

    pub fn recall_feedback_button_label(self, feedback: RecallFeedback) -> &'static str {
        self.gettext(match feedback {
            RecallFeedback::Again => "1. Again",
            RecallFeedback::Hard => "2. Hard",
            RecallFeedback::Good => "3. Good",
            RecallFeedback::Easy => "4. Easy",
        })
    }

    pub fn exam_mode_dialog_title(self) -> &'static str {
        self.gettext("Exam Mode")
    }

    pub fn exam_setup_dialog_title(self) -> &'static str {
        self.gettext("Start Exam")
    }

    pub fn exam_setup_status(self, active_card_count: usize) -> String {
        let template = self.ngettext(
            "Choose how much of the active deck to examine. {active_card_count} card is available. Suspended and deferred cards are excluded.",
            "Choose how much of the active deck to examine. {active_card_count} cards are available. Suspended and deferred cards are excluded.",
            active_card_count,
        );
        self.format_message(
            template,
            &[("active_card_count", active_card_count.to_string())],
        )
    }

    pub fn exam_size_mode_label(self) -> &'static str {
        self.gettext("Exam size")
    }

    pub fn exam_size_value_label(self) -> &'static str {
        self.gettext("Value")
    }

    pub fn exam_exact_checkbox_label(self) -> &'static str {
        self.gettext("Exact")
    }

    pub fn exam_size_question_count_choice(self) -> &'static str {
        self.gettext("Number of questions")
    }

    pub fn exam_size_percentage_choice(self) -> &'static str {
        self.gettext("Percentage of active cards")
    }

    pub fn exam_start_button(self) -> &'static str {
        self.gettext("Start Exam")
    }

    pub fn exam_invalid_size_status(self) -> &'static str {
        self.gettext("Enter a whole number greater than zero.")
    }

    pub fn exam_invalid_percentage_status(self) -> &'static str {
        self.gettext("Enter a percentage from 1 to 100.")
    }

    pub fn exam_question_status(
        self,
        current_question: usize,
        total_questions: usize,
        prompt: &str,
    ) -> String {
        self.format_message(
            "Question {current_question} of {total_questions}: {prompt}",
            &[
                ("current_question", current_question.to_string()),
                ("total_questions", total_questions.to_string()),
                ("prompt", prompt.to_string()),
            ],
        )
    }

    pub fn exam_answer_heading(self) -> &'static str {
        self.gettext("Your answer")
    }

    pub fn submit_exam_answer_button(self) -> &'static str {
        self.gettext("Submit Answer")
    }

    pub fn end_exam_button(self) -> &'static str {
        self.gettext("End Exam")
    }

    pub fn exam_grade_answer_caption(self) -> &'static str {
        self.gettext("Grade Exam Answer")
    }

    pub fn exam_grade_answer_instruction(self) -> &'static str {
        self.gettext("Count this answer as correct?")
    }

    pub fn exam_expected_response_heading(self) -> &'static str {
        self.gettext("Expected response")
    }

    pub fn exam_review_inexact_caption(self) -> &'static str {
        self.gettext("Review Inexact Answer")
    }

    pub fn exam_review_inexact_instruction(self) -> &'static str {
        self.gettext("This answer was not exact.")
    }

    pub fn exam_next_button(self) -> &'static str {
        self.gettext("&Next")
    }

    pub fn exam_summary_button(self) -> &'static str {
        self.gettext("Summary")
    }

    pub fn no_answer_entered(self) -> &'static str {
        self.gettext("No answer entered")
    }

    pub fn yes_button_with_shortcut(self) -> &'static str {
        self.gettext("Yes (Ctrl+Y)")
    }

    pub fn no_button_with_shortcut(self) -> &'static str {
        self.gettext("No (Ctrl+N)")
    }

    pub fn exam_summary_caption(self) -> &'static str {
        self.gettext("Exam Summary")
    }

    pub fn exam_summary_text(self, summary: ExamSummaryText<'_>) -> String {
        let ended_early_text = if summary.ended_early {
            self.gettext("Yes")
        } else {
            self.gettext("No")
        };
        self.format_message(
            "Exam summary\n\nQuestions selected: {total_questions}\nAnswers submitted: {submitted_answers}\nCorrect answers: {correct_answers} ({correct_percentage}%)\nExact matches: {exact_matches}\nAccepted non-exact answers: {accepted_non_exact}\nIncorrect answers: {incorrect_answers}\nTotal time: {elapsed}\nTime per question: {time_per_question}\nEnded early: {ended_early}\n\nThis exam did not change review history or scheduling.",
            &[
                ("total_questions", summary.total_questions.to_string()),
                ("submitted_answers", summary.submitted_answers.to_string()),
                ("correct_answers", summary.correct_answers.to_string()),
                (
                    "correct_percentage",
                    summary.correct_percentage.to_string(),
                ),
                ("exact_matches", summary.exact_matches.to_string()),
                (
                    "accepted_non_exact",
                    summary.accepted_non_exact.to_string(),
                ),
                ("incorrect_answers", summary.incorrect_answers.to_string()),
                ("elapsed", summary.elapsed.to_string()),
                (
                    "time_per_question",
                    summary.time_per_question.to_string(),
                ),
                ("ended_early", ended_early_text.to_string()),
            ],
        )
    }

    pub fn card_details_dialog_title(self) -> &'static str {
        self.gettext("Card Details")
    }

    pub fn card_details_dialog_status(self) -> &'static str {
        self.gettext("Details for the selected card.")
    }

    pub fn learning_dashboard_dialog_title(self) -> &'static str {
        self.gettext("Learning Dashboard")
    }

    pub fn learning_dashboard_dialog_status(self) -> &'static str {
        self.gettext("Deck learning summary.")
    }

    pub fn deck_properties_dialog_title(self) -> &'static str {
        self.gettext("Deck Properties")
    }

    pub fn deck_properties_dialog_status(self) -> &'static str {
        self.gettext("Properties for the current deck.")
    }

    pub fn deck_properties_editor_title(self) -> &'static str {
        self.gettext("Edit Deck Properties")
    }

    pub fn deck_properties_editor_status(self) -> &'static str {
        self.gettext("Edit deck metadata.")
    }

    pub fn deck_properties_editor_single_line_status(self) -> &'static str {
        self.gettext("Metadata fields must be single lines.")
    }

    pub fn card_details_close_button(self) -> &'static str {
        self.gettext("Close")
    }

    pub fn card_details_not_available(self) -> &'static str {
        self.gettext("Not available")
    }

    pub fn card_details_none(self) -> &'static str {
        self.gettext("None")
    }

    pub fn card_details_yes(self) -> &'static str {
        self.gettext("yes")
    }

    pub fn card_details_no(self) -> &'static str {
        self.gettext("no")
    }

    pub fn card_details_new_card_due_now(self) -> &'static str {
        self.gettext("Immediately; new card")
    }

    pub fn card_details_state_normal(self) -> &'static str {
        self.gettext("normal")
    }

    pub fn card_details_state_suspended(self) -> &'static str {
        self.gettext("suspended")
    }

    pub fn card_details_state_flagged(self) -> &'static str {
        self.gettext("flagged")
    }

    pub fn card_details_state_deferred(self) -> &'static str {
        self.gettext("deferred")
    }

    pub fn card_details_state_non_reversible(self) -> &'static str {
        self.gettext("non-reversible")
    }

    pub fn card_details_state_has_reverse(self) -> &'static str {
        self.gettext("has reverse")
    }

    pub fn card_details_state_reverse(self) -> &'static str {
        self.gettext("reverse")
    }

    pub fn card_details_future_datetime(self, duration: &str, timestamp: &str) -> String {
        self.format_message(
            "in {duration} ({timestamp})",
            &[
                ("duration", duration.to_string()),
                ("timestamp", timestamp.to_string()),
            ],
        )
    }

    pub fn card_details_past_datetime(self, duration: &str, timestamp: &str) -> String {
        self.format_message(
            "{duration} ago ({timestamp})",
            &[
                ("duration", duration.to_string()),
                ("timestamp", timestamp.to_string()),
            ],
        )
    }

    pub(crate) fn card_details_label(self, label: CardDetailsLabel) -> &'static str {
        self.gettext(match label {
            CardDetailsLabel::Prompt => "Prompt",
            CardDetailsLabel::Response => "Response",
            CardDetailsLabel::CardId => "Card ID",
            CardDetailsLabel::DatumKey => "Datum key",
            CardDetailsLabel::State => "State",
            CardDetailsLabel::NumberOfReviews => "Number of reviews",
            CardDetailsLabel::LastReview => "Last review",
            CardDetailsLabel::LastConfidenceRating => "Last confidence rating",
            CardDetailsLabel::LastRecallRating => "Last recall rating",
            CardDetailsLabel::DueNow => "Due now",
            CardDetailsLabel::NextScheduledReview => "Next scheduled review",
            CardDetailsLabel::CurrentInterval => "Current interval",
            CardDetailsLabel::MemoryState => "Memory state",
            CardDetailsLabel::Stability => "Stability",
            CardDetailsLabel::Difficulty => "Difficulty",
            CardDetailsLabel::ElapsedDaysAtLastReview => "Elapsed days at last review",
            CardDetailsLabel::ScheduledDays => "Scheduled days",
            CardDetailsLabel::Repetitions => "Repetitions",
            CardDetailsLabel::Lapses => "Lapses",
            CardDetailsLabel::ScheduleLastReviewed => "Schedule last reviewed",
            CardDetailsLabel::ScheduleLastRecallRating => "Schedule last recall rating",
            CardDetailsLabel::StoredSchedule => "Stored schedule",
            CardDetailsLabel::ReviewHistory => "Review history",
            CardDetailsLabel::Confidence => "Confidence",
            CardDetailsLabel::Recall => "Recall",
        })
    }

    pub(crate) fn deck_properties_label(self, label: DeckPropertiesLabel) -> &'static str {
        self.gettext(match label {
            DeckPropertiesLabel::DeckVersion => "Deck version",
            DeckPropertiesLabel::Title => "Title",
            DeckPropertiesLabel::Author => "Author",
            DeckPropertiesLabel::Description => "Description",
            DeckPropertiesLabel::Url => "URL",
        })
    }

    pub fn card_memory_state_label(self, memory_state: CardMemoryState) -> &'static str {
        self.gettext(match memory_state {
            CardMemoryState::New => "new",
            CardMemoryState::Learning => "learning",
            CardMemoryState::Review => "review",
            CardMemoryState::Relearning => "relearning",
        })
    }

    pub(crate) fn learning_dashboard_standard_heading(self) -> &'static str {
        self.gettext("Standard cards")
    }

    pub(crate) fn learning_dashboard_reversed_heading(self) -> &'static str {
        self.gettext("Reversed cards")
    }

    pub(crate) fn learning_dashboard_total_heading(self) -> &'static str {
        self.gettext("Total cards")
    }

    pub(crate) fn learning_dashboard_lenient_recall_rule(self) -> &'static str {
        self.gettext("Lenient: Hard, Good, and Easy count as recalled")
    }

    pub(crate) fn learning_dashboard_strict_recall_rule(self) -> &'static str {
        self.gettext("Strict: Good and Easy count as recalled")
    }

    pub(crate) fn percentage_points_unit(self) -> &'static str {
        self.gettext("percentage points")
    }

    pub(crate) fn learning_dashboard_calibration_target_range(
        self,
        lower: &str,
        upper: &str,
    ) -> String {
        self.format_message(
            "{lower} to {upper}",
            &[("lower", lower.to_string()), ("upper", upper.to_string())],
        )
    }

    pub(crate) fn learning_dashboard_calibration_within_target(self) -> &'static str {
        self.gettext("within target")
    }

    pub(crate) fn learning_dashboard_calibration_under_confident(self, difference: &str) -> String {
        self.format_message(
            "under-confident by {difference}",
            &[("difference", difference.to_string())],
        )
    }

    pub(crate) fn learning_dashboard_calibration_over_confident(self, difference: &str) -> String {
        self.format_message(
            "over-confident by {difference}",
            &[("difference", difference.to_string())],
        )
    }

    pub(crate) fn learning_dashboard_calibration_band_line(
        self,
        confidence: &str,
        target: &str,
        observed: &str,
        answers: usize,
        status: &str,
    ) -> String {
        self.format_message(
            "{confidence}: target {target}; observed {observed}; answers: {answers}; {status}",
            &[
                ("confidence", confidence.to_string()),
                ("target", target.to_string()),
                ("observed", observed.to_string()),
                ("answers", answers.to_string()),
                ("status", status.to_string()),
            ],
        )
    }

    #[allow(clippy::too_many_arguments)]
    pub(crate) fn learning_dashboard_summary_text(
        self,
        total_cards: usize,
        active_cards: usize,
        suspended_cards: usize,
        new_cards: usize,
        learning_cards: usize,
        review_cards: usize,
        relearning_cards: usize,
        due_now_cards: usize,
        overdue_cards: usize,
        estimated_cards: usize,
        average_recall: &str,
        likely_known_cards: usize,
        at_risk_cards: usize,
        calibration_recall_rule: &str,
        answered_with_confidence: usize,
        target_recall_range: &str,
        observed_recall_rate: &str,
        calibration_status: &str,
        dont_know_calibration: &str,
        probably_dont_know_calibration: &str,
        probably_know_calibration: &str,
        certainly_know_calibration: &str,
        not_answered: usize,
        reviewed_cards: usize,
        cards_in_review: usize,
        average_reviews_per_reviewed_card: &str,
        average_reviews_to_review_state: &str,
        average_time_to_review_state: &str,
    ) -> String {
        self.format_message(
            "Cards:\nTotal cards: {total_cards}\nActive cards: {active_cards}\nSuspended cards: {suspended_cards}\nNew cards: {new_cards}\nLearning cards: {learning_cards}\nReview cards: {review_cards}\nRelearning cards: {relearning_cards}\nAvailable now: {due_now_cards}\nScheduled and overdue: {overdue_cards}\n\nEstimated recall now:\nCards with estimates: {estimated_cards}\nAverage estimated recall: {average_recall}\nLikely known cards: {likely_known_cards}\nAt-risk cards: {at_risk_cards}\n\nConfidence calibration:\nRecall rule: {calibration_recall_rule}\nAnswers with confidence ratings: {answered_with_confidence}\nTarget recall range: {target_recall_range}\nObserved recall rate: {observed_recall_rate}\nCalibration: {calibration_status}\nBy confidence:\n{dont_know_calibration}\n{probably_dont_know_calibration}\n{probably_know_calibration}\n{certainly_know_calibration}\nRevealed without confidence: {not_answered}\n\nLearning pace:\nCards with review history: {reviewed_cards}\nCards currently in review: {cards_in_review}\nAverage reviews per reviewed card: {average_reviews_per_reviewed_card}\nAverage reviews to first review state: {average_reviews_to_review_state}\nAverage time to first review state: {average_time_to_review_state}",
            &[
                ("total_cards", total_cards.to_string()),
                ("active_cards", active_cards.to_string()),
                ("suspended_cards", suspended_cards.to_string()),
                ("new_cards", new_cards.to_string()),
                ("learning_cards", learning_cards.to_string()),
                ("review_cards", review_cards.to_string()),
                ("relearning_cards", relearning_cards.to_string()),
                ("due_now_cards", due_now_cards.to_string()),
                ("overdue_cards", overdue_cards.to_string()),
                ("estimated_cards", estimated_cards.to_string()),
                ("average_recall", average_recall.to_string()),
                ("likely_known_cards", likely_known_cards.to_string()),
                ("at_risk_cards", at_risk_cards.to_string()),
                (
                    "calibration_recall_rule",
                    calibration_recall_rule.to_string(),
                ),
                (
                    "answered_with_confidence",
                    answered_with_confidence.to_string(),
                ),
                ("target_recall_range", target_recall_range.to_string()),
                ("observed_recall_rate", observed_recall_rate.to_string()),
                ("calibration_status", calibration_status.to_string()),
                ("dont_know_calibration", dont_know_calibration.to_string()),
                (
                    "probably_dont_know_calibration",
                    probably_dont_know_calibration.to_string(),
                ),
                (
                    "probably_know_calibration",
                    probably_know_calibration.to_string(),
                ),
                (
                    "certainly_know_calibration",
                    certainly_know_calibration.to_string(),
                ),
                ("not_answered", not_answered.to_string()),
                ("reviewed_cards", reviewed_cards.to_string()),
                ("cards_in_review", cards_in_review.to_string()),
                (
                    "average_reviews_per_reviewed_card",
                    average_reviews_per_reviewed_card.to_string(),
                ),
                (
                    "average_reviews_to_review_state",
                    average_reviews_to_review_state.to_string(),
                ),
                (
                    "average_time_to_review_state",
                    average_time_to_review_state.to_string(),
                ),
            ],
        )
    }

    #[allow(clippy::too_many_arguments)]
    pub(crate) fn learning_dashboard_activity_text(
        self,
        review_events: usize,
        review_days: usize,
        average_reviews_per_review_day: &str,
        review_passes: usize,
        questions_answered: usize,
        unique_cards_reviewed_in_passes: usize,
        average_questions_per_pass: &str,
        total_review_time: &str,
        average_time_per_pass: &str,
        average_time_per_question: &str,
        older_answers_without_pass_timing: usize,
    ) -> String {
        let older_answers_without_pass_timing_line = if older_answers_without_pass_timing > 0 {
            self.format_message(
                "\nOlder answers without pass timing: {older_answers_without_pass_timing}",
                &[(
                    "older_answers_without_pass_timing",
                    older_answers_without_pass_timing.to_string(),
                )],
            )
        } else {
            String::new()
        };

        self.format_message(
            "Review activity:\nStored answers: {review_events}\nDays with answers: {review_days}\nAverage answers per active day: {average_reviews_per_review_day}\nRecorded passes: {review_passes}\nAnswers in recorded passes: {questions_answered}\nUnique cards reviewed in recorded passes: {unique_cards_reviewed_in_passes}\nAverage answers per recorded pass: {average_questions_per_pass}\nTotal review time: {total_review_time}\nAverage time per recorded pass: {average_time_per_pass}\nAverage time per answer in recorded passes: {average_time_per_question}{older_answers_without_pass_timing_line}",
            &[
                ("review_events", review_events.to_string()),
                ("review_days", review_days.to_string()),
                (
                    "average_reviews_per_review_day",
                    average_reviews_per_review_day.to_string(),
                ),
                ("review_passes", review_passes.to_string()),
                ("questions_answered", questions_answered.to_string()),
                (
                    "unique_cards_reviewed_in_passes",
                    unique_cards_reviewed_in_passes.to_string(),
                ),
                (
                    "average_questions_per_pass",
                    average_questions_per_pass.to_string(),
                ),
                ("total_review_time", total_review_time.to_string()),
                ("average_time_per_pass", average_time_per_pass.to_string()),
                (
                    "average_time_per_question",
                    average_time_per_question.to_string(),
                ),
                (
                    "older_answers_without_pass_timing_line",
                    older_answers_without_pass_timing_line,
                ),
            ],
        )
    }

    pub fn stats_dialog_title(self) -> &'static str {
        self.gettext("Review Statistics")
    }

    pub fn stats_dialog_status(self) -> &'static str {
        self.gettext("Review statistics.")
    }

    pub fn continue_button(self) -> &'static str {
        self.gettext("Continue")
    }

    pub fn save_report_button(self) -> &'static str {
        self.gettext("Save &Report")
    }

    pub fn saved_review_report_status(self, path: &str) -> String {
        self.format_message("Saved review report: {path}", &[("path", path.to_string())])
    }

    pub fn saved_exam_report_status(self, path: &str) -> String {
        self.format_message("Saved exam report: {path}", &[("path", path.to_string())])
    }

    pub fn saved_learning_dashboard_report_status(self, path: &str) -> String {
        self.format_message(
            "Saved learning dashboard report: {path}",
            &[("path", path.to_string())],
        )
    }

    pub fn could_not_save_review_report_status(self, path: &str, error: &str) -> String {
        self.format_message(
            "Could not save review report to {path}: {error}\n\nThe report was not saved. Check write permission, available disk space, and whether another program is locking the reports directory.",
            &[("path", path.to_string()), ("error", error.to_string())],
        )
    }

    pub fn could_not_save_exam_report_status(self, path: &str, error: &str) -> String {
        self.format_message(
            "Could not save exam report to {path}: {error}\n\nThe report was not saved. Check write permission, available disk space, and whether another program is locking the reports directory.",
            &[("path", path.to_string()), ("error", error.to_string())],
        )
    }

    pub fn could_not_save_learning_dashboard_report_status(
        self,
        path: &str,
        error: &str,
    ) -> String {
        self.format_message(
            "Could not save learning dashboard report to {path}: {error}\n\nThe report was not saved. Check write permission, available disk space, and whether another program is locking the reports directory.",
            &[("path", path.to_string()), ("error", error.to_string())],
        )
    }

    pub fn review_report_text(
        self,
        deck_path: &str,
        review_db_path: &str,
        started_at: &str,
        finished_at: &str,
        summary: &str,
    ) -> String {
        self.format_message(
            "Review report\n\nDeck: {deck_path}\nReview database: {review_db_path}\nStarted: {started_at}\nFinished: {finished_at}\n\n{summary}",
            &[
                ("deck_path", deck_path.to_string()),
                ("review_db_path", review_db_path.to_string()),
                ("started_at", started_at.to_string()),
                ("finished_at", finished_at.to_string()),
                ("summary", summary.to_string()),
            ],
        )
    }

    pub fn exam_report_text(
        self,
        deck_path: &str,
        review_db_path: &str,
        started_at: &str,
        finished_at: &str,
        summary: &str,
    ) -> String {
        self.format_message(
            "Exam report\n\nDeck: {deck_path}\nReview database: {review_db_path}\nStarted: {started_at}\nFinished: {finished_at}\n\n{summary}",
            &[
                ("deck_path", deck_path.to_string()),
                ("review_db_path", review_db_path.to_string()),
                ("started_at", started_at.to_string()),
                ("finished_at", finished_at.to_string()),
                ("summary", summary.to_string()),
            ],
        )
    }

    pub fn learning_dashboard_report_text(
        self,
        deck_path: &str,
        review_db_path: &str,
        generated_at: &str,
        dashboard: &str,
    ) -> String {
        self.format_message(
            "Learning dashboard report\n\nDeck: {deck_path}\nReview database: {review_db_path}\nGenerated: {generated_at}\n\n{dashboard}",
            &[
                ("deck_path", deck_path.to_string()),
                ("review_db_path", review_db_path.to_string()),
                ("generated_at", generated_at.to_string()),
                ("dashboard", dashboard.to_string()),
            ],
        )
    }

    pub fn card_editor_title(self) -> &'static str {
        self.gettext("Card Editor")
    }

    pub fn options_dialog_title(self) -> &'static str {
        self.gettext("Options")
    }

    pub fn options_dialog_status(self) -> &'static str {
        self.gettext("Options.")
    }

    pub fn options_language_label(self) -> &'static str {
        self.gettext("Language")
    }

    pub fn options_minimum_cards_label(self) -> &'static str {
        self.gettext("Minimum cards per session")
    }

    pub fn options_deck_backups_label(self) -> &'static str {
        self.gettext("Deck backups")
    }

    pub fn deck_backup_full_choice(self) -> &'static str {
        self.gettext("Full")
    }

    pub fn deck_backup_last_only_choice(self) -> &'static str {
        self.gettext("Last only")
    }

    pub fn deck_backup_none_choice(self) -> &'static str {
        self.gettext("None")
    }

    pub fn options_review_db_backups_label(self) -> &'static str {
        self.gettext("Review database backups")
    }

    pub fn options_sound_notifications_label(self) -> &'static str {
        self.gettext("Sound notifications")
    }

    pub fn options_review_audio_label(self) -> &'static str {
        self.gettext("Audio during reviews")
    }

    pub fn options_calibration_recall_rule_label(self) -> &'static str {
        self.gettext("Calibration recall rule")
    }

    pub fn sound_notification_never_choice(self) -> &'static str {
        self.gettext("Never")
    }

    pub fn sound_notification_always_choice(self) -> &'static str {
        self.gettext("Always")
    }

    pub fn sound_notification_when_visible_choice(self) -> &'static str {
        self.gettext("When Cosimo is visible")
    }

    pub fn sound_notification_when_in_tray_choice(self) -> &'static str {
        self.gettext("When Cosimo is in the system tray")
    }

    pub fn review_audio_off_choice(self) -> &'static str {
        self.gettext("No audio")
    }

    pub fn review_audio_manual_choice(self) -> &'static str {
        self.gettext("Manual")
    }

    pub fn review_audio_automatic_choice(self) -> &'static str {
        self.gettext("Automatic")
    }

    pub fn options_minimize_to_tray_checkbox(self) -> &'static str {
        self.gettext("Minimise to system tray")
    }

    #[cfg_attr(not(target_os = "windows"), allow(dead_code))]
    pub fn tray_restore_menu_item(self) -> &'static str {
        self.gettext("&Restore")
    }

    #[cfg_attr(not(target_os = "windows"), allow(dead_code))]
    pub fn tray_quit_menu_item(self) -> &'static str {
        self.gettext("&Quit")
    }

    pub fn review_db_backup_on_session_start_choice(self) -> &'static str {
        self.gettext("On session start")
    }

    pub fn review_db_backup_off_choice(self) -> &'static str {
        self.gettext("Off")
    }

    pub fn options_cancel_button(self) -> &'static str {
        self.gettext("Cancel")
    }

    pub fn options_ok_button(self) -> &'static str {
        self.gettext("OK")
    }

    pub fn options_prune_backups_button(self) -> &'static str {
        self.gettext("Delete Old Backups")
    }

    pub fn prune_backups_caption(self) -> &'static str {
        self.gettext("Delete Old Backups")
    }

    pub fn prune_backups_message(self) -> &'static str {
        self.gettext(
            "Delete obsolete automatic deck backups?\n\nCosimo will keep only the newest backup for each original deck path and renumber the remaining backups. This cannot be undone.",
        )
    }

    pub fn prune_backups_completed_caption(self) -> &'static str {
        self.gettext("Old Backups Deleted")
    }

    pub fn prune_backups_completed_message(
        self,
        removed_count: usize,
        kept_count: usize,
    ) -> String {
        self.format_message(
            "Deleted {removed_count} obsolete backups. Kept {kept_count} backups.",
            &[
                ("removed_count", removed_count.to_string()),
                ("kept_count", kept_count.to_string()),
            ],
        )
    }

    pub fn could_not_prune_backups_caption(self) -> &'static str {
        self.gettext("Could not delete old backups")
    }

    pub(crate) fn duration_unit_label(self, unit: DurationUnit, singular: bool) -> &'static str {
        let count = usize::from(!singular) + 1;
        match unit {
            DurationUnit::Second => self.ngettext("second", "seconds", count),
            DurationUnit::Minute => self.ngettext("minute", "minutes", count),
            DurationUnit::Hour => self.ngettext("hour", "hours", count),
            DurationUnit::Day => self.ngettext("day", "days", count),
            DurationUnit::Week => self.ngettext("week", "weeks", count),
            DurationUnit::Month => self.ngettext("month", "months", count),
            DurationUnit::Year => self.ngettext("year", "years", count),
        }
    }

    pub fn language_display_name(self, language: Language) -> &'static str {
        self.gettext(match language {
            Language::English => "English",
            Language::Spanish => "Spanish",
            Language::Galician => "Galician",
        })
    }

    pub fn card_editor_prompt_heading(self) -> &'static str {
        self.prompt_heading()
    }

    pub fn card_editor_response_heading(self) -> &'static str {
        self.response_heading()
    }

    pub fn card_editor_cancel_button(self) -> &'static str {
        self.gettext("Cancel")
    }

    pub fn card_editor_save_button(self) -> &'static str {
        self.gettext("&Save")
    }

    pub fn card_editor_status(self, is_editing: bool) -> &'static str {
        self.gettext(if is_editing {
            "Edit card."
        } else {
            "New card."
        })
    }

    pub fn card_editor_empty_prompt_status(self) -> &'static str {
        self.gettext("Prompt required.")
    }

    pub fn card_editor_empty_response_status(self) -> &'static str {
        self.gettext("Response required.")
    }

    pub fn open_deck_dialog_message(self) -> &'static str {
        self.gettext("Open a deck file")
    }

    pub fn open_deck_wildcard(self) -> &'static str {
        self.gettext("Cosimo deck (*.cosimo-deck)|*.cosimo-deck|All files (*.*)|*.*")
    }

    pub fn create_deck_caption(self) -> &'static str {
        self.gettext("Create Deck")
    }

    pub fn create_deck_message(self, deck_path: &str) -> String {
        self.format_message(
            "The selected deck file does not exist.\n\nCreate an empty deck there?\n\n{deck_path}",
            &[("deck_path", deck_path.to_string())],
        )
    }

    pub fn unable_to_start_review(self, error: &str) -> String {
        self.format_message(
            "Unable to start review. {error}",
            &[("error", error.to_string())],
        )
    }

    pub fn could_not_read_deck_file(self, path: &Path, source: &str) -> String {
        self.format_message(
            "Could not read deck file {path}: {source}\n\nNo deck data were loaded. Check that the file exists, that you have permission to read it, and that it is not locked by another program.",
            &[
                ("path", path.display().to_string()),
                ("source", source.to_string()),
            ],
        )
    }

    pub fn could_not_read_path(self, path: &Path, source: &str) -> String {
        self.format_message(
            "Could not read {path}: {source}",
            &[
                ("path", path.display().to_string()),
                ("source", source.to_string()),
            ],
        )
    }

    pub fn could_not_parse_deck(self, path: &Path, source: &str) -> String {
        self.format_message(
            "Could not parse deck file {path}: {source}\n\nThe deck file was not changed. Check the deck text, especially prompt/response separators and card terminators.",
            &[
                ("path", path.display().to_string()),
                ("source", source.to_string()),
            ],
        )
    }

    pub fn could_not_open_review_database(self, path: &Path, source: &str) -> String {
        self.format_message(
            "Could not open review database {path}: {source}\n\nThe deck file was not changed. If the database is locked, close other Cosimo windows or programs using it. If the database does not exist, Cosimo normally creates it; check that the folder is writable.",
            &[
                ("path", path.display().to_string()),
                ("source", source.to_string()),
            ],
        )
    }

    pub fn could_not_save_deck(self, source: &str) -> String {
        self.format_message(
            "Could not save deck: {source}",
            &[("source", source.to_string())],
        )
    }

    pub fn could_not_write_path(self, path: &Path, source: &str) -> String {
        self.format_message(
            "Could not write {path}: {source}\n\nThe requested change was not saved. Check write permission, available disk space, and whether another program is locking the file.",
            &[
                ("path", path.display().to_string()),
                ("source", source.to_string()),
            ],
        )
    }

    pub fn could_not_create_deck(self, path: &Path, source: &str) -> String {
        self.format_message(
            "Could not create deck file {path}: {source}\n\nNo deck was opened. Check that the folder is writable and then restart Cosimo or open another deck.",
            &[
                ("path", path.display().to_string()),
                ("source", source.to_string()),
            ],
        )
    }

    pub fn startup_state_load_error(
        self,
        error: &str,
        deck_path: &Path,
        review_db_path: &Path,
    ) -> String {
        self.format_message(
            "No deck is currently open.\n\n{error}\n\nDeck file: {deck_path}\nReview database: {review_db_path}\n\nFix the file or folder permissions, close any program locking these files, or open another deck.",
            &[
                ("error", error.to_string()),
                ("deck_path", deck_path.display().to_string()),
                ("review_db_path", review_db_path.display().to_string()),
            ],
        )
    }

    pub fn ready_to_start_summary_text(
        self,
        total_cards: usize,
        review_db_path: &str,
        minimum_cards_per_session: usize,
        scheduled_cards: Option<usize>,
        due_cards: Option<usize>,
    ) -> String {
        let scheduled_text = match (scheduled_cards, due_cards) {
            (Some(0), _) => self
                .gettext("Scheduled now: 0. No cards are due.")
                .to_string(),
            (Some(scheduled_cards), Some(due_cards)) if scheduled_cards != due_cards => self
                .format_message(
                    "Scheduled now: {scheduled_cards} ({due_cards}).",
                    &[
                        ("scheduled_cards", scheduled_cards.to_string()),
                        ("due_cards", self.due_cards_parenthetical(due_cards)),
                    ],
                ),
            (Some(scheduled_cards), _) => self.format_message(
                "Scheduled now: {scheduled_cards}.",
                &[("scheduled_cards", scheduled_cards.to_string())],
            ),
            (None, _) => self.gettext("Scheduled now: unknown.").to_string(),
        };

        self.format_message(
            "Deck: {total_cards} cards.\nMinimum per session: {minimum_cards_per_session}.\n{scheduled_text}\nReview database: {review_db_path}.\n\nStart: due cards, filled to minimum.\nForce Start: all cards not suspended or deferred, random order.",
            &[
                ("total_cards", total_cards.to_string()),
                (
                    "minimum_cards_per_session",
                    minimum_cards_per_session.to_string(),
                ),
                ("scheduled_text", scheduled_text),
                ("review_db_path", review_db_path.to_string()),
            ],
        )
    }

    fn due_cards_parenthetical(self, due_cards: usize) -> String {
        format!("{} {}", due_cards, self.ngettext("due", "due", due_cards))
    }

    pub fn no_cards_available_to_start_pass(self) -> &'static str {
        self.gettext("No cards are available to start a pass.")
    }

    pub fn no_cards_scheduled_to_start_pass(self, minimum_cards_per_session: usize) -> String {
        if minimum_cards_per_session == 0 {
            self.gettext(
                "No cards are currently due. Increase the minimum cards per session in Options to review extra cards.",
            )
            .to_string()
        } else {
            self.format_message(
                "No cards could be scheduled for this session. Minimum cards per session is {minimum_cards_per_session}.",
                &[(
                    "minimum_cards_per_session",
                    minimum_cards_per_session.to_string(),
                )],
            )
        }
    }

    pub fn started_pass(self, total_cards: usize) -> String {
        self.format_message(
            "Started a pass through {total_cards} cards.",
            &[("total_cards", total_cards.to_string())],
        )
    }

    pub fn force_started_pass(self, total_cards: usize) -> String {
        self.format_message(
            "Force started a pass through all {total_cards} cards.",
            &[("total_cards", total_cards.to_string())],
        )
    }

    pub fn started_flagged_pass(self, total_cards: usize) -> String {
        let template = self.ngettext(
            "Started an unscheduled pass through {total_cards} flagged card.",
            "Started an unscheduled pass through {total_cards} flagged cards.",
            total_cards,
        );
        self.format_message(template, &[("total_cards", total_cards.to_string())])
    }

    pub fn started_exam_pass(self, total_cards: usize) -> String {
        let template = self.ngettext(
            "Started an exam pass through {total_cards} card.",
            "Started an exam pass through {total_cards} cards.",
            total_cards,
        );
        self.format_message(template, &[("total_cards", total_cards.to_string())])
    }

    pub fn completed_exam_pass(self, total_cards: usize) -> String {
        let template = self.ngettext(
            "Completed exam pass with {total_cards} answer.",
            "Completed exam pass with {total_cards} answers.",
            total_cards,
        );
        self.format_message(template, &[("total_cards", total_cards.to_string())])
    }

    pub fn no_flagged_cards_available_to_review(self) -> &'static str {
        self.gettext("No flagged cards are available to review.")
    }

    pub fn response_shown_choose_feedback(self) -> &'static str {
        self.gettext("Response shown.")
    }

    pub fn no_datum_available_to_record(self) -> &'static str {
        self.gettext("No datum is available to record.")
    }

    pub fn choose_prompt_guess_before_feedback(self) -> &'static str {
        self.gettext("Prompt guess required before feedback.")
    }

    pub fn recorded_review_completed(self, review_id: i64, total_cards: usize) -> String {
        self.format_message(
            "Recorded review {review_id} and completed all {total_cards} cards.",
            &[
                ("review_id", review_id.to_string()),
                ("total_cards", total_cards.to_string()),
            ],
        )
    }

    pub fn recorded_review_for_card(
        self,
        review_id: i64,
        completed_index: usize,
        total_cards: usize,
    ) -> String {
        self.format_message(
            "Recorded review {review_id} for card {completed_index} of {total_cards}.",
            &[
                ("review_id", review_id.to_string()),
                ("completed_index", completed_index.to_string()),
                ("total_cards", total_cards.to_string()),
            ],
        )
    }

    pub fn could_not_record_review(self, review_db_path: &str, error: &str) -> String {
        self.format_message(
            "Could not record the answer in review database {review_db_path}: {error}\n\nThe answer was not recorded and the current card remains in the review pass.",
            &[
                ("review_db_path", review_db_path.to_string()),
                ("error", error.to_string()),
            ],
        )
    }

    pub fn could_not_record_review_caption(self) -> &'static str {
        self.gettext("Could not record review")
    }

    pub fn could_not_record_review_pass_status(self, review_db_path: &str, error: &str) -> String {
        self.format_message(
            "Warning: could not record the review pass summary in {review_db_path}: {error}\n\nIndividual card answers were recorded, but the pass-level summary may be missing from later analysis.",
            &[
                ("review_db_path", review_db_path.to_string()),
                ("error", error.to_string()),
            ],
        )
    }

    pub fn could_not_build_review_queue(self, error: &str) -> String {
        self.format_message(
            "Could not build the review queue: {error}",
            &[("error", error.to_string())],
        )
    }

    pub fn could_not_start_scheduled_pass(self, error: &str) -> String {
        self.format_message(
            "Could not start the scheduled pass: {error}",
            &[("error", error.to_string())],
        )
    }

    pub fn could_not_backup_review_database_status(
        self,
        review_db_path: &str,
        backup_dir: &str,
        error: &str,
    ) -> String {
        self.format_message(
            "Warning: could not back up review database {review_db_path} to {backup_dir}: {error}\n\nReview can continue, but the retained database backup was not refreshed.",
            &[
                ("review_db_path", review_db_path.to_string()),
                ("backup_dir", backup_dir.to_string()),
                ("error", error.to_string()),
            ],
        )
    }

    pub fn could_not_backup_review_database(
        self,
        review_db_path: &str,
        backup_dir: &str,
        error: &str,
    ) -> String {
        self.format_message(
            "Could not back up review database {review_db_path} to {backup_dir}: {error}\n\nThe database-changing operation was stopped before making changes.",
            &[
                ("review_db_path", review_db_path.to_string()),
                ("backup_dir", backup_dir.to_string()),
                ("error", error.to_string()),
            ],
        )
    }

    pub fn could_not_preview_scheduled_cards_status(self, error: &str) -> String {
        self.format_message(
            "Warning: could not preview scheduled cards: {error}",
            &[("error", error.to_string())],
        )
    }

    pub fn no_cards_available_for_review(self) -> &'static str {
        self.gettext("No cards are available for review.")
    }

    pub fn ended_pass_early_after(self, reviewed_cards: usize, total_cards: usize) -> String {
        self.format_message(
            "Ended the pass early after reviewing {reviewed_cards} of {total_cards} cards.",
            &[
                ("reviewed_cards", reviewed_cards.to_string()),
                ("total_cards", total_cards.to_string()),
            ],
        )
    }

    pub fn ready_to_start_new_pass(self, total_cards: usize) -> String {
        self.format_message(
            "Ready to start a new pass through {total_cards} cards.",
            &[("total_cards", total_cards.to_string())],
        )
    }

    pub fn main_status_bar_due_now(self, total_cards: usize, due_cards: usize) -> String {
        self.format_message(
            "Cards: {total_cards} total; due: {due_cards}; review due now.",
            &[
                ("total_cards", total_cards.to_string()),
                ("due_cards", due_cards.to_string()),
            ],
        )
    }

    pub fn main_status_bar_next_review(self, total_cards: usize, duration: &str) -> String {
        self.format_message(
            "Cards: {total_cards} total; due: 0; next review in {duration}.",
            &[
                ("total_cards", total_cards.to_string()),
                ("duration", duration.to_string()),
            ],
        )
    }

    pub fn main_status_bar_no_review_scheduled(self, total_cards: usize) -> String {
        self.format_message(
            "Cards: {total_cards} total; no review scheduled.",
            &[("total_cards", total_cards.to_string())],
        )
    }

    pub fn main_status_bar_schedule_unavailable(self, total_cards: usize) -> String {
        self.format_message(
            "Cards: {total_cards} total; schedule unavailable.",
            &[("total_cards", total_cards.to_string())],
        )
    }

    pub fn added_card_status(self, total_cards: usize) -> String {
        self.format_message(
            "Card added. {total_cards} cards.",
            &[("total_cards", total_cards.to_string())],
        )
    }

    pub fn no_selected_card_to_edit(self) -> &'static str {
        self.gettext("There is no selected card to edit.")
    }

    pub fn saved_selected_card_status(self, total_cards: usize) -> String {
        self.format_message(
            "Card saved. {total_cards} cards.",
            &[("total_cards", total_cards.to_string())],
        )
    }

    pub fn saved_deck_properties_status(self) -> &'static str {
        self.gettext("Deck properties saved.")
    }

    pub fn saved_revealed_card_status(self) -> &'static str {
        self.gettext("Card saved.")
    }

    pub fn no_selected_card_to_remove(self) -> &'static str {
        self.gettext("There is no selected card to remove.")
    }

    pub fn removed_last_card_status(self) -> &'static str {
        self.gettext("Removed the last card. The deck is now empty.")
    }

    pub fn removed_card_status(self, removed_card_number: usize, total_cards: usize) -> String {
        self.format_message(
            "Removed card {removed_card_number}. {total_cards} cards.",
            &[
                ("removed_card_number", removed_card_number.to_string()),
                ("total_cards", total_cards.to_string()),
            ],
        )
    }

    pub fn marked_card_reversible_status(self) -> &'static str {
        self.gettext("Card marked reversible.")
    }

    pub fn marked_cards_reversible_status(self, card_count: usize) -> String {
        let template = self.ngettext(
            "Marked {card_count} card reversible.",
            "Marked {card_count} cards reversible.",
            card_count,
        );
        self.format_message(template, &[("card_count", card_count.to_string())])
    }

    pub fn removed_reverse_card_status(self) -> &'static str {
        self.gettext("Reverse card removed. The parent card remains.")
    }

    pub fn marked_card_non_reversible_status(self) -> &'static str {
        self.gettext("Card marked non-reversible.")
    }

    pub fn allowed_card_reversal_status(self) -> &'static str {
        self.gettext("Card can be made reversible again.")
    }

    pub fn cannot_reverse_ambiguous_response(self) -> &'static str {
        self.gettext("This card cannot be reversed because another card has the same response.")
    }

    pub fn cannot_reverse_excluded_card(self) -> &'static str {
        self.gettext("This card is marked non-reversible.")
    }

    pub fn make_eligible_cards_reversible_caption(self) -> &'static str {
        self.gettext("Make Cards Reversible")
    }

    pub fn no_eligible_cards_reversible_status(self) -> &'static str {
        self.gettext("No cards can currently be made reversible.")
    }

    pub fn make_eligible_cards_reversible_message(
        self,
        card_count: usize,
        overwrites_review_db_backup: bool,
    ) -> String {
        let opening = self.ngettext(
            "Make {card_count} card reversible?",
            "Make {card_count} cards reversible?",
            card_count,
        );
        let mut message = self.format_message(
            "{opening}\n\nCards already reversible, cards marked non-reversible, and cards with duplicate responses are excluded.",
            &[
                ("opening", self.format_message(opening, &[("card_count", card_count.to_string())])),
            ],
        );
        if overwrites_review_db_backup {
            message.push_str("\n\n");
            message.push_str(self.gettext(
                "The retained review database backup will be overwritten before this change.",
            ));
        }
        message
    }

    pub fn undo_last_bulk_reversal_caption(self) -> &'static str {
        self.gettext("Undo Last Bulk Reversal")
    }

    pub fn undo_last_bulk_reversal_message(
        self,
        card_count: usize,
        overwrites_review_db_backup: bool,
    ) -> String {
        let opening = self.ngettext(
            "Remove {card_count} reverse card from the last bulk reversal?",
            "Remove {card_count} reverse cards from the last bulk reversal?",
            card_count,
        );
        let mut message = self.format_message(
            "{opening}\n\nParent cards and review history will be kept.",
            &[(
                "opening",
                self.format_message(opening, &[("card_count", card_count.to_string())]),
            )],
        );
        if overwrites_review_db_backup {
            message.push_str("\n\n");
            message.push_str(self.gettext(
                "The retained review database backup will be overwritten before this change.",
            ));
        }
        message
    }

    pub fn no_bulk_reversal_to_undo(self) -> &'static str {
        self.gettext("There is no bulk reversal to undo.")
    }

    pub fn removed_latest_bulk_reversal_status(self, card_count: usize) -> String {
        let template = self.ngettext(
            "Removed {card_count} reverse card from the last bulk reversal.",
            "Removed {card_count} reverse cards from the last bulk reversal.",
            card_count,
        );
        self.format_message(template, &[("card_count", card_count.to_string())])
    }

    pub fn scrub_review_database_caption(self) -> &'static str {
        self.gettext("Scrub Review Database")
    }

    pub fn scrub_review_database_message(
        self,
        backups_disabled: bool,
        overwrites_review_db_backup: bool,
    ) -> String {
        let mut message = self
            .gettext(
                "Delete dormant review database data for removed cards and inactive reverse cards?",
            )
            .to_string();
        if backups_disabled {
            message.push_str("\n\n");
            message.push_str(
                self.gettext("Review database backups are off. This process is irreversible."),
            );
        } else if overwrites_review_db_backup {
            message.push_str("\n\n");
            message.push_str(self.gettext(
                "The retained review database backup will be overwritten before this change.",
            ));
        }
        message
    }

    pub fn scrubbed_review_database_status(self, removed_rows: usize) -> String {
        let template = self.ngettext(
            "Scrubbed review database and removed {removed_rows} dormant row.",
            "Scrubbed review database and removed {removed_rows} dormant rows.",
            removed_rows,
        );
        self.format_message(template, &[("removed_rows", removed_rows.to_string())])
    }

    pub fn reclaim_database_space_caption(self) -> &'static str {
        self.gettext("Reclaim Database Space")
    }

    pub fn reclaim_database_space_message(self, backups_disabled: bool) -> String {
        let mut message = self
            .gettext(
                "Reclaim unused space in the review database? This rewrites the database file and may take some time.",
            )
            .to_string();
        if backups_disabled {
            message.push_str("\n\n");
            message.push_str(self.gettext(
                "Review database backups are off. Make sure you have a backup if you are concerned.",
            ));
        }
        message
    }

    pub fn reclaimed_database_space_status(self) -> &'static str {
        self.gettext("Reclaimed review database space.")
    }

    pub fn generated_reverse_card_cannot_be_edited(self) -> &'static str {
        self.gettext("Generated reverse cards cannot be edited yet.")
    }

    pub fn suspended_card_status(self) -> &'static str {
        self.gettext("Card suspended. It will not appear in review.")
    }

    pub fn unsuspended_card_status(self) -> &'static str {
        self.gettext("Card unsuspended. It can appear in review again.")
    }

    pub fn flagged_card_status(self) -> &'static str {
        self.gettext("Card flagged.")
    }

    pub fn unflagged_card_status(self) -> &'static str {
        self.gettext("Card unflagged.")
    }

    pub fn deferred_card_status(self) -> &'static str {
        self.gettext("Card deferred until tomorrow.")
    }

    pub fn cleared_card_deferral_status(self) -> &'static str {
        self.gettext("Card deferral cleared.")
    }

    pub fn no_cards_reviewed_caption(self) -> &'static str {
        self.gettext("No Cards Reviewed")
    }

    pub fn no_cards_reviewed_message(self) -> &'static str {
        self.gettext("No cards were reviewed in this pass.")
    }

    pub fn card_modification_only_before_cycle_starts(self) -> &'static str {
        self.gettext("Card modifications are only allowed before a cycle starts.")
    }

    pub fn cycle_summary_text(self, summary: CycleSummaryText) -> String {
        let CycleSummaryText {
            reviewed_cards,
            unique_reviewed_cards,
            total_cards,
            elapsed_seconds,
            ended_early,
            was_scheduled,
            prompt_guess_counts,
            recall_feedback_counts,
        } = summary;
        let opening = match (ended_early, was_scheduled) {
            (true, _) => self.gettext("Ended the pass early."),
            (false, true) => self.gettext("Completed one scheduled pass."),
            (false, false) => self.gettext("Completed one unscheduled pass."),
        };
        let total_time = self.format_review_time(elapsed_seconds);
        let time_per_question = self.format_average_review_time(elapsed_seconds, reviewed_cards);
        let time_per_unique_card =
            self.format_average_review_time(elapsed_seconds, unique_reviewed_cards);

        self.format_message(
            "{opening}\n\nUnique cards reviewed: {unique_reviewed_cards}\nQuestions answered: {reviewed_cards} of {total_cards}\nTotal time: {total_time}\nTime per question: {time_per_question}\nTime per unique card: {time_per_unique_card}\n\nBefore response:\nNot answered: {not_answered_count}\n1. Don't know: {dont_know_count}\n2. Probably don't know: {probably_dont_know_count}\n3. Probably know: {probably_know_count}\n4. Certainly know: {certainly_know_count}\n\nRecall feedback:\n1. Again: {again_count}\n2. Hard: {hard_count}\n3. Good: {good_count}\n4. Easy: {easy_count}",
            &[
                ("opening", opening.to_string()),
                (
                    "unique_reviewed_cards",
                    unique_reviewed_cards.to_string(),
                ),
                ("reviewed_cards", reviewed_cards.to_string()),
                ("total_cards", total_cards.to_string()),
                ("total_time", total_time),
                ("time_per_question", time_per_question),
                ("time_per_unique_card", time_per_unique_card),
                (
                    "not_answered_count",
                    count_and_percentage(prompt_guess_counts[0], reviewed_cards),
                ),
                (
                    "dont_know_count",
                    count_and_percentage(prompt_guess_counts[1], reviewed_cards),
                ),
                (
                    "probably_dont_know_count",
                    count_and_percentage(prompt_guess_counts[2], reviewed_cards),
                ),
                (
                    "probably_know_count",
                    count_and_percentage(prompt_guess_counts[3], reviewed_cards),
                ),
                (
                    "certainly_know_count",
                    count_and_percentage(prompt_guess_counts[4], reviewed_cards),
                ),
                (
                    "again_count",
                    count_and_percentage(recall_feedback_counts[0], reviewed_cards),
                ),
                (
                    "hard_count",
                    count_and_percentage(recall_feedback_counts[1], reviewed_cards),
                ),
                (
                    "good_count",
                    count_and_percentage(recall_feedback_counts[2], reviewed_cards),
                ),
                (
                    "easy_count",
                    count_and_percentage(recall_feedback_counts[3], reviewed_cards),
                ),
            ],
        )
    }

    fn format_average_review_time(self, elapsed_seconds: u64, divisor: usize) -> String {
        if divisor == 0 {
            return self.format_review_time(0);
        }
        let divisor = divisor as u64;
        self.format_review_time(elapsed_seconds.div_ceil(divisor))
    }

    pub(crate) fn format_review_time(self, elapsed_seconds: u64) -> String {
        let minutes = elapsed_seconds / 60;
        let seconds = elapsed_seconds % 60;
        if minutes == 0 {
            return self.format_duration_unit(seconds, DurationUnit::Second);
        }
        if seconds == 0 {
            return self.format_duration_unit(minutes, DurationUnit::Minute);
        }
        self.format_message(
            "{minutes} {seconds}",
            &[
                (
                    "minutes",
                    self.format_duration_unit(minutes, DurationUnit::Minute),
                ),
                (
                    "seconds",
                    self.format_duration_unit(seconds, DurationUnit::Second),
                ),
            ],
        )
    }

    fn format_duration_unit(self, value: u64, unit: DurationUnit) -> String {
        format!("{value} {}", self.duration_unit_label(unit, value == 1))
    }

    pub fn ready_phase_instruction(self, total_cards: usize) -> &'static str {
        self.gettext(if total_cards == 0 {
            "Add a card to start."
        } else {
            "Ready."
        })
    }

    pub fn prompt_phase_instruction(self) -> &'static str {
        self.gettext("1-4 guess. 0 ends pass.")
    }

    pub fn response_phase_instruction(self) -> &'static str {
        self.gettext("1-4 feedback. 0 ends pass.")
    }

    pub fn loaded_cards_status(self, total_cards: usize, phase_instruction: &str) -> String {
        self.format_message(
            "Loaded {total_cards} cards. {phase_instruction}",
            &[
                ("total_cards", total_cards.to_string()),
                ("phase_instruction", phase_instruction.to_string()),
            ],
        )
    }

    pub fn current_card_status(
        self,
        last_status: &str,
        card_number: usize,
        total_cards: usize,
        phase_instruction: &str,
    ) -> String {
        self.format_message(
            "{last_status} Card {card_number} of {total_cards}. {phase_instruction}",
            &[
                ("last_status", last_status.to_string()),
                ("card_number", card_number.to_string()),
                ("total_cards", total_cards.to_string()),
                ("phase_instruction", phase_instruction.to_string()),
            ],
        )
    }

    pub fn could_not_save_startup_deck_caption(self) -> &'static str {
        self.gettext("Could Not Save Startup Deck")
    }

    pub fn could_not_add_card_caption(self) -> &'static str {
        self.gettext("Could not add card")
    }

    pub fn could_not_save_options_caption(self) -> &'static str {
        self.gettext("Could not save options")
    }

    pub fn could_not_save_view_caption(self) -> &'static str {
        self.gettext("Could not save view")
    }

    pub fn could_not_open_deck_caption(self) -> &'static str {
        self.gettext("Could not open deck")
    }

    pub fn no_selected_card_caption(self) -> &'static str {
        self.gettext("No Selected Card")
    }

    pub fn no_selected_card_message(self) -> &'static str {
        self.gettext("No card is selected.")
    }

    pub fn could_not_show_card_details_caption(self) -> &'static str {
        self.gettext("Could not show card details")
    }

    pub fn could_not_show_learning_dashboard_caption(self) -> &'static str {
        self.gettext("Could not show learning dashboard")
    }

    pub fn could_not_edit_card_caption(self) -> &'static str {
        self.gettext("Could not edit card")
    }

    pub fn could_not_edit_deck_properties_caption(self) -> &'static str {
        self.gettext("Could not edit deck properties")
    }

    pub fn remove_card_caption(self) -> &'static str {
        self.gettext("Remove Card")
    }

    pub fn remove_selected_card_message(self, prompt_preview: &str) -> String {
        self.format_message(
            "Remove the selected card?\n\n{prompt_preview}",
            &[("prompt_preview", prompt_preview.to_string())],
        )
    }

    pub fn remove_reversible_parent_card_message(self, prompt_preview: &str) -> String {
        self.format_message(
            "Remove the selected card?\n\n{prompt_preview}\n\nThis card has a generated reverse card. Removing the parent card removes the reverse card from the prompt list too. The reverse review data remains in the database and can be recovered by restoring a deck backup that contains this parent card.",
            &[("prompt_preview", prompt_preview.to_string())],
        )
    }

    pub fn remove_reverse_card_message(self, prompt_preview: &str) -> String {
        self.format_message(
            "Remove the selected reverse card?\n\n{prompt_preview}\n\nThis removes only the generated reverse direction. Its review data remains in the database and can be recovered by making the parent card reversible again.",
            &[("prompt_preview", prompt_preview.to_string())],
        )
    }

    pub fn could_not_remove_card_caption(self) -> &'static str {
        self.gettext("Could not remove card")
    }

    pub fn could_not_open_user_guide_caption(self) -> &'static str {
        self.gettext("Could not open user guide")
    }

    pub fn user_guide_missing_message(self, path: &Path) -> String {
        self.format_message(
            "The compiled user guide was not found:\n\n{path}",
            &[("path", path.display().to_string())],
        )
    }

    pub fn could_not_open_user_guide_message(self, path: &Path) -> String {
        self.format_message(
            "The user guide could not be opened:\n\n{path}",
            &[("path", path.display().to_string())],
        )
    }

    pub fn could_not_open_bug_report_caption(self) -> &'static str {
        self.gettext("Could not open bug report page")
    }

    pub fn could_not_open_bug_report_message(self, url: &str) -> String {
        self.format_message(
            "The bug report page could not be opened:\n\n{url}",
            &[("url", url.to_string())],
        )
    }

    pub fn could_not_open_project_wiki_caption(self) -> &'static str {
        self.gettext("Could not open project wiki")
    }

    pub fn could_not_open_project_wiki_message(self, url: &str) -> String {
        self.format_message(
            "The project wiki could not be opened:\n\n{url}",
            &[("url", url.to_string())],
        )
    }
}

fn count_and_percentage(count: usize, total: usize) -> String {
    if total == 0 {
        return format!("{count} (0.0%)");
    }

    let percentage = (count as f64 / total as f64) * 100.0;
    format!("{count} ({percentage:.1}%)")
}

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

    #[test]
    fn language_recognizes_english_locale_codes() {
        assert_eq!(Language::from_code("en"), Some(Language::English));
        assert_eq!(Language::from_code("en-US"), Some(Language::English));
        assert_eq!(Language::from_code("en_GB"), Some(Language::English));
    }

    #[test]
    fn language_recognizes_spanish_locale_codes() {
        assert_eq!(Language::from_code("es"), Some(Language::Spanish));
        assert_eq!(Language::from_code("es-ES"), Some(Language::Spanish));
        assert_eq!(Language::from_code("es_MX"), Some(Language::Spanish));
    }

    #[test]
    fn language_recognizes_galician_locale_codes() {
        assert_eq!(Language::from_code("gl"), Some(Language::Galician));
        assert_eq!(Language::from_code("gl-ES"), Some(Language::Galician));
        assert_eq!(Language::from_code("gl_ES"), Some(Language::Galician));
    }

    #[test]
    fn language_rejects_unknown_locale_codes() {
        assert_eq!(Language::from_code("fr"), None);
        assert_eq!(Language::from_code(""), None);
    }

    #[test]
    fn io_error_messages_include_paths_and_consequences() {
        let localizer = Localizer::default();

        let report_error =
            localizer.could_not_save_review_report_status("reports/deck.txt", "permission denied");
        assert!(report_error.contains("reports/deck.txt"));
        assert!(report_error.contains("The report was not saved"));

        let exam_report_error =
            localizer.could_not_save_exam_report_status("reports/deck-exam.txt", "disk full");
        assert!(exam_report_error.contains("reports/deck-exam.txt"));
        assert!(exam_report_error.contains("The report was not saved"));

        let review_error =
            localizer.could_not_record_review("decks/deck.reviews.sqlite3", "database is locked");
        assert!(review_error.contains("decks/deck.reviews.sqlite3"));
        assert!(review_error.contains("The answer was not recorded"));

        let pass_error = localizer.could_not_record_review_pass_status(
            "decks/deck.reviews.sqlite3",
            "database is locked",
        );
        assert!(pass_error.contains("decks/deck.reviews.sqlite3"));
        assert!(pass_error.contains("Individual card answers were recorded"));

        let backup_warning = localizer.could_not_backup_review_database_status(
            "decks/deck.reviews.sqlite3",
            "backups",
            "permission denied",
        );
        assert!(backup_warning.contains("decks/deck.reviews.sqlite3"));
        assert!(backup_warning.contains("backups"));
        assert!(backup_warning.contains("backup was not refreshed"));

        let backup_error = localizer.could_not_backup_review_database(
            "decks/deck.reviews.sqlite3",
            "backups",
            "permission denied",
        );
        assert!(backup_error.contains("decks/deck.reviews.sqlite3"));
        assert!(backup_error.contains("backups"));
        assert!(backup_error.contains("stopped before making changes"));
    }

    #[test]
    fn spanish_review_copy_uses_project_terms_and_accents() {
        let localizer = Localizer::new(Language::Spanish);

        assert_eq!(localizer.ready_add_card_button(), "Aña&dir carta");
        assert_eq!(localizer.ready_card_text_filter_label(), "Fi&ltro de texto");
        assert_eq!(localizer.ready_card_text_filter_name(), "Filtro de texto");
        assert_eq!(localizer.ready_card_filter_label(), "Filtro de cartas");
        assert_eq!(localizer.ready_card_filter_all(), "Todas las cartas");
        assert_eq!(localizer.ready_card_filter_due(), "Cartas pendientes");
        assert_eq!(localizer.ready_card_filter_new(), "Cartas nuevas");
        assert_eq!(
            localizer.ready_card_filter_not_due(),
            "Cartas no pendientes"
        );
        assert_eq!(
            localizer.ready_card_filter_suspended(),
            "Cartas suspendidas"
        );
        assert_eq!(localizer.ready_card_filter_deferred(), "Cartas aplazadas");
        assert_eq!(
            localizer.ready_card_filter_non_reversible(),
            "Cartas no reversibles"
        );
        assert_eq!(localizer.ready_card_filter_reverse(), "Cartas inversas");
        assert_eq!(
            localizer.ready_card_filter_duplicate_prompts(),
            "Preguntas duplicadas"
        );
        assert_eq!(
            localizer.ready_card_filter_duplicate_responses(),
            "Respuestas duplicadas"
        );
        assert_eq!(localizer.ready_card_reverse_suffix(), "(inversa)");
        assert_eq!(
            localizer.ready_card_non_reversible_suffix(),
            "(no reversible)"
        );
        assert_eq!(localizer.ready_card_deferred_suffix(), "(aplazada)");
        assert_eq!(
            localizer.suspend_selected_card_menu_item(),
            "Sus&pender carta seleccionada"
        );
        assert_eq!(
            localizer.unsuspend_selected_card_menu_item(),
            "Quitar sus&pensión de carta seleccionada"
        );
        assert_eq!(localizer.view_menu(), "&Ver");
        assert_eq!(localizer.sort_deck_order_menu_item(), "&Orden del mazo");
        assert_eq!(
            localizer.sort_next_scheduled_menu_item(),
            "&Próximo repaso programado"
        );
        assert_eq!(localizer.sort_last_reviewed_menu_item(), "Último &repaso");
        assert_eq!(localizer.sort_prompt_text_menu_item(), "Texto de pre&gunta");
        assert_eq!(
            localizer.sort_response_text_menu_item(),
            "Texto de resp&uesta"
        );
        assert_eq!(
            localizer.sort_estimated_recall_menu_item(),
            "Recuerdo &estimado"
        );
        assert_eq!(localizer.sort_ascending_menu_item(), "&Ascendente");
        assert_eq!(localizer.sort_descending_menu_item(), "&Descendente");
        assert_eq!(
            localizer.card_details_dialog_title(),
            "Detalles de la carta"
        );
        assert_eq!(
            localizer.card_details_dialog_status(),
            "Detalles de la carta seleccionada."
        );
        assert_eq!(localizer.card_details_close_button(), "Cerrar");
        assert_eq!(
            localizer.card_details_label(CardDetailsLabel::NumberOfReviews),
            "Número de repasos"
        );
        assert_eq!(
            localizer.card_details_label(CardDetailsLabel::NextScheduledReview),
            "Próximo repaso programado"
        );
        assert_eq!(
            localizer.card_details_label(CardDetailsLabel::State),
            "Estado"
        );
        assert_eq!(localizer.card_details_state_suspended(), "suspendida");
        assert_eq!(localizer.card_details_state_flagged(), "marcada");
        assert_eq!(localizer.card_details_state_deferred(), "aplazada");
        assert_eq!(
            localizer.card_details_state_non_reversible(),
            "no reversible"
        );
        assert_eq!(localizer.card_details_state_has_reverse(), "tiene inversa");
        assert_eq!(
            localizer.card_details_future_datetime("3 días", "2027-01-15 08:00:00 UTC"),
            "en 3 días (2027-01-15 08:00:00 UTC)"
        );
        assert_eq!(
            localizer.card_details_past_datetime("3 días", "2027-01-15 08:00:00 UTC"),
            "hace 3 días (2027-01-15 08:00:00 UTC)"
        );
        assert_eq!(
            localizer.card_memory_state_label(CardMemoryState::Relearning),
            "reaprendiendo"
        );
        assert_eq!(
            localizer.duration_unit_label(DurationUnit::Day, true),
            "día"
        );
        assert_eq!(
            localizer.duration_unit_label(DurationUnit::Second, false),
            "segundos"
        );
        assert_eq!(
            localizer.duration_unit_label(DurationUnit::Week, false),
            "semanas"
        );
        assert_eq!(
            localizer.options_minimum_cards_label(),
            "Mínimo de cartas por sesión"
        );
        assert_eq!(localizer.options_deck_backups_label(), "Copias del mazo");
        assert_eq!(localizer.deck_backup_full_choice(), "Completas");
        assert_eq!(localizer.deck_backup_last_only_choice(), "Solo la última");
        assert_eq!(localizer.deck_backup_none_choice(), "Ninguna");
        assert_eq!(
            localizer.options_review_db_backups_label(),
            "Copias de la base de datos"
        );
        assert_eq!(
            localizer.options_sound_notifications_label(),
            "Avisos sonoros"
        );
        assert_eq!(
            localizer.options_review_audio_label(),
            "Audio durante los repasos"
        );
        assert_eq!(
            localizer.options_calibration_recall_rule_label(),
            "Regla de recuerdo para la calibración"
        );
        assert_eq!(localizer.sound_notification_never_choice(), "Nunca");
        assert_eq!(localizer.sound_notification_always_choice(), "Siempre");
        assert_eq!(
            localizer.sound_notification_when_visible_choice(),
            "Cuando Cosimo esté visible"
        );
        assert_eq!(
            localizer.sound_notification_when_in_tray_choice(),
            "Cuando Cosimo esté en el área de notificación"
        );
        assert_eq!(localizer.review_audio_off_choice(), "Sin audio");
        assert_eq!(localizer.review_audio_manual_choice(), "Manual");
        assert_eq!(localizer.review_audio_automatic_choice(), "Automático");
        assert_eq!(
            localizer.options_minimize_to_tray_checkbox(),
            "Minimizar al área de notificación"
        );
        assert_eq!(localizer.tray_restore_menu_item(), "&Restaurar");
        assert_eq!(
            localizer.review_db_backup_on_session_start_choice(),
            "Al comenzar sesión"
        );
        assert_eq!(localizer.review_db_backup_off_choice(), "Desactivadas");
        assert!(
            localizer
                .could_not_backup_review_database_status("reviews.sqlite3", "backups", "error")
                .contains("base de datos de repaso")
        );
        assert!(
            localizer
                .could_not_backup_review_database("reviews.sqlite3", "backups", "error")
                .contains("base de datos de repaso")
        );
        assert!(
            localizer
                .could_not_preview_scheduled_cards_status("error")
                .contains("cartas programadas")
        );
        assert!(
            localizer
                .ready_to_start_summary_text(2, "reviews.sqlite3", 0, Some(0), Some(0))
                .contains("No hay cartas pendientes.")
        );
        assert!(
            localizer
                .no_cards_scheduled_to_start_pass(0)
                .contains("No hay cartas pendientes")
        );

        let summary = localizer.cycle_summary_text(CycleSummaryText {
            reviewed_cards: 2,
            unique_reviewed_cards: 1,
            total_cards: 2,
            elapsed_seconds: 61,
            ended_early: false,
            was_scheduled: true,
            prompt_guess_counts: [0, 1, 0, 1, 0],
            recall_feedback_counts: [0, 0, 2, 0],
        });
        assert!(summary.contains("Cartas únicas repasadas: 1"));
        assert!(summary.contains("Preguntas respondidas: 2 de 2"));
        assert!(summary.contains("Tiempo total: 1 minuto 1 segundo"));
    }

    #[test]
    fn spanish_menu_copy_is_localized() {
        let localizer = Localizer::new(Language::Spanish);

        assert_eq!(localizer.file_menu(), "&Archivo");
        assert_eq!(localizer.cards_menu(), "&Cartas");
        assert_eq!(localizer.help_menu(), "A&yuda");
        assert_eq!(localizer.open_deck_menu_item(), "&Abrir mazo\tCtrl-A");
        assert_eq!(localizer.recent_decks_menu_item(), "&Mazos recientes");
        assert_eq!(localizer.options_menu_item(), "&Opciones");
        assert_eq!(
            localizer.deck_properties_menu_item(),
            "Propiedades d&el mazo"
        );
        assert_eq!(
            localizer.deck_properties_dialog_title(),
            "Propiedades del mazo"
        );
        assert_eq!(
            localizer.deck_properties_dialog_status(),
            "Propiedades del mazo actual."
        );
        assert_eq!(
            localizer.edit_deck_properties_menu_item(),
            "Editar me&tadatos del mazo"
        );
        assert_eq!(
            localizer.deck_properties_editor_title(),
            "Editar propiedades del mazo"
        );
        assert_eq!(
            localizer.deck_properties_editor_status(),
            "Editar metadatos del mazo."
        );
        assert_eq!(
            localizer.deck_properties_editor_single_line_status(),
            "Los campos de metadatos deben ocupar una sola línea."
        );
        assert_eq!(
            localizer.deck_properties_label(DeckPropertiesLabel::DeckVersion),
            "Versión del mazo"
        );
        assert_eq!(
            localizer.deck_properties_label(DeckPropertiesLabel::Title),
            "Título"
        );
        assert_eq!(
            localizer.scrub_review_database_menu_item(),
            "Depurar base de &datos de repaso"
        );
        assert_eq!(
            localizer.reclaim_database_space_menu_item(),
            "Recuperar es&pacio de la base de datos"
        );
        assert_eq!(localizer.quit_menu_item(), "&Salir");
        assert_eq!(localizer.add_card_menu_item(), "Aña&dir carta\tCtrl-N");
        assert_eq!(
            localizer.add_card_batch_menu_item(),
            "Añadir cartas en &bloque"
        );
        assert_eq!(
            localizer.main_status_bar_due_now(12, 3),
            "Cartas: 12 en total; pendientes: 3; repaso pendiente ahora."
        );
        assert_eq!(
            localizer.main_status_bar_next_review(12, "2 días"),
            "Cartas: 12 en total; pendientes: 0; próximo repaso en 2 días."
        );
        assert_eq!(localizer.ready_card_filter_flagged(), "Cartas marcadas");
        assert_eq!(localizer.ready_card_flagged_suffix(), "(marcada)");
        assert_eq!(localizer.flagged_card_status(), "Carta marcada.");
        assert_eq!(localizer.unflagged_card_status(), "Carta desmarcada.");
        assert_eq!(
            localizer.deferred_card_status(),
            "Carta aplazada hasta mañana."
        );
        assert_eq!(
            localizer.cleared_card_deferral_status(),
            "Aplazamiento de carta quitado."
        );
        assert_eq!(
            localizer.edit_selected_card_menu_item(),
            "&Editar carta seleccionada\tCtrl-E"
        );
        assert_eq!(
            localizer.edit_current_card_menu_item(),
            "&Editar carta actual\tCtrl-E"
        );
        assert_eq!(
            localizer.remove_selected_card_menu_item(),
            "E&liminar carta seleccionada\tDelete"
        );
        assert_eq!(
            localizer.flag_selected_card_menu_item(),
            "Mar&car carta seleccionada\tCtrl-F"
        );
        assert_eq!(
            localizer.unflag_selected_card_menu_item(),
            "Desmar&car carta seleccionada\tCtrl-F"
        );
        assert_eq!(
            localizer.flag_current_card_menu_item(),
            "Mar&car carta actual\tCtrl-F"
        );
        assert_eq!(
            localizer.unflag_current_card_menu_item(),
            "Desmar&car carta actual\tCtrl-F"
        );
        assert_eq!(
            localizer.review_flagged_cards_menu_item(),
            "Repasar cartas &marcadas"
        );
        assert_eq!(
            localizer.defer_selected_card_until_tomorrow_menu_item(),
            "Apla&zar carta seleccionada hasta mañana\tCtrl-D"
        );
        assert_eq!(
            localizer.clear_selected_card_deferral_menu_item(),
            "Quitar apla&zamiento de carta seleccionada\tCtrl-D"
        );
        assert_eq!(
            localizer.defer_current_card_until_tomorrow_button_label(),
            "Aplazar carta hasta mañana"
        );
        assert_eq!(
            localizer.no_cards_reviewed_caption(),
            "No se repasó ninguna carta"
        );
        assert_eq!(
            localizer.no_cards_reviewed_message(),
            "No se repasó ninguna carta en esta pasada."
        );
        assert_eq!(localizer.flag_card_button_label(), "Marcar carta");
        assert_eq!(localizer.unflag_card_button_label(), "Desmarcar carta");
        assert_eq!(
            localizer.make_selected_card_reversible_menu_item(),
            "Hacer carta seleccionada re&versible\tCtrl-R"
        );
        assert_eq!(
            localizer.remove_reverse_card_menu_item(),
            "Eliminar carta in&versa\tCtrl-R"
        );
        assert_eq!(
            localizer.mark_selected_card_non_reversible_menu_item(),
            "Marcar carta seleccionada como &no reversible"
        );
        assert_eq!(
            localizer.allow_selected_card_reversal_menu_item(),
            "Permitir &invertir la carta seleccionada"
        );
        assert_eq!(
            localizer.make_eligible_cards_reversible_menu_item(),
            "Hacer cartas reversibles &aptas"
        );
        assert_eq!(
            localizer.undo_last_bulk_reversal_menu_item(),
            "Des&hacer última reversión en bloque"
        );
        assert!(
            !localizer
                .make_eligible_cards_reversible_menu_item()
                .contains('\t')
        );
        assert_eq!(
            localizer.marked_cards_reversible_status(2),
            "Se marcaron 2 cartas como reversibles."
        );
        assert!(
            localizer
                .make_eligible_cards_reversible_message(2, false)
                .contains("no reversibles")
        );
        assert_eq!(
            localizer.cannot_reverse_excluded_card(),
            "Esta carta está marcada como no reversible."
        );
        assert!(
            localizer
                .make_eligible_cards_reversible_message(2, true)
                .contains("copia conservada")
        );
        assert!(
            localizer
                .undo_last_bulk_reversal_message(2, true)
                .contains("2 cartas inversas")
        );
        assert_eq!(
            localizer.removed_latest_bulk_reversal_status(2),
            "Se eliminaron 2 cartas inversas de la última reversión en bloque."
        );
        assert!(
            localizer
                .scrub_review_database_message(true, false)
                .contains("irreversible")
        );
        assert!(
            localizer
                .scrub_review_database_message(false, true)
                .contains("copia conservada")
        );
        assert_eq!(
            localizer.scrubbed_review_database_status(2),
            "Se depuró la base de datos de repaso y se eliminaron 2 filas inactivas."
        );
        assert_eq!(
            localizer.reclaim_database_space_caption(),
            "Recuperar espacio de la base de datos"
        );
        assert!(
            localizer
                .reclaim_database_space_message(true)
                .contains("copias de la base de datos de repaso")
        );
        assert!(
            !localizer
                .reclaim_database_space_message(false)
                .contains("copia retenida")
        );
        assert_eq!(
            localizer.reclaimed_database_space_status(),
            "Se recuperó espacio de la base de datos de repaso."
        );
        assert_eq!(localizer.review_flagged_cards_button(), "Repasar marcadas");
        assert_eq!(
            localizer.started_flagged_pass(2),
            "Se inició una pasada no programada por 2 cartas marcadas."
        );
        assert_eq!(
            localizer.no_flagged_cards_available_to_review(),
            "No hay cartas marcadas disponibles para repasar."
        );
        assert!(
            localizer
                .remove_reversible_parent_card_message("Pregunta")
                .contains("carta inversa generada")
        );
        assert!(
            localizer
                .remove_reverse_card_message("Respuesta")
                .contains("carta inversa seleccionada")
        );
        assert_eq!(localizer.user_guide_menu_item(), "&Guía de usuario");
        assert_eq!(localizer.user_guide_html_path(), "docs/user-guide-es.html");
        assert_eq!(localizer.report_bug_menu_item(), "Informar de &error");
        assert_eq!(
            localizer.report_bug_url(),
            "https://modulus.isonomia.net/cosimo.cgi/tktnew"
        );
        assert_eq!(localizer.project_wiki_menu_item(), "&Wiki del proyecto");
        assert_eq!(
            localizer.project_wiki_url(),
            "https://modulus.isonomia.net/cosimo.cgi/wiki?name=home-es"
        );
        assert_eq!(localizer.about_menu_item(), "&Acerca de Cosimo");
        assert_eq!(localizer.about_caption(), "Acerca de Cosimo");
        assert!(localizer.about_message().contains("David Picón Álvarez"));
        assert!(
            localizer
                .about_message()
                .contains(&format!("Versión actual: {}", env!("CARGO_PKG_VERSION")))
        );
        assert!(localizer.about_message().contains("Estado: beta"));
        assert!(
            localizer
                .about_message()
                .contains("@modulux@node.isonomia.net")
        );
        assert_eq!(localizer.edit_revealed_card_button_label(), "Editar carta");
    }

    #[test]
    fn spanish_add_card_mnemonic_does_not_clash_with_open_deck() {
        let localizer = Localizer::new(Language::Spanish);

        assert_eq!(localizer.open_deck_menu_item(), "&Abrir mazo\tCtrl-A");
        assert_eq!(localizer.add_card_menu_item(), "Aña&dir carta\tCtrl-N");
        assert_eq!(localizer.ready_add_card_button(), "Aña&dir carta");
    }

    #[test]
    fn spanish_start_mnemonic_does_not_clash_with_cards_menu() {
        let localizer = Localizer::new(Language::Spanish);

        assert_eq!(localizer.cards_menu(), "&Cartas");
        assert_eq!(localizer.start_button(), "Co&menzar");
    }

    #[test]
    fn exam_mode_copy_is_localized_for_canonical_languages() {
        let spanish = Localizer::new(Language::Spanish);
        let galician = Localizer::new(Language::Galician);

        assert_eq!(spanish.exam_exact_checkbox_label(), "Exacto");
        assert_eq!(
            spanish.exam_review_inexact_caption(),
            "Revisar respuesta inexacta"
        );
        assert_eq!(spanish.exam_next_button(), "Siguie&nte");
        assert_eq!(spanish.exam_summary_button(), "Resumen");
        assert!(
            spanish
                .saved_exam_report_status("reports/exam.txt")
                .contains("Informe de examen guardado")
        );
        assert!(
            spanish
                .exam_report_text("deck", "reviews", "inicio", "fin", "resumen")
                .contains("Informe de examen")
        );

        assert_eq!(galician.exam_exact_checkbox_label(), "Exacto");
        assert_eq!(
            galician.exam_review_inexact_caption(),
            "Revisar resposta inexacta"
        );
        assert_eq!(galician.exam_next_button(), "Segui&nte");
        assert_eq!(galician.exam_summary_button(), "Resumo");
        assert!(
            galician
                .saved_exam_report_status("reports/exam.txt")
                .contains("Informe de exame gardado")
        );
        assert!(
            galician
                .exam_report_text("deck", "reviews", "inicio", "fin", "resumo")
                .contains("Informe de exame")
        );
    }

    #[test]
    fn galician_review_flagged_copy_is_localized() {
        let localizer = Localizer::new(Language::Galician);

        assert_eq!(
            localizer.review_flagged_cards_menu_item(),
            "Repasar cartas &marcadas"
        );
        assert_eq!(localizer.review_flagged_cards_button(), "Repasar marcadas");
        assert_eq!(
            localizer.started_flagged_pass(2),
            "Iniciouse unha pasada non programada por 2 cartas marcadas."
        );
        assert_eq!(
            localizer.no_flagged_cards_available_to_review(),
            "Non hai cartas marcadas dispoñibles para repasar."
        );
        assert_eq!(localizer.report_bug_menu_item(), "Informar dun &erro");
        assert_eq!(localizer.project_wiki_menu_item(), "&Wiki do proxecto");
        assert_eq!(
            localizer.project_wiki_url(),
            "https://modulus.isonomia.net/cosimo.cgi/wiki?name=home-gl"
        );
    }
}
