use std::path::Path;

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

use crate::time_format::format_elapsed_duration_complete;

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,
    pub(crate) time_limit: Option<&'a str>,
    pub(crate) timed_out: bool,
}

pub(crate) struct QuizSummaryText<'a> {
    pub(crate) total_questions: usize,
    pub(crate) submitted_answers: 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(crate) const ALL: &'static [Self] = &[Self::English, Self::Spanish, Self::Galician];

    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; PromptGuess::COUNT],
    pub recall_feedback_counts: [usize; RecallFeedback::COUNT],
}

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

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DeckPropertiesLabel {
    DeckVersion,
    DeckUuid,
    Title,
    Author,
    Description,
    Url,
    Visibility,
    PromptVoice,
    PromptVoiceEngine,
    ResponseVoice,
    ResponseVoiceEngine,
    AudioGeneration,
    SyncOwnerIdentity,
}

#[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 }
    }

    pub(crate) 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)
    }

    pub(crate) 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("&Options")
    }

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

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

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

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

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

    pub fn generate_audio_menu_item(self) -> &'static str {
        self.gettext("&Generate Audio")
    }

    pub fn remove_inoperative_audio_menu_item(self) -> &'static str {
        self.gettext("Remove &Inoperative Audio from Deck")
    }

    pub fn export_deck_bundle_menu_item(self) -> &'static str {
        self.gettext("E&xport Full Deck...")
    }

    pub fn export_deck_bundle_with_study_history_menu_item(self) -> &'static str {
        self.gettext("Export Full Deck with Study &History...")
    }

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

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

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

    pub fn sync_status_menu_item(self) -> &'static str {
        self.gettext("Sync &Status")
    }

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

    pub fn sync_login_menu_item(self) -> &'static str {
        self.gettext("&Log In...")
    }

    pub fn sync_logout_menu_item(self) -> &'static str {
        self.gettext("Log &Out")
    }

    pub fn sync_register_account_menu_item(self) -> &'static str {
        self.gettext("Register &New Account...")
    }

    pub fn sync_invalidate_token_menu_item(self) -> &'static str {
        self.gettext("Invalidate This &Token")
    }

    pub fn sync_invalidate_all_tokens_menu_item(self) -> &'static str {
        self.gettext("Invalidate &All Account Tokens...")
    }

    pub fn sync_delete_account_menu_item(self) -> &'static str {
        self.gettext("Delete Syn&c Account Data...")
    }

    pub fn sync_upload_menu_item(self) -> &'static str {
        self.gettext("&Upload with Study History")
    }

    pub fn sync_download_menu_item(self) -> &'static str {
        self.gettext("&Download Synced Deck")
    }

    pub fn sync_download_confirm_caption(self) -> &'static str {
        self.gettext("Download Synced Deck?")
    }

    pub fn sync_download_confirm_new_deck_message(self, sync_directory: &str) -> String {
        self.format_message(
            "Download and restore this deck from the sync target?\n\nSync target: {sync_directory}\n\nNo local deck with this sync ID was found. Cosimo will restore it as a separate deck and will avoid overwriting existing deck files.",
            &[("sync_directory", sync_directory.to_string())],
        )
    }

    pub fn sync_download_confirm_replace_deck_message(
        self,
        sync_directory: &str,
        deck_path: &str,
        replaces_review_database: bool,
    ) -> String {
        let replacement_scope = if replaces_review_database {
            self.gettext(
                "This will replace the local deck file, audio files, and review database for that deck.",
            )
        } else {
            self.gettext(
                "This will replace the local deck file and audio files for that deck. The local review database will be kept.",
            )
        };
        self.format_message(
            "Download and restore this deck from the sync target?\n\nSync target: {sync_directory}\n\nLocal deck to replace: {deck_path}\n\n{replacement_scope} Cosimo will write a full compressed backup before replacing existing deck files.",
            &[
                ("sync_directory", sync_directory.to_string()),
                ("deck_path", deck_path.to_string()),
                ("replacement_scope", replacement_scope.to_string()),
            ],
        )
    }

    pub fn sync_delete_confirm_caption(self) -> &'static str {
        self.gettext("Delete Synced Deck?")
    }

    pub fn sync_delete_confirm_message(self, deck_label: &str) -> String {
        self.format_message(
            "Delete this deck from the sync server?\n\n{deck_label}\n\nThis deletes the remote deck metadata and all stored sync bundles for this deck. It does not delete your local deck.",
            &[("deck_label", deck_label.to_string())],
        )
    }

    pub fn sync_delete_finished_caption(self) -> &'static str {
        self.gettext("Synced Deck Deleted")
    }

    pub fn sync_delete_finished_message(self, deck_label: &str) -> String {
        self.format_message(
            "Deleted the synced deck.\n\n{deck_label}",
            &[("deck_label", deck_label.to_string())],
        )
    }

    pub fn sync_delete_owner_required_message(self) -> &'static str {
        self.gettext("Only decks owned by the logged-in sync account can be deleted.")
    }

    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 wipe_backup_directory_menu_item(self) -> &'static str {
        self.gettext("&Wipe Backup Directory")
    }

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

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

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

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

    pub fn study_menu(self) -> &'static str {
        self.gettext("St&udy")
    }

    pub fn tools_menu(self) -> &'static str {
        self.gettext("T&ools")
    }

    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 import_anki_deck_menu_item(self) -> &'static str {
        self.gettext("Import &Anki Deck...")
    }

    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 selected_card_details_menu_item(self) -> &'static str {
        self.gettext("Card De&tails\tAlt-Enter")
    }

    pub fn resolve_duplicate_prompts_menu_item(self) -> &'static str {
        self.gettext("Resolve &Duplicate Prompts")
    }

    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_all_shown_cards_menu_item(self) -> &'static str {
        self.gettext("Flag All S&hown Cards")
    }

    pub fn unflag_all_shown_cards_menu_item(self) -> &'static str {
        self.gettext("&Unflag All Shown Cards")
    }

    pub fn suspend_all_shown_cards_menu_item(self) -> &'static str {
        self.gettext("Suspend All Sho&wn Cards")
    }

    pub fn unsuspend_all_shown_cards_menu_item(self) -> &'static str {
        self.gettext("Unsuspend All Shown &Cards")
    }

    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("Study Fla&gged")
    }

    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("Add Re&verse Card\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("Clear Selected Card &Non-Reversible Mark")
    }

    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 Recall")
    }

    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: release candidate\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")
    }

    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_filter_problem_cards(self) -> &'static str {
        self.gettext("Problem cards")
    }

    pub fn ready_card_filter_asymmetry_issues(self) -> &'static str {
        self.gettext("Asymmetry issues")
    }

    pub fn ready_card_filter_high_confidence_failures(self) -> &'static str {
        self.gettext("High-confidence failures")
    }

    pub fn ready_card_filter_low_confidence_successes(self) -> &'static str {
        self.gettext("Low-confidence successes")
    }

    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("Study Flagged")
    }

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

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

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

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

    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 session 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_timed_checkbox_label(self) -> &'static str {
        self.gettext("Timed exam")
    }

    pub fn exam_time_limit_label(self) -> &'static str {
        self.gettext("Time limit in minutes")
    }

    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_invalid_time_limit_status(self) -> &'static str {
        self.gettext("Enter a whole number of minutes greater than zero.")
    }

    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_timeout_caption(self) -> &'static str {
        self.gettext("Time Is Out")
    }

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

    pub fn exam_timeout_message(self, submitted_answers: usize) -> String {
        let template = self.ngettext(
            "Time is out.\n\n{submitted_answers} answer has been submitted.",
            "Time is out.\n\n{submitted_answers} answers have been submitted.",
            submitted_answers,
        );
        self.format_message(
            template,
            &[("submitted_answers", submitted_answers.to_string())],
        )
    }

    pub fn exam_finished_message(self, submitted_answers: usize) -> String {
        let template = self.ngettext(
            "Exam is finished.\n\n{submitted_answers} answer has been submitted.",
            "Exam is finished.\n\n{submitted_answers} answers have been submitted.",
            submitted_answers,
        );
        self.format_message(
            template,
            &[("submitted_answers", submitted_answers.to_string())],
        )
    }

    pub fn exam_time_remaining_status(self, remaining: &str) -> String {
        self.format_message(
            "Exam time remaining: {remaining}",
            &[("remaining", remaining.to_string())],
        )
    }

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

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

    pub fn quiz_setup_status(self, active_card_count: usize) -> String {
        self.format_message(
            "Active cards available for quiz: {active_card_count}.",
            &[("active_card_count", active_card_count.to_string())],
        )
    }

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

    pub fn quiz_study_direction_label(self) -> &'static str {
        self.gettext("Study direction")
    }

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

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

    pub fn quiz_study_direction_both_choice(self) -> &'static str {
        self.gettext("Both directions")
    }

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

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

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

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

    pub fn quiz_invalid_size_status(self) -> &'static str {
        self.gettext("Enter a positive number of quiz questions.")
    }

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

    pub fn quiz_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 quiz_choices_heading(self) -> &'static str {
        self.gettext("Choices")
    }

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

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

    pub fn quiz_choose_answer_status(self) -> &'static str {
        self.gettext("Choose an answer before submitting.")
    }

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

    pub fn quiz_review_status(
        self,
        current_answer: usize,
        total_answers: usize,
        is_correct: bool,
    ) -> String {
        let result = if is_correct {
            self.gettext("correct")
        } else {
            self.gettext("incorrect")
        };
        self.format_message(
            "Question {current_answer} of {total_answers}: {result}.",
            &[
                ("current_answer", current_answer.to_string()),
                ("total_answers", total_answers.to_string()),
                ("result", result.to_string()),
            ],
        )
    }

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

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

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

    pub fn quiz_summary_text(self, summary: QuizSummaryText<'_>) -> String {
        let ended_early_text = if summary.ended_early {
            self.gettext("Yes")
        } else {
            self.gettext("No")
        };
        self.format_message(
            "Quiz summary\n\nQuestions selected: {total_questions}\nAnswers submitted: {submitted_answers}\nCorrect answers: {correct_answers} ({correct_percentage}%)\nIncorrect answers: {incorrect_answers}\nTotal time: {elapsed}\nTime per question: {time_per_question}\nEnded early: {ended_early}\n\nThis quiz 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()),
                ("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 exam_summary_text(self, summary: ExamSummaryText<'_>) -> String {
        let ended_early_text = if summary.ended_early {
            self.gettext("Yes")
        } else {
            self.gettext("No")
        };
        let timed_out_text = if summary.timed_out {
            self.gettext("Yes")
        } else {
            self.gettext("No")
        };
        let time_limit_line = summary
            .time_limit
            .map(|time_limit| {
                self.format_message(
                    self.gettext("\nTime limit: {time_limit}\nTime expired: {timed_out}"),
                    &[
                        ("time_limit", time_limit.to_string()),
                        ("timed_out", timed_out_text.to_string()),
                    ],
                )
            })
            .unwrap_or_default();
        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}{time_limit_line}\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()),
                ("time_limit_line", time_limit_line),
            ],
        )
    }

    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 deck_properties_editor_voice_engine_status(self) -> &'static str {
        self.gettext("Select onecore or sapi before saving a manually edited voice.")
    }

    pub fn deck_properties_make_independent_copy_button(self) -> &'static str {
        self.gettext("Make &Independent Copy")
    }

    pub fn deck_properties_make_independent_copy_caption(self) -> &'static str {
        self.gettext("Make Independent Copy")
    }

    pub fn deck_properties_make_independent_copy_message(self) -> &'static str {
        self.gettext(
            "This will give the deck a new sync identity and reset visibility to Private before you save. Cards, audio, and local study history are kept, but local sync revision information for the old identity is cleared. Continue?",
        )
    }

    pub fn deck_properties_make_independent_copy_status(self) -> &'static str {
        self.gettext("A new deck identity has been prepared and visibility was reset to Private.")
    }

    pub fn deck_properties_make_independent_copy_error_status(self, error: &str) -> String {
        self.format_message(
            "Could not create an independent deck UUID: {error}",
            &[("error", error.to_string())],
        )
    }

    pub fn deck_properties_manual_audio_checkbox(self) -> &'static str {
        self.gettext("Manual audio deck (protect against automatic audio generation)")
    }

    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_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 fn card_details_current_datetime(self, timestamp: &str) -> String {
        self.format_message("now ({timestamp})", &[("timestamp", timestamp.to_string())])
    }

    pub fn card_details_availability_new(self) -> &'static str {
        self.gettext("new card; available now")
    }

    pub fn card_details_availability_due(self, scheduled_at: &str) -> String {
        self.format_message(
            "available now; scheduled {scheduled_at}",
            &[("scheduled_at", scheduled_at.to_string())],
        )
    }

    pub fn card_details_availability_learning_step_due(self, scheduled_at: &str) -> String {
        self.format_message(
            "learning step available now; scheduled {scheduled_at}",
            &[("scheduled_at", scheduled_at.to_string())],
        )
    }

    pub fn card_details_availability_scheduled(self, scheduled_at: &str) -> String {
        self.format_message(
            "scheduled {scheduled_at}",
            &[("scheduled_at", scheduled_at.to_string())],
        )
    }

    pub fn card_details_availability_learning_step(self, scheduled_at: &str) -> String {
        self.format_message(
            "learning step {scheduled_at}",
            &[("scheduled_at", scheduled_at.to_string())],
        )
    }

    pub fn card_details_recent_recall(
        self,
        recalled: usize,
        considered: usize,
        percent: &str,
    ) -> String {
        self.format_message(
            "{recalled} of last {considered} recalled ({percent})",
            &[
                ("recalled", recalled.to_string()),
                ("considered", considered.to_string()),
                ("percent", percent.to_string()),
            ],
        )
    }

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

    pub fn card_details_lapse_summary_one(self) -> &'static str {
        self.gettext("one lapse recorded")
    }

    pub fn card_details_lapse_summary_multiple(self, lapses: u32) -> String {
        self.format_message(
            "{lapses} lapses recorded; repeatedly relearned",
            &[("lapses", lapses.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 attempts",
            CardDetailsLabel::LastReview => "Last attempt",
            CardDetailsLabel::LastConfidenceRating => "Last confidence rating",
            CardDetailsLabel::LastRecallRating => "Last recall rating",
            CardDetailsLabel::Availability => "Availability",
            CardDetailsLabel::EstimatedRecallNow => "Estimated recall now",
            CardDetailsLabel::RecentRecall => "Recent recall",
            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::LapseSummary => "Lapse summary",
            CardDetailsLabel::ScheduleLastReviewed => "Schedule last updated",
            CardDetailsLabel::ScheduleLastRecallRating => "Schedule last recall rating",
            CardDetailsLabel::StoredSchedule => "Stored schedule",
            CardDetailsLabel::ReviewHistory => "Attempt 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::DeckUuid => "Deck UUID",
            DeckPropertiesLabel::Title => "Title",
            DeckPropertiesLabel::Author => "Author",
            DeckPropertiesLabel::Description => "Description",
            DeckPropertiesLabel::Url => "URL",
            DeckPropertiesLabel::Visibility => "Visibility",
            DeckPropertiesLabel::PromptVoice => "Prompt voice",
            DeckPropertiesLabel::PromptVoiceEngine => "Prompt voice engine",
            DeckPropertiesLabel::ResponseVoice => "Response voice",
            DeckPropertiesLabel::ResponseVoiceEngine => "Response voice engine",
            DeckPropertiesLabel::AudioGeneration => "Audio generation",
            DeckPropertiesLabel::SyncOwnerIdentity => "Sync account",
        })
    }

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

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

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

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

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

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

    pub(crate) fn learning_dashboard_workload_text(
        self,
        due_now: &str,
        due_within_24_hours: &str,
        due_within_3_days: &str,
        due_within_7_days: &str,
        due_within_30_days: &str,
    ) -> String {
        self.format_message(
            "Available now: {due_now}\nDue within 24 hours: {due_within_24_hours}\nDue within 3 days: {due_within_3_days}\nDue within 7 days: {due_within_7_days}\nDue within 30 days: {due_within_30_days}",
            &[
                ("due_now", due_now.to_string()),
                ("due_within_24_hours", due_within_24_hours.to_string()),
                ("due_within_3_days", due_within_3_days.to_string()),
                ("due_within_7_days", due_within_7_days.to_string()),
                ("due_within_30_days", due_within_30_days.to_string()),
            ],
        )
    }

    pub(crate) fn learning_dashboard_forecast_count_with_time(
        self,
        count: usize,
        estimated_time: &str,
    ) -> String {
        self.format_message(
            "{count} (estimated time: {estimated_time})",
            &[
                ("count", count.to_string()),
                ("estimated_time", estimated_time.to_string()),
            ],
        )
    }

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

    pub fn stats_dialog_status(self) -> &'static str {
        self.gettext("Study session summary.")
    }

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

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

    pub fn copy_report_button(self) -> &'static str {
        self.gettext("&Copy to Clipboard")
    }

    pub fn saved_review_report_status(self, path: &str) -> String {
        self.format_message(
            "Saved study session 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_quiz_report_status(self, path: &str) -> String {
        self.format_message("Saved quiz 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 copied_learning_dashboard_report_status(self) -> &'static str {
        self.gettext("Copied learning dashboard report to the clipboard.")
    }

    pub fn could_not_copy_learning_dashboard_report_status(self) -> &'static str {
        self.gettext("Could not copy learning dashboard report to the clipboard.")
    }

    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_quiz_report_status(self, path: &str, error: &str) -> String {
        self.format_message(
            "Could not save quiz 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(
            "Study session 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 quiz_report_text(
        self,
        deck_path: &str,
        review_db_path: &str,
        started_at: &str,
        finished_at: &str,
        summary: &str,
    ) -> String {
        self.format_message(
            "Quiz 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_new_cards_per_day_label(self) -> &'static str {
        self.gettext("New cards per day")
    }

    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_automatic_full_deck_backups_label(self) -> &'static str {
        self.gettext("Automatic full deck 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 study")
    }

    pub fn options_exam_post_answer_audio_label(self) -> &'static str {
        self.gettext("Automatically play audio during exam grading and review")
    }

    pub fn options_quiz_audio_label(self) -> &'static str {
        self.gettext("Automatically play audio during quizzes")
    }

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

    pub fn options_sync_server_url_label(self) -> &'static str {
        self.gettext("Sync server URL")
    }

    pub fn options_sync_server_url_single_line_status(self) -> &'static str {
        self.gettext("Sync server URL must be a single line.")
    }

    pub fn options_local_device_name_label(self) -> &'static str {
        self.gettext("Local device name")
    }

    pub fn options_local_device_name_single_line_status(self) -> &'static str {
        self.gettext("Local device name must be a single line.")
    }

    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_review_audio_cues_checkbox(self) -> &'static str {
        self.gettext("Guiding tones for automatic audio")
    }

    pub fn options_audio_generation_format_label(self) -> &'static str {
        self.gettext("Audio generation format")
    }

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

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

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

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

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

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

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

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

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

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

    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 automatic_full_deck_backup_daily_choice(self) -> &'static str {
        self.gettext("Daily")
    }

    pub fn automatic_full_deck_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 prune_backups_caption(self) -> &'static str {
        self.gettext("Delete Old Backups")
    }

    pub fn prune_backups_message(self, removed_count: usize) -> String {
        self.format_message(
            "Delete obsolete deck backups?\n\nCosimo would delete {removed_count} backup files. Cosimo will keep only the newest ordinary deck backup and the newest automatic full deck backup for each original deck path. Ordinary deck backups may be renumbered. This cannot be undone.",
            &[("removed_count", removed_count.to_string())],
        )
    }

    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_non_reversible_checkbox(self) -> &'static str {
        self.gettext("Mark as non-reversible")
    }

    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 import_anki_deck_dialog_message(self) -> &'static str {
        self.gettext("Import Anki deck")
    }

    pub fn save_imported_deck_dialog_message(self) -> &'static str {
        self.gettext("Save imported Cosimo deck")
    }

    pub fn anki_deck_wildcard(self) -> &'static str {
        self.gettext("Anki deck package (*.apkg)|*.apkg|All files (*.*)|*.*")
    }

    pub fn export_deck_bundle_dialog_message(self) -> &'static str {
        self.gettext("Export full deck")
    }

    pub fn export_deck_bundle_with_study_history_dialog_message(self) -> &'static str {
        self.gettext("Export full deck with study history")
    }

    pub fn restore_deck_bundle_dialog_message(self) -> &'static str {
        self.gettext("Restore full deck")
    }

    pub fn deck_bundle_wildcard(self) -> &'static str {
        self.gettext("Cosimo deck bundle (*.zip)|*.zip|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 profile_already_locked(self, lock_path: &Path) -> String {
        self.format_message(
            "Another Cosimo instance is already running with this profile.\n\nLock file: {path}\n\nClose the other Cosimo window before starting a second copy. This protects the config file, deck file, review database, and sync state from conflicting writes.",
            &[("path", lock_path.display().to_string())],
        )
    }

    pub fn could_not_open_profile_lock(self, lock_path: &Path, source: &str) -> String {
        self.format_message(
            "Could not open the Cosimo profile lock {path}: {source}\n\nCosimo did not start because it cannot protect the profile from conflicting writes. Check that the folder is writable and that security software is not blocking the lock file.",
            &[
                ("path", lock_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 startup_recovery_caption(self) -> &'static str {
        self.gettext("No Deck Is Open")
    }

    pub fn startup_recovery_message(self, error: &str) -> String {
        self.format_message(
            "Cosimo could not finish startup.\n\n{error}\n\nChoose Yes to open a deck, or No to quit Cosimo.",
            &[("error", error.to_string())],
        )
    }

    pub fn ready_to_start_summary_text(
        self,
        total_cards: usize,
        review_db_path: &str,
        minimum_cards_per_session: usize,
        new_cards_per_day: 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}.\nNew cards per day: {new_cards_per_day}.\n{scheduled_text}\nReview database: {review_db_path}.\n\nStart: due cards, plus limited new cards, filled to minimum from already introduced cards.\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(),
                ),
                ("new_cards_per_day", new_cards_per_day.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 study session.")
    }

    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 or available under the new-card limit. Increase new cards per day in Options, use Force Start, or wait until more cards are due.",
            )
            .to_string()
        } else {
            self.format_message(
                "No cards could be scheduled for this study session. Minimum cards per session is {minimum_cards_per_session}, but new cards are limited by the new-cards-per-day option.",
                &[(
                    "minimum_cards_per_session",
                    minimum_cards_per_session.to_string(),
                )],
            )
        }
    }

    pub fn started_pass(self, total_cards: usize) -> String {
        self.format_message(
            "Started a study session 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 study session 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 study session through {total_cards} flagged card.",
            "Started an unscheduled study session 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 session with {total_cards} card.",
            "Started an exam session with {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 session with {total_cards} answer.",
            "Completed exam session with {total_cards} answers.",
            total_cards,
        );
        self.format_message(template, &[("total_cards", total_cards.to_string())])
    }

    pub fn started_quiz_pass(self, total_cards: usize) -> String {
        let template = self.ngettext(
            "Started a quiz session with {total_cards} question.",
            "Started a quiz session with {total_cards} questions.",
            total_cards,
        );
        self.format_message(template, &[("total_cards", total_cards.to_string())])
    }

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

    pub fn not_enough_distinct_answer_choices_for_quiz(
        self,
        distinct_answer_choices: usize,
    ) -> String {
        let template = self.ngettext(
            "Quiz mode needs at least 4 distinct answer choices in one study direction; this deck has {distinct_answer_choices}.",
            "Quiz mode needs at least 4 distinct answer choices in one study direction; this deck has {distinct_answer_choices}.",
            distinct_answer_choices,
        );
        self.format_message(
            template,
            &[(
                "distinct_answer_choices",
                distinct_answer_choices.to_string(),
            )],
        )
    }

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

    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 attempt {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 attempt {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 study session.",
            &[
                ("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 answer")
    }

    pub fn could_not_record_review_pass_status(self, review_db_path: &str, error: &str) -> String {
        self.format_message(
            "Warning: could not record the study session summary in {review_db_path}: {error}\n\nIndividual card answers were recorded, but the session-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 study 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 study session: {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_write_automatic_full_deck_backup_status(
        self,
        deck_path: &std::path::Path,
        backup_dir: &std::path::Path,
        error: &str,
    ) -> String {
        self.format_message(
            "Warning: could not write the automatic full deck backup for {deck_path} to {backup_dir}: {error}\n\nThe deck opened, but the daily full backup was not refreshed.",
            &[
                ("deck_path", deck_path.display().to_string()),
                ("backup_dir", backup_dir.display().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 to study.")
    }

    pub fn ended_pass_early_after(self, reviewed_cards: usize, total_cards: usize) -> String {
        self.format_message(
            "Ended the session early after studying {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 study session 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}; cards 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 scheduled 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 scheduled_study_session_status_bar(
        self,
        studied_cards: usize,
        total_cards: usize,
    ) -> String {
        self.format_message(
            "Scheduled study session: {studied_cards} of {total_cards} studied.",
            &[
                ("studied_cards", studied_cards.to_string()),
                ("total_cards", total_cards.to_string()),
            ],
        )
    }

    pub fn unscheduled_study_session_status_bar(
        self,
        studied_cards: usize,
        total_cards: usize,
    ) -> String {
        self.format_message(
            "Unscheduled study session: {studied_cards} of {total_cards} studied.",
            &[
                ("studied_cards", studied_cards.to_string()),
                ("total_cards", total_cards.to_string()),
            ],
        )
    }

    pub fn exam_status_bar(self, answered_questions: usize, total_questions: usize) -> String {
        self.format_message(
            "Exam: {answered_questions} of {total_questions} answered.",
            &[
                ("answered_questions", answered_questions.to_string()),
                ("total_questions", total_questions.to_string()),
            ],
        )
    }

    pub fn quiz_status_bar(self, answered_questions: usize, total_questions: usize) -> String {
        self.format_message(
            "Quiz: {answered_questions} of {total_questions} answered.",
            &[
                ("answered_questions", answered_questions.to_string()),
                ("total_questions", total_questions.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 no_duplicate_prompts_to_resolve(self) -> &'static str {
        self.gettext("There are no duplicate prompts to resolve.")
    }

    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 resolved_duplicate_prompts_status(self, removed_card_count: usize) -> String {
        let template = self.ngettext(
            "Resolved duplicate prompts. Removed {removed_card_count} card.",
            "Resolved duplicate prompts. Removed {removed_card_count} cards.",
            removed_card_count,
        );
        self.format_message(
            template,
            &[("removed_card_count", removed_card_count.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 duplicate_card_prompt(self) -> &'static str {
        self.gettext("A card with this prompt already exists.")
    }

    pub fn duplicate_prompts_warning(self, card_count: usize) -> String {
        let template = self.ngettext(
            "Warning: this deck has {card_count} card with a duplicate prompt. Use the duplicate prompts filter to find and edit it.",
            "Warning: this deck has {card_count} cards with duplicate prompts. Use the duplicate prompts filter to find and edit them.",
            card_count,
        );
        self.format_message(template, &[("card_count", card_count.to_string())])
    }

    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 wipe_backup_directory_caption(self) -> &'static str {
        self.gettext("Wipe Backup Directory")
    }

    pub fn wipe_backup_directory_message(self, backup_dir: &str) -> String {
        let template = self.gettext(
            "Delete every file and folder in the backup directory {backup_dir}? This includes files that may not have been generated or tracked by Cosimo. This cannot be undone.",
        );
        self.format_message(template, &[("backup_dir", backup_dir.to_string())])
    }

    pub fn wiped_backup_directory_status(self, removed_count: usize) -> String {
        let template = self.ngettext(
            "Deleted {removed_count} file or folder from the backup directory.",
            "Deleted {removed_count} files or folders from the backup directory.",
            removed_count,
        );
        self.format_message(template, &[("removed_count", removed_count.to_string())])
    }

    pub fn could_not_wipe_backup_directory_caption(self) -> &'static str {
        self.gettext("Could Not Wipe Backup Directory")
    }

    pub fn remove_inoperative_audio_caption(self) -> &'static str {
        self.gettext("Remove Inoperative Audio")
    }

    pub fn remove_inoperative_audio_message(
        self,
        sidecar_dir: &str,
        removed_card_count: usize,
        shadowed_format_count: usize,
    ) -> String {
        let template = self.gettext(
            "Remove inoperative audio files from {sidecar_dir}?\n\nFiles for cards no longer in the deck: {removed_card_count}\nFiles hidden by a preferred format: {shadowed_format_count}\n\nThis deletes files from the deck sidecar directory. This cannot be undone.",
        );
        self.format_message(
            template,
            &[
                ("sidecar_dir", sidecar_dir.to_string()),
                ("removed_card_count", removed_card_count.to_string()),
                ("shadowed_format_count", shadowed_format_count.to_string()),
            ],
        )
    }

    pub fn no_inoperative_audio_files_status(self) -> &'static str {
        self.gettext("No inoperative audio files were found.")
    }

    pub fn removed_inoperative_audio_status(
        self,
        removed_files: usize,
        removed_card_files: usize,
        shadowed_format_files: usize,
        manifest_entries_removed: usize,
    ) -> String {
        let template = self.gettext(
            "Inoperative audio files removed: {removed_files}.\nFiles for cards no longer in the deck: {removed_card_files}.\nFiles hidden by a preferred format: {shadowed_format_files}.\nManifest entries removed: {manifest_entries_removed}.",
        );
        self.format_message(
            template,
            &[
                ("removed_files", removed_files.to_string()),
                ("removed_card_files", removed_card_files.to_string()),
                ("shadowed_format_files", shadowed_format_files.to_string()),
                (
                    "manifest_entries_removed",
                    manifest_entries_removed.to_string(),
                ),
            ],
        )
    }

    pub fn could_not_remove_inoperative_audio_caption(self) -> &'static str {
        self.gettext("Could Not Remove Inoperative Audio")
    }

    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 flagged_shown_cards_status(self, card_count: usize) -> String {
        let template = self.ngettext(
            "Flagged {card_count} shown card.",
            "Flagged {card_count} shown cards.",
            card_count,
        );
        self.format_message(template, &[("card_count", card_count.to_string())])
    }

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

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

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

    pub fn ready_bulk_action_caption(self) -> &'static str {
        self.gettext("Apply Bulk Card Action")
    }

    pub fn confirm_flag_shown_cards_message(
        self,
        affected_card_count: usize,
        shown_card_count: usize,
    ) -> String {
        self.confirm_ready_bulk_action_message(
            "Flag {affected_card_count} of {shown_card_count} currently shown card? Cards already flagged will be left unchanged.",
            "Flag {affected_card_count} of {shown_card_count} currently shown cards? Cards already flagged will be left unchanged.",
            affected_card_count,
            shown_card_count,
        )
    }

    pub fn confirm_unflag_shown_cards_message(
        self,
        affected_card_count: usize,
        shown_card_count: usize,
    ) -> String {
        self.confirm_ready_bulk_action_message(
            "Unflag {affected_card_count} of {shown_card_count} currently shown card? Cards not flagged will be left unchanged.",
            "Unflag {affected_card_count} of {shown_card_count} currently shown cards? Cards not flagged will be left unchanged.",
            affected_card_count,
            shown_card_count,
        )
    }

    pub fn confirm_suspend_shown_cards_message(
        self,
        affected_card_count: usize,
        shown_card_count: usize,
    ) -> String {
        self.confirm_ready_bulk_action_message(
            "Suspend {affected_card_count} of {shown_card_count} currently shown card? Cards already suspended will be left unchanged.",
            "Suspend {affected_card_count} of {shown_card_count} currently shown cards? Cards already suspended will be left unchanged.",
            affected_card_count,
            shown_card_count,
        )
    }

    pub fn confirm_unsuspend_shown_cards_message(
        self,
        affected_card_count: usize,
        shown_card_count: usize,
    ) -> String {
        self.confirm_ready_bulk_action_message(
            "Unsuspend {affected_card_count} of {shown_card_count} currently shown card? Cards not suspended will be left unchanged.",
            "Unsuspend {affected_card_count} of {shown_card_count} currently shown cards? Cards not suspended will be left unchanged.",
            affected_card_count,
            shown_card_count,
        )
    }

    fn confirm_ready_bulk_action_message(
        self,
        singular_msgid: &'static str,
        plural_msgid: &'static str,
        affected_card_count: usize,
        shown_card_count: usize,
    ) -> String {
        let template = self.ngettext(singular_msgid, plural_msgid, shown_card_count);
        self.format_message(
            template,
            &[
                ("affected_card_count", affected_card_count.to_string()),
                ("shown_card_count", shown_card_count.to_string()),
            ],
        )
    }

    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 Studied")
    }

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

    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, true) => self.gettext("Ended a partial scheduled study session."),
            (true, false) => self.gettext("Ended a partial unscheduled study session."),
            (false, true) => self.gettext("Completed one scheduled study session."),
            (false, false) => self.gettext("Completed one unscheduled study session."),
        };
        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);
        let time_per_card = self.format_average_review_time(elapsed_seconds, reviewed_cards);

        let count_lines = if reviewed_cards == unique_reviewed_cards {
            self.format_message(
                "Cards studied: {reviewed_cards} of {total_cards}\nTotal time: {total_time}\nTime per card: {time_per_card}",
                &[
                    ("reviewed_cards", reviewed_cards.to_string()),
                    ("total_cards", total_cards.to_string()),
                    ("total_time", total_time),
                    ("time_per_card", time_per_card),
                ],
            )
        } else {
            self.format_message(
                "Unique cards studied: {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}",
                &[
                    (
                        "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),
                ],
            )
        };

        self.format_message(
            "{opening}\n\n{count_lines}\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()),
                ("count_lines", count_lines),
                (
                    "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 {
        format_elapsed_duration_complete(self, elapsed_seconds)
    }

    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 session.")
    }

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

    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 could_not_import_deck_caption(self) -> &'static str {
        self.gettext("Could not import deck")
    }

    pub fn could_not_start_cosimo_caption(self) -> &'static str {
        self.gettext("Could not start Cosimo")
    }

    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 audio_generation_progress_caption(self) -> &'static str {
        self.gettext("Generate Audio")
    }

    pub fn audio_generation_progress_preparing(self) -> &'static str {
        self.gettext("Preparing audio generation.")
    }

    pub fn audio_generation_finished_caption(self) -> &'static str {
        self.gettext("Audio Generation Finished")
    }

    #[allow(clippy::too_many_arguments)]
    pub fn audio_generation_finished_message(
        self,
        generated_cards: usize,
        generated_new_files: usize,
        regenerated_changed_files: usize,
        regenerated_untracked_files: usize,
        overwritten_files: usize,
        preserved_existing_files: usize,
        skipped_unchanged: usize,
        skipped_missing_id: usize,
        skipped_empty_text: usize,
        report_path: &str,
    ) -> String {
        let overwrite_line = if overwritten_files > 0 {
            self.format_message(
                "Files force-regenerated by explicit overwrite: {overwritten_files}\n",
                &[("overwritten_files", overwritten_files.to_string())],
            )
        } else {
            String::new()
        };
        let changed_line = if regenerated_changed_files > 0 {
            self.format_message(
                "Managed audio files regenerated or converted: {regenerated_changed_files}\n",
                &[(
                    "regenerated_changed_files",
                    regenerated_changed_files.to_string(),
                )],
            )
        } else {
            String::new()
        };
        let untracked_line = if regenerated_untracked_files > 0 {
            self.format_message(
                "Existing untracked audio files regenerated to create the manifest: {regenerated_untracked_files}\n",
                &[(
                    "regenerated_untracked_files",
                    regenerated_untracked_files.to_string(),
                )],
            )
        } else {
            String::new()
        };
        self.format_message(
            "Cosimo finished generating audio for the current deck.\n\nCards with generated audio: {generated_cards}\nNew audio files generated: {generated_new_files}\n{changed_line}{untracked_line}{overwrite_line}Existing untracked audio files preserved: {preserved_existing_files}\nSkipped already tracked audio files: {skipped_unchanged}\nSkipped cards without stable IDs: {skipped_missing_id}\nSkipped empty card sides: {skipped_empty_text}\n\nOutput log: {report_path}",
            &[
                ("generated_cards", generated_cards.to_string()),
                ("generated_new_files", generated_new_files.to_string()),
                ("changed_line", changed_line),
                ("untracked_line", untracked_line),
                ("overwrite_line", overwrite_line),
                (
                    "preserved_existing_files",
                    preserved_existing_files.to_string(),
                ),
                ("skipped_unchanged", skipped_unchanged.to_string()),
                ("skipped_missing_id", skipped_missing_id.to_string()),
                ("skipped_empty_text", skipped_empty_text.to_string()),
                ("report_path", report_path.to_string()),
            ],
        )
    }

    pub fn audio_generation_unknown_failure(self) -> String {
        self.gettext("Audio generation ended without reporting a result.")
            .to_string()
    }

    pub fn could_not_generate_audio_caption(self) -> &'static str {
        self.gettext("Could not generate audio")
    }

    pub fn could_not_generate_audio_message(self, error: &str) -> String {
        self.format_message(
            "Cosimo could not generate audio: {error}\n\nCheck that voice.exe is beside cosimo.exe and that the current deck file is available.",
            &[("error", error.to_string())],
        )
    }

    pub fn audio_generation_missing_metadata_message(self) -> &'static str {
        self.gettext("Audio generation requires prompt voice, response voice, prompt voice engine, and response voice engine metadata. Use Deck -> Edit Deck Metadata to choose the voices before generating audio.")
    }

    pub fn audio_generation_disabled_message(self) -> &'static str {
        self.gettext("Audio generation is disabled for this deck. It is marked as a manual audio deck to protect existing audio files. Use Deck -> Edit Deck Metadata to allow generation.")
    }

    pub fn could_not_generate_audio_with_report_message(
        self,
        error: &str,
        report_path: &str,
    ) -> String {
        self.format_message(
            "Cosimo could not generate audio: {error}\n\nOutput log: {report_path}",
            &[
                ("error", error.to_string()),
                ("report_path", report_path.to_string()),
            ],
        )
    }

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

    pub fn import_anki_deck_confirm_caption(self) -> &'static str {
        self.gettext("Import Deck?")
    }

    #[allow(clippy::too_many_arguments)]
    pub fn import_anki_deck_confirmation_message(
        self,
        source_path: &str,
        destination_path: &str,
        imported_cards: usize,
        skipped_unsupported_notes: usize,
        skipped_empty_notes: usize,
        skipped_malformed_notes: usize,
        removed_sound_references: usize,
        imported_audio_files: usize,
        skipped_audio_references: usize,
    ) -> String {
        self.format_message(
            "Import this Anki deck?\n\nSource:\n{source_path}\n\nDestination:\n{destination_path}\n\nCards to import: {imported_cards}\nUnsupported notes skipped: {skipped_unsupported_notes}\nEmpty notes skipped: {skipped_empty_notes}\nMalformed notes skipped: {skipped_malformed_notes}\nSound references removed from text: {removed_sound_references}\nMP3 audio files to import: {imported_audio_files}\nAudio references skipped: {skipped_audio_references}\n\nOnly supported prompt and response MP3 audio will be imported. Other media, cloze cards, Anki scheduling, and review history will not be imported.",
            &[
                ("source_path", source_path.to_string()),
                ("destination_path", destination_path.to_string()),
                ("imported_cards", imported_cards.to_string()),
                (
                    "skipped_unsupported_notes",
                    skipped_unsupported_notes.to_string(),
                ),
                ("skipped_empty_notes", skipped_empty_notes.to_string()),
                ("skipped_malformed_notes", skipped_malformed_notes.to_string()),
                (
                    "removed_sound_references",
                    removed_sound_references.to_string(),
                ),
                ("imported_audio_files", imported_audio_files.to_string()),
                (
                    "skipped_audio_references",
                    skipped_audio_references.to_string(),
                ),
            ],
        )
    }

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

    pub fn imported_deck_message(
        self,
        source_path: &str,
        destination_path: &str,
        imported_cards: usize,
        imported_audio_files: usize,
    ) -> String {
        self.format_message(
            "Imported the Anki deck from:\n\n{source_path}\n\nCosimo deck:\n{destination_path}\n\nCards imported: {imported_cards}\nMP3 audio files imported: {imported_audio_files}\n\nOnly supported prompt and response MP3 audio was imported. Other media, cloze cards, Anki scheduling, and review history were not imported.",
            &[
                ("source_path", source_path.to_string()),
                ("destination_path", destination_path.to_string()),
                ("imported_cards", imported_cards.to_string()),
                ("imported_audio_files", imported_audio_files.to_string()),
            ],
        )
    }

    pub fn could_not_import_anki_deck_message(self, source_path: &str, error: &str) -> String {
        self.format_message(
            "Cosimo could not import the Anki deck from:\n\n{source_path}\n\n{error}\n\nThe deck was not imported.",
            &[
                ("source_path", source_path.to_string()),
                ("error", error.to_string()),
            ],
        )
    }

    pub fn could_not_write_imported_deck_message(
        self,
        destination_path: &str,
        error: &str,
    ) -> String {
        self.format_message(
            "Cosimo could not write the imported deck to:\n\n{destination_path}\n\n{error}\n\nThe deck was not imported.",
            &[
                ("destination_path", destination_path.to_string()),
                ("error", error.to_string()),
            ],
        )
    }

    pub fn imported_deck_review_db_conflict_message(
        self,
        destination_path: &str,
        review_db_path: &str,
    ) -> String {
        self.format_message(
            "Cosimo will not import into this deck path because a matching review database already exists.\n\nDeck path:\n{destination_path}\n\nReview database:\n{review_db_path}\n\nChoose a new deck file name so the imported deck starts with a fresh review database.",
            &[
                ("destination_path", destination_path.to_string()),
                ("review_db_path", review_db_path.to_string()),
            ],
        )
    }

    pub fn exported_deck_bundle_message(self, path: &str, file_count: usize) -> String {
        self.format_message(
            "Exported the full deck bundle to:\n\n{path}\n\nFiles included: {file_count}",
            &[
                ("path", path.to_string()),
                ("file_count", file_count.to_string()),
            ],
        )
    }

    pub fn exported_deck_bundle_with_study_history_message(
        self,
        path: &str,
        file_count: usize,
    ) -> String {
        self.format_message(
            "Exported the full deck bundle with study history to:\n\n{path}\n\nFiles included: {file_count}\n\nThis archive includes the review database.",
            &[
                ("path", path.to_string()),
                ("file_count", file_count.to_string()),
            ],
        )
    }

    pub fn could_not_export_deck_bundle_caption(self) -> &'static str {
        self.gettext("Could not export full deck")
    }

    pub fn could_not_export_deck_bundle_message(self, path: &str, error: &str) -> String {
        self.format_message(
            "Cosimo could not export the full deck bundle to:\n\n{path}\n\n{error}\n\nThe deck was not exported.",
            &[("path", path.to_string()), ("error", error.to_string())],
        )
    }

    pub fn deck_bundle_backup_written_caption(self) -> &'static str {
        self.gettext("Full Deck Backup Written")
    }

    pub fn deck_bundle_backup_written_message(self, path: &str, file_count: usize) -> String {
        self.format_message(
            "Wrote the full compressed deck backup to:\n\n{path}\n\nFiles included: {file_count}",
            &[
                ("path", path.to_string()),
                ("file_count", file_count.to_string()),
            ],
        )
    }

    pub fn deck_bundle_progress_caption(self) -> &'static str {
        self.gettext("Full Deck Bundle Progress")
    }

    pub fn deck_bundle_progress_creating(self) -> &'static str {
        self.gettext("Creating full deck bundle.")
    }

    pub fn deck_bundle_progress_unknown_failure(self) -> String {
        self.gettext("Full deck bundle operation ended without reporting a result.")
            .to_string()
    }

    pub fn could_not_write_deck_bundle_backup_caption(self) -> &'static str {
        self.gettext("Could not write full deck backup")
    }

    pub fn could_not_write_deck_bundle_backup_message(self, error: &str) -> String {
        self.format_message(
            "Cosimo could not write the full compressed deck backup: {error}\n\nThe backup was not written.",
            &[("error", error.to_string())],
        )
    }

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

    pub fn deck_bundle_review_database_not_included(self) -> &'static str {
        self.gettext("not included")
    }

    pub fn restore_deck_bundle_overwrite_notice(self) -> &'static str {
        self.gettext(
            "Existing files in the decks directory will be overwritten. Cosimo will write a full compressed backup of the current deck before restoring.",
        )
    }

    pub fn restore_deck_bundle_no_overwrite_notice(self) -> &'static str {
        self.gettext("No existing deck files will be overwritten.")
    }

    #[allow(clippy::too_many_arguments)]
    pub fn restore_deck_bundle_confirmation_message(
        self,
        path: &str,
        deck_file_name: &str,
        card_count: usize,
        audio_file_count: usize,
        file_count: usize,
        study_history: &str,
        overwrite_notice: &str,
    ) -> String {
        self.format_message(
            "Restore this full deck bundle?\n\nBundle:\n{path}\n\nDeck file: {deck_file_name}\nCards: {card_count}\nAudio files: {audio_file_count}\nFiles: {file_count}\nStudy history: {study_history}\n\n{overwrite_notice}",
            &[
                ("path", path.to_string()),
                ("deck_file_name", deck_file_name.to_string()),
                ("card_count", card_count.to_string()),
                ("audio_file_count", audio_file_count.to_string()),
                ("file_count", file_count.to_string()),
                ("study_history", study_history.to_string()),
                ("overwrite_notice", overwrite_notice.to_string()),
            ],
        )
    }

    pub fn restore_deck_bundle_confirm_caption(self) -> &'static str {
        self.gettext("Restore Full Deck?")
    }

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

    pub fn restored_deck_bundle_backup_notice(self, backup_path: &str) -> String {
        self.format_message(
            "Backup of overwritten deck: {backup_path}",
            &[("backup_path", backup_path.to_string())],
        )
    }

    pub fn restored_deck_bundle_no_backup_notice(self) -> &'static str {
        self.gettext("No existing deck was overwritten.")
    }

    pub fn restored_deck_bundle_message(
        self,
        deck_path: &str,
        card_count: usize,
        audio_file_count: usize,
        study_history: &str,
        backup_notice: &str,
    ) -> String {
        self.format_message(
            "Restored the full deck bundle into:\n\n{deck_path}\n\nCards: {card_count}\nAudio files: {audio_file_count}\nStudy history: {study_history}\n\n{backup_notice}",
            &[
                ("deck_path", deck_path.to_string()),
                ("card_count", card_count.to_string()),
                ("audio_file_count", audio_file_count.to_string()),
                ("study_history", study_history.to_string()),
                ("backup_notice", backup_notice.to_string()),
            ],
        )
    }

    pub fn could_not_restore_deck_bundle_caption(self) -> &'static str {
        self.gettext("Could not restore full deck")
    }

    pub fn could_not_restore_deck_bundle_message(self, path: &str, error: &str) -> String {
        self.format_message(
            "Cosimo could not restore the full deck bundle from:\n\n{path}\n\n{error}\n\nThe deck was not restored.",
            &[("path", path.to_string()), ("error", error.to_string())],
        )
    }

    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 could_not_clear_local_sync_state_caption(self) -> &'static str {
        self.gettext("Could Not Clear Local Sync State")
    }

    pub fn sync_settings_missing_caption(self) -> &'static str {
        self.gettext("Deck Sync Settings Missing")
    }

    pub fn sync_settings_missing_message(self) -> &'static str {
        self.gettext(
            "Use Tools -> Options to set a sync server URL, then use Deck -> Sync -> Log In.",
        )
    }

    pub fn sync_login_missing_message(self) -> &'static str {
        self.gettext("Use Deck -> Sync -> Log In to connect to the configured sync server.")
    }

    pub fn sync_deck_uuid_missing_message(self) -> &'static str {
        self.gettext(
            "This deck has no sync UUID yet. Upload it first, or open a deck that was downloaded from sync.",
        )
    }

    pub fn sync_status_dialog_title(self) -> &'static str {
        self.gettext("Deck Sync Status")
    }

    pub fn sync_status_dialog_status(self) -> &'static str {
        self.gettext("Deck sync status.")
    }

    pub fn sync_remote_decks_dialog_title(self) -> &'static str {
        self.gettext("Remote Sync Decks")
    }

    pub fn sync_remote_decks_dialog_status(self) -> &'static str {
        self.gettext("Remote decks in the configured sync target.")
    }

    pub fn sync_remote_decks_list_label(self) -> &'static str {
        self.gettext("Remote decks")
    }

    pub fn sync_remote_decks_details_button(self) -> &'static str {
        self.gettext("De&tails")
    }

    pub fn sync_remote_decks_copy_id_button(self) -> &'static str {
        self.gettext("&Copy ID")
    }

    pub fn sync_remote_decks_find_by_id_button(self) -> &'static str {
        self.gettext("&Find by ID")
    }

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

    pub fn sync_remote_decks_delete_button(self) -> &'static str {
        self.gettext("De&lete")
    }

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

    pub fn sync_remote_decks_count_label(self) -> &'static str {
        self.gettext("Remote decks")
    }

    pub fn sync_remote_decks_invalid_count_label(self) -> &'static str {
        self.gettext("Malformed remote entries")
    }

    pub fn sync_remote_decks_none_status(self) -> &'static str {
        self.gettext("No remote decks were found in the configured sync target.")
    }

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

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

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

    pub fn sync_remote_decks_audio_files_label(self) -> &'static str {
        self.gettext("Audio files")
    }

    pub fn sync_remote_decks_managed_audio_files_label(self) -> &'static str {
        self.gettext("Generated audio files")
    }

    pub fn sync_remote_decks_manual_audio_files_label(self) -> &'static str {
        self.gettext("Manual or untracked audio files")
    }

    pub fn sync_remote_decks_audio_formats_label(self) -> &'static str {
        self.gettext("Audio formats")
    }

    pub fn sync_details_technical_section_label(self) -> &'static str {
        self.gettext("Technical details")
    }

    pub fn sync_status_actions_section_label(self) -> &'static str {
        self.gettext("Available actions")
    }

    pub fn sync_status_remote_section_label(self) -> &'static str {
        self.gettext("Remote copy")
    }

    pub fn sync_status_local_section_label(self) -> &'static str {
        self.gettext("Local sync settings")
    }

    pub fn sync_remote_decks_content_hash_label(self) -> &'static str {
        self.gettext("Content bundle hash")
    }

    pub fn sync_remote_decks_study_history_hash_label(self) -> &'static str {
        self.gettext("Study-history bundle hash")
    }

    pub fn sync_remote_deck_id_copied_caption(self) -> &'static str {
        self.gettext("Deck ID Copied")
    }

    pub fn sync_remote_deck_id_copied_message(self) -> &'static str {
        self.gettext("Copied deck UUID to the clipboard.")
    }

    pub fn sync_remote_deck_id_copy_failed_caption(self) -> &'static str {
        self.gettext("Could Not Copy Deck ID")
    }

    pub fn sync_remote_deck_id_copy_failed_message(self) -> &'static str {
        self.gettext("Could not copy deck UUID to the clipboard.")
    }

    pub fn sync_remote_deck_find_by_id_caption(self) -> &'static str {
        self.gettext("Find Remote Deck")
    }

    pub fn sync_remote_deck_find_by_id_message(self) -> &'static str {
        self.gettext("Enter a shared deck UUID or URL.")
    }

    pub fn sync_remote_deck_invalid_id_message(self) -> &'static str {
        self.gettext("Enter a valid sync deck UUID or shared deck URL.")
    }

    pub fn sync_status_last_seen_remote_revision_label(self) -> &'static str {
        self.gettext("Last seen remote revision")
    }

    pub fn sync_status_last_uploaded_hash_label(self) -> &'static str {
        self.gettext("Last uploaded bundle hash")
    }

    pub fn sync_status_last_downloaded_hash_label(self) -> &'static str {
        self.gettext("Last downloaded bundle hash")
    }

    pub fn sync_status_upload_label(self) -> &'static str {
        self.gettext("Upload with study history")
    }

    pub fn sync_status_download_label(self) -> &'static str {
        self.gettext("Download synced deck")
    }

    pub fn sync_status_remote_deck_label(self) -> &'static str {
        self.gettext("Remote deck")
    }

    pub fn sync_status_remote_revision_label(self) -> &'static str {
        self.gettext("Remote revision")
    }

    pub fn sync_status_remote_owner_identity_label(self) -> &'static str {
        self.gettext("Remote owner")
    }

    pub fn sync_status_remote_visibility_label(self) -> &'static str {
        self.gettext("Remote visibility")
    }

    pub fn sync_status_remote_study_history_label(self) -> &'static str {
        self.gettext("Remote study history")
    }

    pub fn sync_status_remote_last_upload_device_label(self) -> &'static str {
        self.gettext("Remote last upload device")
    }

    pub fn sync_status_remote_deck_quota_label(self) -> &'static str {
        self.gettext("Remote deck quota")
    }

    pub fn sync_status_remote_storage_quota_label(self) -> &'static str {
        self.gettext("Remote storage quota")
    }

    pub fn sync_status_remote_server_storage_limit_label(self) -> &'static str {
        self.gettext("Remote server storage limit")
    }

    pub fn sync_status_deck_quota_used(self, used: usize, limit: usize) -> String {
        self.format_message(
            "{used} of {limit} decks used",
            &[("used", used.to_string()), ("limit", limit.to_string())],
        )
    }

    pub fn sync_status_deck_quota_used_unlimited(self, used: usize) -> String {
        self.format_message(
            "{used} decks used; no deck-count limit announced",
            &[("used", used.to_string())],
        )
    }

    pub fn sync_status_deck_quota_limit(self, limit: usize) -> String {
        self.format_message(
            "Limit: {limit} decks per owner",
            &[("limit", limit.to_string())],
        )
    }

    pub fn sync_status_deck_quota_unlimited(self) -> &'static str {
        self.gettext("No deck-count limit announced")
    }

    pub fn sync_status_storage_quota_used(self, used: &str, limit: &str) -> String {
        self.format_message(
            "{used} of {limit} used",
            &[("used", used.to_string()), ("limit", limit.to_string())],
        )
    }

    pub fn sync_status_storage_quota_used_unlimited(self, used: &str) -> String {
        self.format_message(
            "{used} used; no per-owner storage limit announced",
            &[("used", used.to_string())],
        )
    }

    pub fn sync_status_storage_quota_limit(self, limit: &str) -> String {
        self.format_message("Limit: {limit} per owner", &[("limit", limit.to_string())])
    }

    pub fn sync_status_storage_quota_unlimited(self) -> &'static str {
        self.gettext("No per-owner storage limit announced")
    }

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

    pub fn sync_status_available_content_only(self) -> &'static str {
        self.gettext("Available: deck content only")
    }

    pub fn sync_status_available_with_study_history(self) -> &'static str {
        self.gettext("Available: deck and study history")
    }

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

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

    pub fn sync_status_unavailable_remote_not_found(self) -> &'static str {
        self.gettext("Not available: remote deck was not found.")
    }

    pub fn sync_status_unavailable_no_deck_uuid(self) -> &'static str {
        self.gettext("Not available: this deck has no deck UUID.")
    }

    pub fn sync_status_unavailable_missing_local_settings(self) -> &'static str {
        self.gettext("Not available: log in to the sync server first.")
    }

    pub fn sync_status_unavailable_authentication_failed(self) -> &'static str {
        self.gettext("Not available: saved sync login is no longer valid. Log in again.")
    }

    pub fn sync_status_unavailable_missing_sync_server_url(self) -> &'static str {
        self.gettext("Not available: set a sync server URL first.")
    }

    pub fn sync_status_unavailable_owner_only(self) -> &'static str {
        self.gettext("Not available: owner-only information.")
    }

    pub fn sync_status_unavailable_remote_read_error(self, error: &str) -> String {
        self.format_message(
            "Not available: could not check remote sync state: {error}",
            &[("error", error.to_string())],
        )
    }

    pub fn sync_status_remote_read_error(self, error: &str) -> String {
        self.format_message(
            "Could not read remote sync state: {error}",
            &[("error", error.to_string())],
        )
    }

    pub fn sync_status_authentication_failed(self) -> &'static str {
        self.gettext("Saved sync login is no longer valid. Log in again.")
    }

    pub fn could_not_sync_deck_caption(self) -> &'static str {
        self.gettext("Could Not Sync Deck")
    }

    pub fn sync_upload_owner_mismatch_message(
        self,
        remote_owner: &str,
        current_owner: &str,
    ) -> String {
        self.format_message(
            "This deck is already owned on the sync server by {remote_owner}. Cosimo is logged in as {current_owner}, so it will not upload this deck. Use Edit Deck Metadata -> Make Independent Copy if this should become your own synced copy.",
            &[
                ("remote_owner", remote_owner.to_string()),
                ("current_owner", current_owner.to_string()),
            ],
        )
    }

    pub fn sync_upload_conflict_caption(self) -> &'static str {
        self.gettext("Sync Upload Conflict")
    }

    pub fn sync_upload_conflict_message(
        self,
        local_revision: Option<&str>,
        remote_revision: Option<&str>,
    ) -> String {
        let local_revision = local_revision.unwrap_or_else(|| self.gettext("none"));
        let remote_revision = remote_revision.unwrap_or_else(|| self.gettext("none"));
        self.format_message(
            "The server has a newer revision of this deck than this Cosimo installation last saw.\n\nLocal last-seen revision: {local_revision}\nRemote revision: {remote_revision}\n\nChoose Yes to force upload and replace the remote deck with this local copy. Changes made elsewhere that are not present here will be lost.\n\nChoose No to download the remote copy instead. Cosimo will write a full compressed backup before replacing local files.\n\nChoose Cancel to do nothing.",
            &[
                ("local_revision", local_revision.to_string()),
                ("remote_revision", remote_revision.to_string()),
            ],
        )
    }

    pub fn sync_progress_caption(self) -> &'static str {
        self.gettext("Deck Sync in Progress")
    }

    pub fn sync_progress_preparing(self) -> &'static str {
        self.gettext("Preparing sync operation.")
    }

    pub fn sync_progress_bundling(self) -> &'static str {
        self.gettext("Preparing deck bundle.")
    }

    pub fn sync_progress_uploading(self) -> &'static str {
        self.gettext("Uploading deck.")
    }

    pub fn sync_progress_downloading(self) -> &'static str {
        self.gettext("Downloading deck.")
    }

    pub fn sync_progress_restoring(self) -> &'static str {
        self.gettext("Restoring downloaded deck.")
    }

    pub fn sync_progress_saving(self) -> &'static str {
        self.gettext("Saving sync state.")
    }

    pub fn sync_progress_unknown_failure(self) -> String {
        self.gettext("Sync ended before reporting a result.")
            .to_string()
    }

    pub fn could_not_log_in_to_sync_server_caption(self) -> &'static str {
        self.gettext("Could Not Log In to Sync Server")
    }

    pub fn could_not_log_out_of_sync_server_caption(self) -> &'static str {
        self.gettext("Could Not Log Out of Sync Server")
    }

    pub fn could_not_invalidate_sync_token_caption(self) -> &'static str {
        self.gettext("Could Not Invalidate Sync Token")
    }

    pub fn could_not_invalidate_sync_tokens_caption(self) -> &'static str {
        self.gettext("Could Not Invalidate Sync Tokens")
    }

    pub fn could_not_open_sync_account_deletion_caption(self) -> &'static str {
        self.gettext("Could Not Open Sync Account Deletion")
    }

    pub fn sync_account_deletion_browser_open_failed_message(self, deletion_url: &str) -> String {
        self.format_message(
            "Could not open the browser automatically. Open this sync account deletion URL manually to delete server-side Cosimo sync data for an account.\n\nSync account deletion URL: {deletion_url}",
            &[("deletion_url", deletion_url.to_string())],
        )
    }

    pub fn could_not_register_sync_account_caption(self) -> &'static str {
        self.gettext("Could Not Register Sync Account")
    }

    pub fn sync_register_account_caption(self) -> &'static str {
        self.gettext("Register Sync Account")
    }

    pub fn sync_register_account_browser_open_failed_message(
        self,
        registration_url: &str,
    ) -> String {
        self.format_message(
            "Could not open the browser automatically. Open this sync account registration URL manually, complete registration, then return to Cosimo.\n\nSync account registration URL: {registration_url}",
            &[("registration_url", registration_url.to_string())],
        )
    }

    pub fn sync_register_account_browser_completion_message(self) -> &'static str {
        self.gettext(
            "Complete Cosimo sync account registration in your browser, then return here and choose OK.",
        )
    }

    pub fn sync_register_account_browser_not_completed_message(self) -> &'static str {
        self.gettext("Cosimo sync account registration has not completed in the browser yet.")
    }

    pub fn sync_register_account_finished_caption(self) -> &'static str {
        self.gettext("Sync Account Registered")
    }

    pub fn sync_register_account_finished_message(self, owner_identity: &str) -> String {
        self.format_message(
            "Cosimo has registered the sync account and is logged in as {owner_identity}.",
            &[("owner_identity", owner_identity.to_string())],
        )
    }

    pub fn sync_login_authorization_code_caption(self) -> &'static str {
        self.gettext("Sync Server Log In")
    }

    pub fn sync_login_browser_open_failed_message(self, authorization_url: &str) -> String {
        self.format_message(
            "Could not open the browser automatically. Open this sync login URL manually, complete the browser login, then return to Cosimo.\n\nSync login URL: {authorization_url}",
            &[("authorization_url", authorization_url.to_string())],
        )
    }

    pub fn sync_login_browser_completion_message(self) -> &'static str {
        self.gettext(
            "Complete the Cosimo sync login in your browser, then return here and choose OK.",
        )
    }

    pub fn sync_login_browser_not_completed_message(self) -> &'static str {
        self.gettext("Cosimo sync login has not completed in the browser yet.")
    }

    pub fn sync_login_finished_caption(self) -> &'static str {
        self.gettext("Sync Login Finished")
    }

    pub fn sync_login_finished_message(self, owner_identity: &str) -> String {
        self.format_message(
            "Cosimo is logged in to the sync server as {owner_identity}.",
            &[("owner_identity", owner_identity.to_string())],
        )
    }

    pub fn sync_logout_finished_caption(self) -> &'static str {
        self.gettext("Sync Logout Finished")
    }

    pub fn sync_tokens_invalidated_finished_caption(self) -> &'static str {
        self.gettext("Sync Tokens Invalidated")
    }

    pub fn sync_logout_finished_message(self, owner_identity: Option<&str>) -> String {
        match owner_identity {
            Some(owner_identity) => self.format_message(
                "Cosimo has cleared the stored sync login.\n\nPrevious owner: {owner_identity}",
                &[("owner_identity", owner_identity.to_string())],
            ),
            None => self.gettext("No sync login was stored.").to_string(),
        }
    }

    pub fn sync_token_invalidated_finished_message(self, owner_identity: Option<&str>) -> String {
        match owner_identity {
            Some(owner_identity) => self.format_message(
                "Cosimo has invalidated this sync token on the server and cleared the stored sync login.\n\nPrevious owner: {owner_identity}",
                &[("owner_identity", owner_identity.to_string())],
            ),
            None => self.gettext("No sync login was stored.").to_string(),
        }
    }

    pub fn sync_invalidate_all_tokens_confirmation_caption(self) -> &'static str {
        self.gettext("Invalidate All Sync Tokens?")
    }

    pub fn sync_invalidate_all_tokens_confirmation_message(self, owner_identity: &str) -> String {
        self.format_message(
            "Invalidate every Cosimo sync token for {owner_identity} on this server?\n\nThis logs out every copied Cosimo folder and device currently using this sync account. Use it if a copied Cosimo folder or device may be compromised.",
            &[("owner_identity", owner_identity.to_string())],
        )
    }

    pub fn sync_all_tokens_invalidated_finished_message(
        self,
        owner_identity: Option<&str>,
    ) -> String {
        match owner_identity {
            Some(owner_identity) => self.format_message(
                "Cosimo has invalidated every sync token for {owner_identity} on this server and cleared the stored sync login.",
                &[("owner_identity", owner_identity.to_string())],
            ),
            None => self.gettext("No sync login was stored.").to_string(),
        }
    }

    pub fn sync_logout_revocation_failed_message(self, error: &str) -> String {
        self.format_message(
            "The local login was cleared, but Cosimo could not revoke the server token: {error}",
            &[("error", error.to_string())],
        )
    }

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

    pub fn sync_upload_finished_message(
        self,
        deck_uuid: &str,
        revision: &str,
        sync_directory: &str,
    ) -> String {
        self.format_message(
            "Uploaded the deck to the sync target.\n\nRevision: {revision}\nSync target: {sync_directory}\n\nTechnical details:\nDeck UUID: {deck_uuid}",
            &[
                ("deck_uuid", deck_uuid.to_string()),
                ("revision", revision.to_string()),
                ("sync_directory", sync_directory.to_string()),
            ],
        )
    }

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

    pub fn sync_download_finished_message(
        self,
        deck_path: &str,
        revision: &str,
        has_review_database: bool,
        backup_path: Option<&str>,
    ) -> String {
        let study_history = if has_review_database {
            self.gettext("included")
        } else {
            self.gettext("not included")
        };
        let backup_notice = backup_path
            .map(|path| {
                self.format_message(
                    "Backup of overwritten deck: {path}",
                    &[("path", path.to_string())],
                )
            })
            .unwrap_or_else(|| {
                self.gettext("No existing deck was overwritten.")
                    .to_string()
            });
        self.format_message(
            "Downloaded and restored the deck from the sync target.\n\nDeck file: {deck_path}\nRevision: {revision}\nStudy history: {study_history}\n\n{backup_notice}",
            &[
                ("deck_path", deck_path.to_string()),
                ("revision", revision.to_string()),
                ("study_history", study_history.to_string()),
                ("backup_notice", backup_notice),
            ],
        )
    }

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

    pub fn resolve_duplicate_prompts_caption(self) -> &'static str {
        self.gettext("Resolve Duplicate Prompts")
    }

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

    pub fn resolve_duplicate_prompt_status(self, candidate_count: usize) -> String {
        let template = self.ngettext(
            "Choose the card to keep, skip this prompt, or cancel resolution. The other card with this prompt will be removed.",
            "Choose the card to keep, skip this prompt, or cancel resolution. The other {remove_count} cards with this prompt will be removed.",
            candidate_count.saturating_sub(1),
        );
        self.format_message(
            template,
            &[(
                "remove_count",
                candidate_count.saturating_sub(1).to_string(),
            )],
        )
    }

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

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

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

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

    pub fn duplicate_prompt_candidate_label(
        self,
        card_number: usize,
        response_preview: &str,
    ) -> String {
        self.format_message(
            "Card {card_number}: {response_preview}",
            &[
                ("card_number", card_number.to_string()),
                ("response_preview", response_preview.to_string()),
            ],
        )
    }

    pub fn resolve_duplicate_prompts_message(
        self,
        prompt_group_count: usize,
        duplicate_card_count: usize,
    ) -> String {
        let group_text = self.ngettext(
            "{prompt_group_count} duplicate prompt",
            "{prompt_group_count} duplicate prompts",
            prompt_group_count,
        );
        let card_text = self.ngettext(
            "{duplicate_card_count} card",
            "{duplicate_card_count} cards",
            duplicate_card_count,
        );
        self.format_message(
            "Resolve {group_text} across {card_text}?\n\nFor each duplicate prompt, you will choose the response to keep. The other cards with that prompt will be removed from the deck. Their review data will remain dormant in the review database unless you later scrub the database.\n\nYou can skip any prompt to leave that prompt unchanged and continue with later duplicate prompts. You can cancel resolution to stop entirely. Prompts already resolved stay changed.",
            &[
                (
                    "group_text",
                    self.format_message(
                        group_text,
                        &[("prompt_group_count", prompt_group_count.to_string())],
                    ),
                ),
                (
                    "card_text",
                    self.format_message(
                        card_text,
                        &[("duplicate_card_count", duplicate_card_count.to_string())],
                    ),
                ),
            ],
        )
    }

    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 ready_cards_heading_is_not_session_specific() {
        assert_eq!(Localizer::default().ready_cards_heading(), "Cards");
        assert_eq!(
            Localizer::new(Language::Spanish).ready_cards_heading(),
            "Cartas"
        );
        assert_eq!(
            Localizer::new(Language::Galician).ready_cards_heading(),
            "Cartas"
        );
    }

    #[test]
    fn sync_upload_success_message_prioritizes_actionable_details() {
        let message = Localizer::default().sync_upload_finished_message(
            "550e8400-e29b-41d4-a716-446655440000",
            "42",
            "https://cosimo.example/sync.cgi",
        );

        assert!(message.contains("Revision: 42"));
        assert!(message.contains("Sync target: https://cosimo.example/sync.cgi"));
        assert!(message.contains("Deck UUID: 550e8400-e29b-41d4-a716-446655440000"));
        assert!(!message.contains("Study history"));
        assert!(message.find("Revision: 42").unwrap() < message.find("Deck UUID:").unwrap());
    }

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

        let owner_download = localizer.sync_download_confirm_replace_deck_message(
            "https://cosimo.example/sync.cgi",
            "decks/deck.cosimo-deck",
            true,
        );
        let shared_download = localizer.sync_download_confirm_replace_deck_message(
            "https://cosimo.example/sync.cgi",
            "decks/deck.cosimo-deck",
            false,
        );

        assert!(owner_download.contains("review database for that deck"));
        assert!(!owner_download.contains("will be kept"));
        assert!(shared_download.contains("The local review database will be kept."));
        assert!(!shared_download.contains("and review database for that deck"));
    }

    #[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 quiz_report_error =
            localizer.could_not_save_quiz_report_status("reports/deck-quiz.txt", "disk full");
        assert!(quiz_report_error.contains("reports/deck-quiz.txt"));
        assert!(quiz_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_filter_problem_cards(),
            "Cartas problemáticas"
        );
        assert_eq!(
            localizer.ready_card_filter_asymmetry_issues(),
            "Problemas de asimetría"
        );
        assert_eq!(
            localizer.ready_card_filter_high_confidence_failures(),
            "Fallos con confianza alta"
        );
        assert_eq!(
            localizer.ready_card_filter_low_confidence_successes(),
            "Aciertos con confianza baja"
        );
        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(),
            "&Suspender carta seleccionada"
        );
        assert_eq!(
            localizer.unsuspend_selected_card_menu_item(),
            "Quitar &suspensió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 intentos"
        );
        assert_eq!(
            localizer.card_details_label(CardDetailsLabel::Availability),
            "Disponibilidad"
        );
        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_automatic_full_deck_backups_label(),
            "Copias completas automáticas"
        );
        assert_eq!(
            localizer.options_sound_notifications_label(),
            "Avisos sonoros"
        );
        assert_eq!(
            localizer.options_review_audio_label(),
            "Audio durante el estudio"
        );
        assert_eq!(
            localizer.options_exam_post_answer_audio_label(),
            "Reproducir audio automáticamente durante la corrección y revisión de examen"
        );
        assert_eq!(
            localizer.options_quiz_audio_label(),
            "Reproducir audio automáticamente durante cuestionarios"
        );
        assert_eq!(localizer.quiz_study_direction_label(), "Dirección");
        assert_eq!(
            localizer.quiz_study_direction_standard_choice(),
            "Solo normal"
        );
        assert_eq!(
            localizer.quiz_study_direction_reverse_choice(),
            "Solo inversa"
        );
        assert_eq!(localizer.quiz_study_direction_both_choice(), "Ambas");
        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.automatic_full_deck_backup_daily_choice(),
            "Diarias"
        );
        assert_eq!(
            localizer.automatic_full_deck_backup_off_choice(),
            "Desactivadas"
        );
        assert_eq!(
            localizer.options_review_audio_cues_checkbox(),
            "Tonos guía para el audio automático"
        );
        assert_eq!(
            localizer.exam_post_answer_audio_off_choice(),
            "Sin audio automático"
        );
        assert_eq!(
            localizer.exam_post_answer_audio_question_choice(),
            "Pregunta"
        );
        assert_eq!(
            localizer.exam_post_answer_audio_answer_choice(),
            "Respuesta"
        );
        assert_eq!(localizer.exam_post_answer_audio_both_choice(), "Ambas");
        assert_eq!(localizer.quiz_audio_off_choice(), "Sin audio automático");
        assert_eq!(localizer.quiz_audio_prompt_choice(), "Pregunta");
        assert_eq!(localizer.quiz_audio_choices_choice(), "Opciones");
        assert_eq!(localizer.quiz_audio_both_choice(), "Ambas");
        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, 20, 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 estudiadas: 1"));
        assert!(summary.contains("Preguntas respondidas: 2 de 2"));
        assert!(summary.contains("Tiempo total: 1 minuto, 1 segundo"));
        let compact_summary = localizer.cycle_summary_text(CycleSummaryText {
            reviewed_cards: 1,
            unique_reviewed_cards: 1,
            total_cards: 1,
            elapsed_seconds: 61,
            ended_early: false,
            was_scheduled: true,
            prompt_guess_counts: [0, 0, 0, 1, 0],
            recall_feedback_counts: [0, 0, 1, 0],
        });
        assert!(compact_summary.contains("Cartas estudiadas: 1 de 1"));
        assert!(compact_summary.contains("Tiempo por carta: 1 minuto, 1 segundo"));
        let partial_summary = localizer.cycle_summary_text(CycleSummaryText {
            reviewed_cards: 1,
            unique_reviewed_cards: 1,
            total_cards: 2,
            elapsed_seconds: 61,
            ended_early: true,
            was_scheduled: false,
            prompt_guess_counts: [0, 0, 0, 1, 0],
            recall_feedback_counts: [0, 0, 1, 0],
        });
        assert!(partial_summary.contains("sesión de estudio no programada parcial"));
    }

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

        assert_eq!(localizer.file_menu(), "&Archivo");
        assert_eq!(localizer.deck_menu(), "Ma&zo");
        assert_eq!(localizer.cards_menu(), "&Carta");
        assert_eq!(localizer.study_menu(), "Est&udio");
        assert_eq!(localizer.tools_menu(), "&Herramientas");
        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.start_scheduled_menu_item(),
            "Comenzar sesión &programada"
        );
        assert_eq!(localizer.force_start_menu_item(), "&Forzar comienzo");
        assert_eq!(
            localizer.deck_properties_menu_item(),
            "&Propiedades del 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 metadatos 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_editor_voice_engine_status(),
            "Selecciona onecore o sapi antes de guardar una voz editada manualmente."
        );
        assert_eq!(
            localizer.deck_properties_make_independent_copy_button(),
            "Hacer copia &independiente"
        );
        assert_eq!(
            localizer.deck_properties_make_independent_copy_caption(),
            "Hacer copia independiente"
        );
        assert!(
            localizer
                .deck_properties_make_independent_copy_message()
                .contains("nueva identidad de sincronización")
        );
        assert_eq!(
            localizer.deck_properties_make_independent_copy_status(),
            "Se preparó una nueva identidad del mazo y la visibilidad se restableció a privado."
        );
        assert_eq!(
            localizer.deck_properties_manual_audio_checkbox(),
            "Mazo de audio manual (proteger contra generación automática de audio)"
        );
        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.deck_properties_label(DeckPropertiesLabel::PromptVoice),
            "Voz de la pregunta"
        );
        assert_eq!(
            localizer.deck_properties_label(DeckPropertiesLabel::PromptVoiceEngine),
            "Motor de voz de la pregunta"
        );
        assert_eq!(
            localizer.deck_properties_label(DeckPropertiesLabel::ResponseVoice),
            "Voz de la respuesta"
        );
        assert_eq!(
            localizer.deck_properties_label(DeckPropertiesLabel::ResponseVoiceEngine),
            "Motor de voz de la respuesta"
        );
        assert_eq!(
            localizer.deck_properties_label(DeckPropertiesLabel::AudioGeneration),
            "Generación de audio"
        );
        assert_eq!(
            localizer.deck_properties_audio_generation_disabled(),
            "Desactivada"
        );
        assert_eq!(
            localizer.deck_properties_audio_generation_allowed(),
            "Permitida"
        );
        assert_eq!(
            localizer.could_not_clear_local_sync_state_caption(),
            "No se pudo borrar el estado local de sincronización"
        );
        assert_eq!(
            localizer.card_editor_non_reversible_checkbox(),
            "Marcar como no reversible"
        );
        assert_eq!(
            localizer.scrub_review_database_menu_item(),
            "&Depurar base de datos de repaso"
        );
        assert_eq!(
            localizer.reclaim_database_space_menu_item(),
            "&Recuperar espacio de la base de datos"
        );
        assert_eq!(
            localizer.wipe_backup_directory_menu_item(),
            "&Borrar directorio de copias"
        );
        assert_eq!(localizer.quit_menu_item(), "&Salir");
        assert_eq!(localizer.add_card_menu_item(), "&Añadir 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; hay cartas pendientes ahora."
        );
        assert_eq!(
            localizer.main_status_bar_next_review(12, "2 días"),
            "Cartas: 12 en total; pendientes: 0; próximo repaso programado 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.selected_card_details_menu_item(),
            "De&talles de la carta\tAlt-Enter"
        );
        assert_eq!(
            localizer.flag_selected_card_menu_item(),
            "&Marcar carta seleccionada\tCtrl-F"
        );
        assert_eq!(
            localizer.unflag_selected_card_menu_item(),
            "Des&marcar carta seleccionada\tCtrl-F"
        );
        assert_eq!(
            localizer.flag_all_shown_cards_menu_item(),
            "Marcar todas las cartas &visibles"
        );
        assert_eq!(
            localizer.unflag_all_shown_cards_menu_item(),
            "Desmarcar t&odas las cartas visibles"
        );
        assert_eq!(
            localizer.suspend_all_shown_cards_menu_item(),
            "Sus&pender todas las cartas visibles"
        );
        assert_eq!(
            localizer.unsuspend_all_shown_cards_menu_item(),
            "&Quitar suspensión de todas las cartas visibles"
        );
        assert_eq!(
            localizer.flag_current_card_menu_item(),
            "&Marcar carta actual\tCtrl-F"
        );
        assert_eq!(
            localizer.unflag_current_card_menu_item(),
            "Des&marcar carta actual\tCtrl-F"
        );
        assert_eq!(
            localizer.review_flagged_cards_menu_item(),
            "Estudiar &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 estudió ninguna carta"
        );
        assert_eq!(
            localizer.no_cards_reviewed_message(),
            "No se estudió ninguna carta en esta sesión."
        );
        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(),
            "Añadir carta inve&rsa\tCtrl-R"
        );
        assert_eq!(
            localizer.remove_reverse_card_menu_item(),
            "Eliminar carta inve&rsa\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(),
            "Quitar marca de carta &no reversible"
        );
        assert_eq!(
            localizer.make_eligible_cards_reversible_menu_item(),
            "Hacer cartas aptas &reversibles"
        );
        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!(
            localizer
                .wipe_backup_directory_message("/tmp/backups")
                .contains("no puede deshacerse")
        );
        assert_eq!(
            localizer.wiped_backup_directory_status(2),
            "Se eliminaron 2 archivos o carpetas del directorio de copias."
        );
        assert_eq!(localizer.review_flagged_cards_button(), "Estudiar marcadas");
        assert_eq!(
            localizer.started_flagged_pass(2),
            "Se inició una sesión de estudio no programada con 2 cartas marcadas."
        );
        assert_eq!(
            localizer.no_flagged_cards_available_to_review(),
            "No hay cartas marcadas disponibles para estudiar."
        );
        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.generate_audio_menu_item(), "&Generar audio");
        assert_eq!(
            localizer.export_deck_bundle_menu_item(),
            "E&xportar mazo completo..."
        );
        assert_eq!(
            localizer.export_deck_bundle_with_study_history_menu_item(),
            "Exportar mazo completo con &historial..."
        );
        assert_eq!(
            localizer.write_deck_bundle_backup_menu_item(),
            "Copia &completa del mazo"
        );
        assert_eq!(
            localizer.restore_deck_bundle_menu_item(),
            "Restaurar &mazo completo..."
        );
        assert_eq!(
            localizer.audio_generation_progress_caption(),
            "Generar audio"
        );
        assert_eq!(
            localizer.audio_generation_missing_metadata_message(),
            "La generación de audio requiere metadatos de voz de la pregunta, voz de la respuesta, motor de voz de la pregunta y motor de voz de la respuesta. Usa Mazo -> Editar metadatos del mazo para escoger las voces antes de generar audio."
        );
        assert_eq!(
            localizer.audio_generation_disabled_message(),
            "La generación de audio está desactivada para este mazo. Está marcado como mazo de audio manual para proteger los archivos de audio existentes. Usa Mazo -> Editar metadatos del mazo para permitir la generación."
        );
        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: versión candidata")
        );
        assert!(
            localizer
                .about_message()
                .contains("@modulux@node.isonomia.net")
        );
        assert_eq!(localizer.edit_revealed_card_button_label(), "Editar carta");
    }

    #[test]
    fn spanish_ready_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ñadir 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(), "&Carta");
        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_finished_caption(), "Examen terminado");
        assert!(
            spanish
                .exam_finished_message(2)
                .contains("El examen ha terminado")
        );
        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!(
            spanish
                .saved_quiz_report_status("reports/quiz.txt")
                .contains("Informe de cuestionario guardado")
        );
        assert!(
            spanish
                .quiz_report_text("deck", "reviews", "inicio", "fin", "resumen")
                .contains("Informe de cuestionario")
        );

        assert_eq!(galician.exam_exact_checkbox_label(), "Exacto");
        assert_eq!(
            galician.exam_review_inexact_caption(),
            "Revisar resposta inexacta"
        );
        assert_eq!(galician.exam_finished_caption(), "Exame rematado");
        assert!(
            galician
                .exam_finished_message(2)
                .contains("O exame rematou")
        );
        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")
        );
        assert!(
            galician
                .saved_quiz_report_status("reports/quiz.txt")
                .contains("Informe de cuestionario gardado")
        );
        assert!(
            galician
                .quiz_report_text("deck", "reviews", "inicio", "fin", "resumo")
                .contains("Informe de cuestionario")
        );
    }

    #[test]
    fn audio_generation_summary_labels_distinguish_forced_overwrite() {
        let english = Localizer::new(Language::English);

        let normal =
            english.audio_generation_finished_message(2, 0, 0, 0, 0, 0, 6, 0, 0, "report.txt");
        assert!(normal.contains("New audio files generated: 0"));
        assert!(!normal.contains("regenerated or converted"));
        assert!(!normal.contains("Existing untracked audio files regenerated"));
        assert!(!normal.contains("overwrite"));

        let forced =
            english.audio_generation_finished_message(2, 0, 4, 0, 3, 0, 6, 0, 0, "report.txt");
        assert!(forced.contains("Managed audio files regenerated or converted: 4"));
        assert!(forced.contains("Files force-regenerated by explicit overwrite: 3"));

        let spanish = Localizer::new(Language::Spanish).audio_generation_finished_message(
            2,
            0,
            4,
            0,
            3,
            0,
            6,
            0,
            0,
            "report.txt",
        );
        assert!(spanish.contains("sobrescritura explícita"));
        assert!(!spanish.contains("reemplazados al sobrescribir"));
    }

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

        assert_eq!(
            localizer.review_flagged_cards_menu_item(),
            "Estudar &marcadas"
        );
        assert_eq!(localizer.review_flagged_cards_button(), "Estudar marcadas");
        assert_eq!(
            localizer.started_flagged_pass(2),
            "Iniciouse unha sesión de estudo non programada con 2 cartas marcadas."
        );
        assert_eq!(
            localizer.no_flagged_cards_available_to_review(),
            "Non hai cartas marcadas dispoñibles para estudar."
        );
        let partial_summary = localizer.cycle_summary_text(CycleSummaryText {
            reviewed_cards: 1,
            unique_reviewed_cards: 1,
            total_cards: 2,
            elapsed_seconds: 61,
            ended_early: true,
            was_scheduled: false,
            prompt_guess_counts: [0, 0, 0, 1, 0],
            recall_feedback_counts: [0, 0, 1, 0],
        });
        assert!(partial_summary.contains("sesión de estudo non programada parcial"));
        assert_eq!(localizer.report_bug_menu_item(), "Informar dun &erro");
        assert_eq!(localizer.generate_audio_menu_item(), "&Xerar audio");
        assert_eq!(localizer.sort_last_reviewed_menu_item(), "Último &repaso");
        assert_eq!(
            localizer.sort_response_text_menu_item(),
            "Texto da respos&ta"
        );
        assert_eq!(
            localizer.export_deck_bundle_menu_item(),
            "E&xportar mazo completo..."
        );
        assert_eq!(
            localizer.export_deck_bundle_with_study_history_menu_item(),
            "Exportar mazo completo con &historial..."
        );
        assert_eq!(
            localizer.write_deck_bundle_backup_menu_item(),
            "Copia de seguridade &completa do mazo"
        );
        assert_eq!(
            localizer.restore_deck_bundle_menu_item(),
            "Restaurar &mazo completo..."
        );
        assert_eq!(localizer.audio_generation_progress_caption(), "Xerar audio");
        assert_eq!(
            localizer.audio_generation_missing_metadata_message(),
            "A xeración de audio require metadatos de voz da pregunta, voz da resposta, motor de voz da pregunta e motor de voz da resposta. Usa Mazo -> Editar metadatos do mazo para escoller as voces antes de xerar audio."
        );
        assert_eq!(
            localizer.audio_generation_disabled_message(),
            "A xeración de audio está desactivada para este mazo. Está marcado como mazo de audio manual para protexer os ficheiros de audio existentes. Usa Mazo -> Editar metadatos do mazo para permitir a xeración."
        );
        assert_eq!(
            localizer.deck_properties_manual_audio_checkbox(),
            "Mazo de audio manual (protexer contra a xeración automática de audio)"
        );
        assert_eq!(
            localizer.deck_properties_label(DeckPropertiesLabel::AudioGeneration),
            "Xeración de audio"
        );
        assert_eq!(
            localizer.deck_properties_audio_generation_disabled(),
            "Desactivada"
        );
        assert_eq!(
            localizer.deck_properties_audio_generation_allowed(),
            "Permitida"
        );
        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"
        );
    }
}
