use std::collections::{HashSet, VecDeque};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread;
use std::time::{Duration, Instant};

use rand::Rng;
use rand::SeedableRng;
use rand::rngs::SmallRng;

use crate::i18n::t;

use crate::config::Config;
use crate::engine::FocusSelection;
use crate::engine::filter::CharFilter;
use crate::engine::key_stats::KeyStatsStore;
use crate::engine::ngram_stats::{self, BigramStatsStore, extract_ngram_events, select_focus};
use crate::engine::scoring;
use crate::engine::skill_tree::{BranchId, BranchStatus, DrillScope, SkillTree, SkillTreeProgress};
use crate::generator::TextGenerator;
use crate::generator::capitalize;
use crate::generator::code_patterns;
use crate::generator::code_syntax::{
    CodeSyntaxGenerator, build_code_download_queue, code_language_options,
    download_code_repo_to_cache_with_progress, is_language_cached, language_by_key,
    languages_with_content,
};
use crate::generator::dictionary::Dictionary;
use crate::generator::numbers;
use crate::generator::passage::{
    GUTENBERG_BOOKS, PassageGenerator, book_by_key, download_book_to_cache_with_progress,
    is_book_cached, passage_options, uncached_books,
};
use crate::generator::phonetic::PhoneticGenerator;
use crate::generator::punctuate;
use crate::generator::transition_table::TransitionTable;
use crate::keyboard::display::BACKSPACE;
use crate::keyboard::model::KeyboardModel;
use crate::l10n::language_pack::{
    CapabilityState, DEFAULT_LATIN_PRIMARY_SEQUENCE, LanguageLayoutValidationError,
    default_keyboard_layout_for_language, find_language_pack, normalized_primary_letter_sequence,
    ranked_adaptive_readiness, validate_language_layout_pair,
};

use crate::session::drill::DrillState;
use crate::session::input::{self, KeystrokeEvent};
use crate::session::result::{DrillResult, KeyTime};
use crate::store::json_store::JsonStore;
use crate::store::schema::{
    DrillHistoryData, EXPORT_VERSION, ExportData, KeyStatsData, ProfileData, SCHEMA_VERSION,
};
use crate::ui::components::menu::Menu;
use crate::ui::line_input::{LineInput, PathField};
use crate::ui::theme::Theme;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AppScreen {
    Menu,
    Drill,
    DrillResult,
    StatsDashboard,
    Settings,
    DictionaryLanguageSelect,
    KeyboardLayoutSelect,
    SkillTree,
    CodeLanguageSelect,
    PassageBookSelect,
    PassageIntro,
    PassageDownloadProgress,
    CodeIntro,
    CodeDownloadProgress,
    Keyboard,
    UiLanguageSelect,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DrillMode {
    Adaptive,
    Code,
    Passage,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SettingItem {
    TargetWpm,
    Theme,
    WordCount,
    UiLanguage,
    DictionaryLanguage,
    KeyboardLayout,
    CodeLanguage,
    CodeDownloads,
    CodeDownloadDir,
    SnippetsPerRepo,
    DownloadCodeNow,
    PassageDownloads,
    PassageDownloadDir,
    ParagraphsPerBook,
    DownloadPassagesNow,
    ExportPath,
    ExportData,
    ImportPath,
    ImportData,
}

impl SettingItem {
    pub const ALL: [Self; 19] = [
        Self::TargetWpm,
        Self::Theme,
        Self::WordCount,
        Self::UiLanguage,
        Self::DictionaryLanguage,
        Self::KeyboardLayout,
        Self::CodeLanguage,
        Self::CodeDownloads,
        Self::CodeDownloadDir,
        Self::SnippetsPerRepo,
        Self::DownloadCodeNow,
        Self::PassageDownloads,
        Self::PassageDownloadDir,
        Self::ParagraphsPerBook,
        Self::DownloadPassagesNow,
        Self::ExportPath,
        Self::ExportData,
        Self::ImportPath,
        Self::ImportData,
    ];

    pub fn from_index(index: usize) -> Self {
        debug_assert!(
            index < Self::ALL.len(),
            "settings index out of range: {index}"
        );
        Self::ALL.get(index).copied().unwrap_or(Self::TargetWpm)
    }

    pub fn index(self) -> usize {
        Self::ALL
            .iter()
            .position(|item| *item == self)
            .expect("setting item should be in ALL")
    }

    pub fn is_path_field(self) -> bool {
        matches!(
            self,
            Self::CodeDownloadDir | Self::PassageDownloadDir | Self::ExportPath | Self::ImportPath
        )
    }

    pub fn is_action_button(self) -> bool {
        matches!(
            self,
            Self::DownloadCodeNow | Self::DownloadPassagesNow | Self::ExportData | Self::ImportData
        )
    }

    pub fn supports_left_right_cycle(self) -> bool {
        !self.is_path_field() && !self.is_action_button()
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PassageDownloadCompleteAction {
    StartPassageDrill,
    ReturnToSettings,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CodeDownloadCompleteAction {
    StartCodeDrill,
    ReturnToSettings,
}

#[derive(Debug, PartialEq, Eq)]
pub enum MilestoneKind {
    Unlock,
    Mastery,
    BranchesAvailable,
    BranchComplete,
    AllKeysUnlocked,
    AllKeysMastered,
}

pub struct KeyMilestonePopup {
    pub kind: MilestoneKind,
    pub keys: Vec<char>,
    pub finger_info: Vec<(char, String)>,
    pub message: String,
    pub branch_ids: Vec<BranchId>,
}

fn unlock_messages(key_count: usize) -> Vec<String> {
    use crate::i18n::t;
    if key_count == 1 {
        vec![
            t!("milestones.unlock_msg_1").to_string(),
            t!("milestones.unlock_msg_2").to_string(),
            t!("milestones.unlock_msg_3").to_string(),
            t!("milestones.unlock_msg_4").to_string(),
        ]
    } else {
        vec![
            t!("milestones.unlock_msg_plural_1").to_string(),
            t!("milestones.unlock_msg_plural_2").to_string(),
            t!("milestones.unlock_msg_plural_3").to_string(),
            t!("milestones.unlock_msg_plural_4").to_string(),
        ]
    }
}

fn mastery_messages(key_count: usize) -> Vec<String> {
    use crate::i18n::t;
    if key_count == 1 {
        vec![
            t!("milestones.mastery_msg_1").to_string(),
            t!("milestones.mastery_msg_2").to_string(),
            t!("milestones.mastery_msg_3").to_string(),
            t!("milestones.mastery_msg_4").to_string(),
        ]
    } else {
        vec![
            t!("milestones.mastery_msg_plural_1").to_string(),
            t!("milestones.mastery_msg_plural_2").to_string(),
            t!("milestones.mastery_msg_plural_3").to_string(),
            t!("milestones.mastery_msg_plural_4").to_string(),
        ]
    }
}

const POST_DRILL_INPUT_LOCK_MS: u64 = 800;

struct DownloadJob {
    downloaded_bytes: Arc<AtomicU64>,
    total_bytes: Arc<AtomicU64>,
    done: Arc<AtomicBool>,
    success: Arc<AtomicBool>,
    handle: Option<thread::JoinHandle<()>>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StatusKind {
    Success,
    Error,
}

#[derive(Clone, Debug)]
pub struct StatusMessage {
    pub kind: StatusKind,
    pub text: String,
}

/// Given a file path, find the next available path by appending/incrementing
/// a `-N` numeric suffix before the extension. Strips any existing trailing
/// `-N` suffix to normalize before scanning.
pub fn next_available_path(path_str: &str) -> String {
    let path = std::path::Path::new(path_str).to_path_buf();
    let parent = path.parent().unwrap_or(std::path::Path::new("."));
    let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("json");
    let full_stem = path
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("export");

    // Strip existing trailing -N suffix to get base stem
    let base_stem = if let Some(pos) = full_stem.rfind('-') {
        let suffix = &full_stem[pos + 1..];
        // Only strip if the suffix is a pure positive integer AND the base before
        // it also exists as a file (i.e., this is our rename suffix, not part of
        // the original name like a date component)
        if suffix.parse::<u32>().is_ok() {
            let candidate_base = &full_stem[..pos];
            let base_file = parent.join(format!("{candidate_base}.{extension}"));
            if base_file.exists() {
                candidate_base
            } else {
                full_stem
            }
        } else {
            full_stem
        }
    } else {
        full_stem
    };

    let mut n = 1u32;
    loop {
        let candidate = parent.join(format!("{base_stem}-{n}.{extension}"));
        if !candidate.exists() {
            return candidate.to_string_lossy().to_string();
        }
        n += 1;
    }
}

fn default_export_path() -> String {
    let dir = dirs::download_dir()
        .or_else(dirs::home_dir)
        .unwrap_or_else(|| std::path::PathBuf::from("."));
    let date = chrono::Utc::now().format("%Y-%m-%d");
    dir.join(format!("keydr-export-{date}.json"))
        .to_string_lossy()
        .to_string()
}

impl DrillMode {
    pub fn as_str(self) -> &'static str {
        match self {
            DrillMode::Adaptive => "adaptive",
            DrillMode::Code => "code",
            DrillMode::Passage => "passage",
        }
    }

    pub fn is_ranked(self) -> bool {
        matches!(self, DrillMode::Adaptive)
    }
}

pub struct App {
    pub screen: AppScreen,
    pub drill_mode: DrillMode,
    pub drill_scope: DrillScope,
    pub drill: Option<DrillState>,
    pub drill_events: Vec<KeystrokeEvent>,
    pub last_result: Option<DrillResult>,
    pub drill_history: Vec<DrillResult>,
    pub menu: Menu<'static>,
    pub theme: &'static Theme,
    pub config: Config,
    pub key_stats: KeyStatsStore,
    pub ranked_key_stats: KeyStatsStore,
    pub skill_tree: SkillTree,
    pub profile: ProfileData,
    pub store: Option<JsonStore>,
    pub should_quit: bool,
    pub settings_selected: usize,
    pub settings_editing_path: Option<(PathField, LineInput)>,
    pub stats_tab: usize,
    pub depressed_keys: HashSet<char>,
    pub last_key_time: Option<Instant>,
    pub history_selected: usize,
    pub history_scroll: usize,
    pub history_confirm_delete: bool,
    pub skill_tree_selected: usize,
    pub skill_tree_detail_scroll: usize,
    pub skill_tree_confirm_unlock: Option<BranchId>,
    pub drill_source_info: Option<String>,
    pub ui_language_selected: usize,
    pub ui_language_scroll: usize,
    pub dictionary_language_selected: usize,
    pub dictionary_language_scroll: usize,
    pub keyboard_layout_selected: usize,
    pub keyboard_layout_scroll: usize,
    pub code_language_selected: usize,
    pub code_language_scroll: usize,
    pub passage_book_selected: usize,
    pub passage_intro_selected: usize,
    pub passage_intro_downloads_enabled: bool,
    pub passage_intro_download_dir: String,
    pub passage_intro_paragraph_limit: usize,
    pub passage_intro_downloading: bool,
    pub passage_intro_download_total: usize,
    pub passage_intro_downloaded: usize,
    pub passage_intro_current_book: String,
    pub passage_intro_download_bytes: u64,
    pub passage_intro_download_bytes_total: u64,
    pub passage_download_queue: Vec<usize>,
    pub passage_drill_selection_override: Option<String>,
    pub last_passage_drill_selection: Option<String>,
    pub passage_download_action: PassageDownloadCompleteAction,
    pub code_intro_selected: usize,
    pub code_intro_downloads_enabled: bool,
    pub code_intro_download_dir: String,
    pub code_intro_snippets_per_repo: usize,
    pub code_intro_downloading: bool,
    pub code_intro_download_total: usize,
    pub code_intro_downloaded: usize,
    pub code_intro_current_repo: String,
    pub code_intro_download_bytes: u64,
    pub code_intro_download_bytes_total: u64,
    pub code_download_queue: Vec<(String, usize)>,
    pub code_drill_language_override: Option<String>,
    pub last_code_drill_language: Option<String>,
    pub code_download_attempted: bool,
    pub code_download_action: CodeDownloadCompleteAction,
    pub shift_held: bool,
    pub caps_lock: bool,
    pub keyboard_model: KeyboardModel,
    pub milestone_queue: VecDeque<KeyMilestonePopup>,
    pub show_adaptive_intro: bool,
    pub settings_confirm_import: bool,
    pub settings_export_conflict: bool,
    pub settings_status_message: Option<StatusMessage>,
    pub settings_export_path: String,
    pub settings_import_path: String,
    pub keyboard_explorer_selected: Option<char>,
    pub explorer_accuracy_cache_overall: Option<(char, usize, usize)>,
    pub explorer_accuracy_cache_ranked: Option<(char, usize, usize)>,
    pub bigram_stats: BigramStatsStore,
    pub ranked_bigram_stats: BigramStatsStore,
    pub user_median_transition_ms: f64,
    pub transition_buffer: Vec<f64>,
    pub current_focus: Option<FocusSelection>,
    pub post_drill_input_lock_until: Option<Instant>,
    adaptive_word_history: VecDeque<HashSet<String>>,
    rng: SmallRng,
    transition_table: TransitionTable,
    dictionary: Dictionary,
    passage_download_job: Option<DownloadJob>,
    code_download_job: Option<DownloadJob>,
}

impl App {
    fn build_skill_tree_for_language(
        progress: SkillTreeProgress,
        language_key: &str,
        layout_key: &str,
    ) -> SkillTree {
        let sequence = find_language_pack(language_key)
            .map(|pack| pack.primary_letter_sequence)
            .unwrap_or(DEFAULT_LATIN_PRIMARY_SEQUENCE);

        // Keep lowercase progression aligned with what the active keyboard can type.
        let filtered_sequence = if let Some(model) = KeyboardModel::from_key(layout_key) {
            let filtered: String = normalized_primary_letter_sequence(sequence)
                .into_iter()
                .filter(|&ch| model.physical_key_for(ch).is_some())
                .collect();
            if filtered.is_empty() {
                sequence.to_string()
            } else {
                filtered
            }
        } else {
            sequence.to_string()
        };

        SkillTree::new_with_primary_sequence(progress, &filtered_sequence)
    }

    pub fn new() -> Self {
        let mut config = Config::load().unwrap_or_default();

        // Normalize code_language: reset to default if not a valid option
        let valid_keys: Vec<&str> = code_language_options().iter().map(|(k, _)| *k).collect();
        config.validate(&valid_keys);
        let loaded_theme = Theme::load(&config.theme).unwrap_or_default();
        let theme: &'static Theme = Box::leak(Box::new(loaded_theme));
        let menu = Menu::new(theme);

        let store = JsonStore::new().ok();

        let (key_stats, ranked_key_stats, skill_tree, profile, drill_history) =
            if let Some(ref s) = store {
                // load_profile returns None if file exists but can't parse (schema mismatch)
                let pd = s.load_profile();

                match pd {
                    Some(pd) if !pd.needs_reset() => {
                        let ksd = s.load_key_stats();
                        let rksd = s.load_ranked_key_stats();
                        let lhd = s.load_drill_history();
                        // Clean-break policy: any schema mismatch in any persisted store
                        // file resets all stores together to avoid partial mixed-version state.
                        if ksd.schema_version != SCHEMA_VERSION
                            || rksd.schema_version != SCHEMA_VERSION
                            || lhd.schema_version != SCHEMA_VERSION
                        {
                            s.archive_legacy_data_files();
                            (
                                KeyStatsStore::default(),
                                KeyStatsStore::default(),
                                SkillTree::default(),
                                ProfileData::default(),
                                Vec::new(),
                            )
                        } else {
                            let st = Self::build_skill_tree_for_language(
                                pd.skill_tree_for_language(&config.dictionary_language),
                                &config.dictionary_language,
                                &config.keyboard_layout,
                            );
                            (ksd.stats, rksd.stats, st, pd, lhd.drills)
                        }
                    }
                    _ => {
                        // Schema mismatch or parse failure: clean-break reset.
                        // Archive stale files so partial legacy state cannot be reloaded.
                        s.archive_legacy_data_files();
                        (
                            KeyStatsStore::default(),
                            KeyStatsStore::default(),
                            SkillTree::default(),
                            ProfileData::default(),
                            Vec::new(),
                        )
                    }
                }
            } else {
                (
                    KeyStatsStore::default(),
                    KeyStatsStore::default(),
                    SkillTree::default(),
                    ProfileData::default(),
                    Vec::new(),
                )
            };

        let mut key_stats_with_target = key_stats;
        key_stats_with_target.target_cpm = config.target_cpm();
        let mut ranked_key_stats_with_target = ranked_key_stats;
        ranked_key_stats_with_target.target_cpm = config.target_cpm();

        let dictionary = Dictionary::load_for_language(&config.dictionary_language);
        let transition_table = TransitionTable::build_from_words(dictionary.words_list());
        let keyboard_model = KeyboardModel::from_key(&config.keyboard_layout)
            .expect("config validation must ensure a known keyboard layout");
        let intro_downloads_enabled = config.passage_downloads_enabled;
        let intro_download_dir = config.passage_download_dir.clone();
        let intro_paragraph_limit = config.passage_paragraphs_per_book;
        let code_intro_downloads_enabled = config.code_downloads_enabled;
        let code_intro_download_dir = config.code_download_dir.clone();
        let code_intro_snippets_per_repo = config.code_snippets_per_repo;

        let mut app = Self {
            screen: AppScreen::Menu,
            drill_mode: DrillMode::Adaptive,
            drill_scope: DrillScope::Global,
            drill: None,
            drill_events: Vec::new(),
            last_result: None,
            drill_history,
            menu,
            theme,
            config,
            key_stats: key_stats_with_target,
            ranked_key_stats: ranked_key_stats_with_target,
            skill_tree,
            profile,
            store,
            should_quit: false,
            settings_selected: 0,
            settings_editing_path: None,
            stats_tab: 0,
            depressed_keys: HashSet::new(),
            last_key_time: None,
            history_selected: 0,
            history_scroll: 0,
            history_confirm_delete: false,
            skill_tree_selected: 0,
            skill_tree_detail_scroll: 0,
            skill_tree_confirm_unlock: None,
            drill_source_info: None,
            ui_language_selected: 0,
            ui_language_scroll: 0,
            dictionary_language_selected: 0,
            dictionary_language_scroll: 0,
            keyboard_layout_selected: 0,
            keyboard_layout_scroll: 0,
            code_language_selected: 0,
            code_language_scroll: 0,
            passage_book_selected: 0,
            passage_intro_selected: 0,
            passage_intro_downloads_enabled: intro_downloads_enabled,
            passage_intro_download_dir: intro_download_dir,
            passage_intro_paragraph_limit: intro_paragraph_limit,
            passage_intro_downloading: false,
            passage_intro_download_total: 0,
            passage_intro_downloaded: 0,
            passage_intro_current_book: String::new(),
            passage_intro_download_bytes: 0,
            passage_intro_download_bytes_total: 0,
            passage_download_queue: Vec::new(),
            passage_drill_selection_override: None,
            last_passage_drill_selection: None,
            passage_download_action: PassageDownloadCompleteAction::StartPassageDrill,
            code_intro_selected: 0,
            code_intro_downloads_enabled,
            code_intro_download_dir,
            code_intro_snippets_per_repo,
            code_intro_downloading: false,
            code_intro_download_total: 0,
            code_intro_downloaded: 0,
            code_intro_current_repo: String::new(),
            code_intro_download_bytes: 0,
            code_intro_download_bytes_total: 0,
            code_download_queue: Vec::new(),
            code_drill_language_override: None,
            last_code_drill_language: None,
            code_download_attempted: false,
            code_download_action: CodeDownloadCompleteAction::StartCodeDrill,
            shift_held: false,
            caps_lock: false,
            keyboard_model,
            milestone_queue: VecDeque::new(),
            show_adaptive_intro: false,
            settings_confirm_import: false,
            settings_export_conflict: false,
            settings_status_message: None,
            settings_export_path: default_export_path(),
            settings_import_path: default_export_path(),
            keyboard_explorer_selected: None,
            explorer_accuracy_cache_overall: None,
            explorer_accuracy_cache_ranked: None,
            bigram_stats: BigramStatsStore::default(),
            ranked_bigram_stats: BigramStatsStore::default(),
            user_median_transition_ms: 0.0,
            transition_buffer: Vec::new(),
            current_focus: None,
            post_drill_input_lock_until: None,
            adaptive_word_history: VecDeque::new(),
            rng: SmallRng::from_entropy(),
            transition_table,
            dictionary,
            passage_download_job: None,
            code_download_job: None,
        };

        // Check for leftover .bak files from interrupted import
        if let Some(ref s) = app.store
            && s.check_interrupted_import()
        {
            app.settings_status_message = Some(StatusMessage {
                kind: StatusKind::Error,
                text: t!("status.recovery_files").to_string(),
            });
        }

        // Rebuild n-gram stats from drill history
        app.rebuild_ngram_stats();

        if !app.config.adaptive_intro_done {
            app.show_adaptive_intro = true;
        } else {
            app.start_drill();
        }
        app
    }

    /// Clear all import/export modal and edit states.
    pub fn clear_settings_modals(&mut self) {
        self.settings_confirm_import = false;
        self.settings_export_conflict = false;
        self.settings_editing_path = None;
    }

    pub fn is_editing_path(&self) -> bool {
        self.settings_editing_path.is_some()
    }

    pub fn is_editing_field(&self, index: usize) -> bool {
        self.settings_editing_path
            .as_ref()
            .map(|(field, _)| match field {
                PathField::CodeDownloadDir => index == SettingItem::CodeDownloadDir.index(),
                PathField::PassageDownloadDir => index == SettingItem::PassageDownloadDir.index(),
                PathField::ExportPath => index == SettingItem::ExportPath.index(),
                PathField::ImportPath => index == SettingItem::ImportPath.index(),
            })
            .unwrap_or(false)
    }

    pub fn arm_post_drill_input_lock(&mut self) {
        self.post_drill_input_lock_until =
            Some(Instant::now() + Duration::from_millis(POST_DRILL_INPUT_LOCK_MS));
    }

    pub fn clear_post_drill_input_lock(&mut self) {
        self.post_drill_input_lock_until = None;
    }

    pub fn post_drill_input_lock_remaining_ms(&self) -> Option<u64> {
        self.post_drill_input_lock_until.and_then(|until| {
            until
                .checked_duration_since(Instant::now())
                .map(|remaining| remaining.as_millis().max(1) as u64)
        })
    }

    pub fn export_data(&mut self) {
        let path = std::path::Path::new(&self.settings_export_path);

        // Check for existing file
        if path.exists() {
            self.settings_export_conflict = true;
            return;
        }

        self.write_export_to_path();
    }

    pub fn export_data_overwrite(&mut self) {
        self.write_export_to_path();
    }

    pub fn export_data_rename(&mut self) {
        self.settings_export_path = next_available_path(&self.settings_export_path);
        self.write_export_to_path();
    }

    fn write_export_to_path(&mut self) {
        // Check parent directory exists
        let path = std::path::Path::new(&self.settings_export_path);
        if let Some(parent) = path.parent()
            && !parent.as_os_str().is_empty()
            && !parent.exists()
        {
            self.settings_status_message = Some(StatusMessage {
                kind: StatusKind::Error,
                text: t!("status.dir_not_exist", path = parent.display().to_string()).to_string(),
            });
            return;
        }

        let Some(ref store) = self.store else {
            self.settings_status_message = Some(StatusMessage {
                kind: StatusKind::Error,
                text: t!("status.no_data_store").to_string(),
            });
            return;
        };

        let export = store.export_all(&self.config);
        let json = match serde_json::to_string_pretty(&export) {
            Ok(j) => j,
            Err(e) => {
                self.settings_status_message = Some(StatusMessage {
                    kind: StatusKind::Error,
                    text: t!("status.serialization_error", error = e.to_string()).to_string(),
                });
                return;
            }
        };

        let path = std::path::Path::new(&self.settings_export_path);
        let tmp_path = path.with_extension("json.tmp");

        let result = (|| -> anyhow::Result<()> {
            let mut file = std::fs::File::create(&tmp_path)?;
            std::io::Write::write_all(&mut file, json.as_bytes())?;
            file.sync_all()?;
            std::fs::rename(&tmp_path, path)?;
            Ok(())
        })();

        match result {
            Ok(()) => {
                self.settings_status_message = Some(StatusMessage {
                    kind: StatusKind::Success,
                    text: t!("status.exported_to", path = &self.settings_export_path).to_string(),
                });
            }
            Err(e) => {
                let _ = std::fs::remove_file(&tmp_path);
                self.settings_status_message = Some(StatusMessage {
                    kind: StatusKind::Error,
                    text: t!("status.export_failed", error = e.to_string()).to_string(),
                });
            }
        }
    }

    pub fn import_data(&mut self) {
        let path = std::path::Path::new(&self.settings_import_path);

        // Read and parse
        let content = match std::fs::read_to_string(path) {
            Ok(c) => c,
            Err(e) => {
                self.settings_status_message = Some(StatusMessage {
                    kind: StatusKind::Error,
                    text: t!("status.could_not_read", error = e.to_string()).to_string(),
                });
                return;
            }
        };

        let mut export: ExportData = match serde_json::from_str(&content) {
            Ok(d) => d,
            Err(e) => {
                self.settings_status_message = Some(StatusMessage {
                    kind: StatusKind::Error,
                    text: t!("status.invalid_export", error = e.to_string()).to_string(),
                });
                return;
            }
        };

        // Version check
        if export.keydr_export_version != EXPORT_VERSION {
            self.settings_status_message = Some(StatusMessage {
                kind: StatusKind::Error,
                text: t!(
                    "status.unsupported_version",
                    got = export.keydr_export_version,
                    expected = EXPORT_VERSION
                )
                .to_string(),
            });
            return;
        }

        // Normalize imported payload schema versions before writing to disk.
        export.profile.schema_version = SCHEMA_VERSION;
        export.key_stats.schema_version = SCHEMA_VERSION;
        export.ranked_key_stats.schema_version = SCHEMA_VERSION;
        export.drill_history.schema_version = SCHEMA_VERSION;

        // Write data files transactionally
        let Some(ref store) = self.store else {
            self.settings_status_message = Some(StatusMessage {
                kind: StatusKind::Error,
                text: t!("status.no_data_store").to_string(),
            });
            return;
        };
        if let Err(e) = store.import_all(&export) {
            self.settings_status_message = Some(StatusMessage {
                kind: StatusKind::Error,
                text: t!("status.import_failed", error = e.to_string()).to_string(),
            });
            return;
        }

        // Merge config: import everything except machine-local paths
        let preserved_code_dir = self.config.code_download_dir.clone();
        let preserved_passage_dir = self.config.passage_download_dir.clone();
        self.config = export.config.clone();
        self.config.code_download_dir = preserved_code_dir;
        self.config.passage_download_dir = preserved_passage_dir;

        // Validate and save config
        let valid_keys: Vec<&str> = code_language_options().iter().map(|(k, _)| *k).collect();
        self.config.validate(&valid_keys);
        let _ = self.config.save();

        // Reload in-memory state from imported data
        self.profile = export.profile;
        // Ensure imported legacy (v2) profiles seed scoped progress for current language.
        let scoped_progress = self
            .profile
            .skill_tree_for_language(&self.config.dictionary_language);
        self.profile
            .set_skill_tree_for_language(&self.config.dictionary_language, scoped_progress);
        self.key_stats = export.key_stats.stats;
        self.key_stats.target_cpm = self.config.target_cpm();
        self.ranked_key_stats = export.ranked_key_stats.stats;
        self.ranked_key_stats.target_cpm = self.config.target_cpm();
        self.drill_history = export.drill_history.drills;
        self.skill_tree = Self::build_skill_tree_for_language(
            self.profile
                .skill_tree_for_language(&self.config.dictionary_language),
            &self.config.dictionary_language,
            &self.config.keyboard_layout,
        );
        self.keyboard_model = KeyboardModel::from_key(&self.config.keyboard_layout)
            .expect("config validation must ensure a known keyboard layout");
        self.rebuild_language_assets();

        // Rebuild n-gram stats from imported drill history
        self.rebuild_ngram_stats();

        // Check theme availability
        let theme_name = self.config.theme.clone();
        let loaded_theme = Theme::load(&theme_name).unwrap_or_default();
        let theme_fell_back = loaded_theme.name != theme_name;
        let theme: &'static Theme = Box::leak(Box::new(loaded_theme));
        self.theme = theme;
        self.menu = Menu::new(theme);

        if theme_fell_back {
            self.config.theme = self.theme.name.clone();
            let _ = self.config.save();
            self.settings_status_message = Some(StatusMessage {
                kind: StatusKind::Success,
                text: t!("status.imported_theme_fallback", theme = &theme_name).to_string(),
            });
        } else {
            self.settings_status_message = Some(StatusMessage {
                kind: StatusKind::Success,
                text: t!("status.imported_success").to_string(),
            });
        }
    }

    pub fn start_drill(&mut self) {
        self.clear_post_drill_input_lock();
        let (text, source_info) = self.generate_text();
        let mut drill = DrillState::new(&text);
        drill.auto_indent_after_newline = self.drill_mode != DrillMode::Adaptive;
        self.drill = Some(drill);
        self.drill_source_info = source_info;
        self.drill_events.clear();
        self.screen = AppScreen::Drill;
    }

    fn generate_text(&mut self) -> (String, Option<String>) {
        let word_count = self.config.word_count;
        let mode = self.drill_mode;

        match mode {
            DrillMode::Adaptive => {
                let scope = self.drill_scope;
                let all_keys = self.skill_tree.unlocked_keys(scope);

                // Select focus targets: char and bigram independently
                let selection = select_focus(
                    &self.skill_tree,
                    scope,
                    &self.ranked_key_stats,
                    &self.ranked_bigram_stats,
                );
                self.current_focus = Some(selection.clone());
                let focused_char = selection.char_focus;
                let focused_bigram = selection.bigram_focus.map(|(k, _, _)| k.0);

                // Generate base lowercase text using only lowercase keys from scope
                let lowercase_keys: Vec<char> = all_keys
                    .iter()
                    .copied()
                    .filter(|ch| ch.is_lowercase() || *ch == ' ')
                    .collect();
                let filter = CharFilter::new(lowercase_keys);
                // Feed uppercase focus as lowercase so capitals drills bias base word content
                // the same way other focused key types bias their generators.
                let lowercase_focused = lowercase_generation_focus(focused_char);
                let table = self.transition_table.clone();
                let dict = self.dictionary.clone();
                let rng = SmallRng::from_rng(&mut self.rng).unwrap();
                let cross_drill_history: HashSet<String> = self
                    .adaptive_word_history
                    .iter()
                    .flatten()
                    .cloned()
                    .collect();
                let mut generator = PhoneticGenerator::new(table, dict, rng, cross_drill_history);
                let mut text =
                    generator.generate(&filter, lowercase_focused, focused_bigram, word_count);

                // Track words for cross-drill history (before capitalization/punctuation)
                let drill_words: HashSet<String> =
                    text.split_whitespace().map(|w| w.to_string()).collect();
                self.adaptive_word_history.push_back(drill_words);
                if self.adaptive_word_history.len() > 5 {
                    self.adaptive_word_history.pop_front();
                }

                // Apply capitalization if uppercase keys are in scope
                let cap_keys: Vec<char> = all_keys
                    .iter()
                    .copied()
                    .filter(|ch| ch.is_uppercase())
                    .collect();
                if !cap_keys.is_empty() {
                    let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
                    text =
                        capitalize::apply_capitalization(&text, &cap_keys, focused_char, &mut rng);
                }

                // Apply punctuation if punctuation keys are in scope
                let punct_keys: Vec<char> = all_keys
                    .iter()
                    .copied()
                    .filter(|ch| {
                        matches!(
                            ch,
                            '.' | ',' | '\'' | ';' | ':' | '"' | '-' | '?' | '!' | '(' | ')'
                        )
                    })
                    .collect();
                if !punct_keys.is_empty() {
                    let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
                    text = punctuate::apply_punctuation(&text, &punct_keys, focused_char, &mut rng);
                }

                // Apply numbers if digit keys are in scope
                let digit_keys: Vec<char> = all_keys
                    .iter()
                    .copied()
                    // Digits remain ASCII-scoped by design in Phase 2 because the
                    // progression branch and keyboard profiles currently model 0-9.
                    .filter(|ch| ch.is_ascii_digit())
                    .collect();
                if !digit_keys.is_empty() {
                    let has_dot = all_keys.contains(&'.');
                    let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
                    text =
                        numbers::apply_numbers(&text, &digit_keys, has_dot, focused_char, &mut rng);
                }

                // Apply code symbols only if this drill is for the CodeSymbols branch,
                // or if it's a global drill and CodeSymbols is active
                let code_active = match scope {
                    DrillScope::Branch(id) => id == BranchId::CodeSymbols,
                    DrillScope::Global => matches!(
                        self.skill_tree.branch_status(BranchId::CodeSymbols),
                        BranchStatus::InProgress | BranchStatus::Complete
                    ),
                };
                let symbol_keys: Vec<char> = all_keys
                    .iter()
                    .copied()
                    .filter(|ch| {
                        matches!(
                            ch,
                            '=' | '+'
                                | '*'
                                | '/'
                                | '-'
                                | '{'
                                | '}'
                                | '['
                                | ']'
                                | '<'
                                | '>'
                                | '&'
                                | '|'
                                | '^'
                                | '~'
                                | '@'
                                | '#'
                                | '$'
                                | '%'
                                | '_'
                                | '\\'
                                | '`'
                        )
                    })
                    .collect();
                if code_active {
                    if !symbol_keys.is_empty() {
                        let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
                        text = code_patterns::apply_code_symbols(
                            &text,
                            &symbol_keys,
                            focused_char,
                            &mut rng,
                        );
                    }
                }

                // Apply whitespace line breaks if newline is in scope
                let has_newline = all_keys.contains(&'\n');
                let has_tab = all_keys.contains(&'\t');
                if has_newline {
                    text = insert_line_breaks(&text);
                }

                // Balance injection density so unlocked branches contribute
                // roughly similar amounts of practice content.
                let active_branch_count = [
                    !cap_keys.is_empty(),
                    !punct_keys.is_empty(),
                    !digit_keys.is_empty(),
                    code_active && !symbol_keys.is_empty(),
                    has_newline || has_tab,
                ]
                .into_iter()
                .filter(|active| *active)
                .count();
                if active_branch_count > 1 || has_tab {
                    let target_per_branch = (word_count / 6).clamp(2, 6);
                    let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
                    text = rebalance_branch_injections(
                        text,
                        &cap_keys,
                        &punct_keys,
                        &digit_keys,
                        if code_active { &symbol_keys } else { &[] },
                        has_newline,
                        has_tab,
                        focused_char,
                        target_per_branch,
                        &mut rng,
                    );
                }

                (text, None)
            }
            DrillMode::Code => {
                let filter = CharFilter::new(self.skill_tree.unlocked_keys(DrillScope::Global));
                let lang = self
                    .code_drill_language_override
                    .clone()
                    .unwrap_or_else(|| self.config.code_language.clone());
                self.last_code_drill_language = Some(lang.clone());
                let rng = SmallRng::from_rng(&mut self.rng).unwrap();
                let mut generator =
                    CodeSyntaxGenerator::new(rng, &lang, &self.config.code_download_dir);
                self.code_drill_language_override = None;
                let text = generator.generate(&filter, None, None, word_count);
                (text, Some(generator.last_source().to_string()))
            }
            DrillMode::Passage => {
                let filter = CharFilter::new(self.skill_tree.unlocked_keys(DrillScope::Global));
                let rng = SmallRng::from_rng(&mut self.rng).unwrap();
                let selection = self
                    .passage_drill_selection_override
                    .clone()
                    .unwrap_or_else(|| self.config.passage_book.clone());
                self.last_passage_drill_selection = Some(selection.clone());
                let mut generator = PassageGenerator::new(
                    rng,
                    &selection,
                    &self.config.passage_download_dir,
                    self.config.passage_paragraphs_per_book,
                    self.config.passage_downloads_enabled,
                );
                self.passage_drill_selection_override = None;
                let text = generator.generate(&filter, None, None, word_count);
                (text, Some(generator.last_source().to_string()))
            }
        }
    }

    pub fn type_char(&mut self, ch: char) {
        if let Some(ref mut drill) = self.drill {
            let event = input::process_char(drill, ch);
            let had_event = event.is_some();
            if let Some(event) = event {
                self.drill_events.push(event);
            }

            if drill.is_complete() {
                let synthetic_reached_end = drill
                    .synthetic_spans
                    .last()
                    .is_some_and(|span| span.end == drill.target.len());
                if synthetic_reached_end && had_event {
                    // Give the user a chance to backspace erroneous Enter/Tab spans at EOF.
                    return;
                }
                self.finish_drill();
            }
        }
    }

    pub fn backspace(&mut self) {
        if let Some(ref mut drill) = self.drill {
            if drill.cursor == 0 {
                return;
            }
            self.drill_events.push(KeystrokeEvent {
                expected: BACKSPACE,
                actual: BACKSPACE,
                timestamp: Instant::now(),
                correct: true,
            });
            input::process_backspace(drill);
        }
    }

    fn finish_drill(&mut self) {
        if let Some(ref drill) = self.drill {
            let ranked = self.drill_mode.is_ranked();
            let result = DrillResult::from_drill(
                drill,
                &self.drill_events,
                self.drill_mode.as_str(),
                ranked,
                false,
            );

            // Update timing stats for all drill modes
            let before_stats = if ranked {
                Some(self.ranked_key_stats.clone())
            } else {
                None
            };
            for kt in &result.per_key_times {
                if kt.correct {
                    self.key_stats.update_key(kt.key, kt.time_ms);
                } else {
                    self.key_stats.update_key_error(kt.key);
                }
            }

            // Extract and update n-gram stats for all drill modes
            let drill_index = self.drill_history.len() as u32;
            let hesitation_thresh =
                ngram_stats::hesitation_threshold(self.user_median_transition_ms);
            let bigram_events = extract_ngram_events(&result.per_key_times, hesitation_thresh);
            // Collect unique bigram keys for per-drill streak updates
            let mut seen_bigrams: std::collections::HashSet<ngram_stats::BigramKey> =
                std::collections::HashSet::new();
            for ev in &bigram_events {
                seen_bigrams.insert(ev.key.clone());
                self.bigram_stats.update(
                    ev.key.clone(),
                    ev.total_time_ms,
                    ev.correct,
                    ev.has_hesitation,
                    drill_index,
                );
            }
            // Update streaks once per drill per unique bigram (not per event)
            for key in &seen_bigrams {
                self.bigram_stats
                    .update_error_anomaly_streak(key, &self.key_stats);
                self.bigram_stats
                    .update_speed_anomaly_streak(key, &self.key_stats);
            }

            if ranked {
                let mut seen_ranked_bigrams: std::collections::HashSet<ngram_stats::BigramKey> =
                    std::collections::HashSet::new();
                for kt in &result.per_key_times {
                    if kt.correct {
                        self.ranked_key_stats.update_key_ranked(kt.key, kt.time_ms);
                    } else {
                        self.ranked_key_stats.update_key_error_ranked(kt.key);
                    }
                }
                for ev in &bigram_events {
                    seen_ranked_bigrams.insert(ev.key.clone());
                    self.ranked_bigram_stats.update(
                        ev.key.clone(),
                        ev.total_time_ms,
                        ev.correct,
                        ev.has_hesitation,
                        drill_index,
                    );
                }
                for key in &seen_ranked_bigrams {
                    self.ranked_bigram_stats
                        .update_error_anomaly_streak(key, &self.ranked_key_stats);
                    self.ranked_bigram_stats
                        .update_speed_anomaly_streak(key, &self.ranked_key_stats);
                }
                let update = self
                    .skill_tree
                    .update(&self.ranked_key_stats, before_stats.as_ref());

                // Queue milestone overlays for newly unlocked keys
                if !update.newly_unlocked.is_empty() {
                    let finger_info: Vec<(char, String)> = update
                        .newly_unlocked
                        .iter()
                        .map(|&ch| {
                            let desc = self
                                .keyboard_model
                                .finger_for_char(ch)
                                .localized_description();
                            (ch, desc)
                        })
                        .collect();
                    let msgs = unlock_messages(update.newly_unlocked.len());
                    let msg = msgs[self.rng.gen_range(0..msgs.len())].clone();
                    self.milestone_queue.push_back(KeyMilestonePopup {
                        kind: MilestoneKind::Unlock,
                        keys: update.newly_unlocked,
                        finger_info,
                        message: msg,
                        branch_ids: vec![],
                    });
                }

                // Queue milestone overlays for newly mastered keys
                if !update.newly_mastered.is_empty() {
                    let finger_info: Vec<(char, String)> = update
                        .newly_mastered
                        .iter()
                        .map(|&ch| {
                            let desc = self
                                .keyboard_model
                                .finger_for_char(ch)
                                .localized_description();
                            (ch, desc)
                        })
                        .collect();
                    let msgs = mastery_messages(update.newly_mastered.len());
                    let msg = msgs[self.rng.gen_range(0..msgs.len())].clone();
                    self.milestone_queue.push_back(KeyMilestonePopup {
                        kind: MilestoneKind::Mastery,
                        keys: update.newly_mastered,
                        finger_info,
                        message: msg,
                        branch_ids: vec![],
                    });
                }

                // Queue milestone popups for branch/global milestones
                if !update.branches_newly_available.is_empty() {
                    self.milestone_queue.push_back(KeyMilestonePopup {
                        kind: MilestoneKind::BranchesAvailable,
                        keys: vec![],
                        finger_info: vec![],
                        message: String::new(),
                        branch_ids: update.branches_newly_available,
                    });
                }

                // Branch complete (excluding Lowercase, since BranchesAvailable covers it)
                let completed_non_lowercase: Vec<BranchId> = update
                    .branches_newly_completed
                    .iter()
                    .filter(|&&id| id != BranchId::Lowercase)
                    .copied()
                    .collect();
                if !completed_non_lowercase.is_empty() {
                    self.milestone_queue.push_back(KeyMilestonePopup {
                        kind: MilestoneKind::BranchComplete,
                        keys: vec![],
                        finger_info: vec![],
                        message: String::new(),
                        branch_ids: completed_non_lowercase,
                    });
                }

                if update.all_keys_unlocked {
                    self.milestone_queue.push_back(KeyMilestonePopup {
                        kind: MilestoneKind::AllKeysUnlocked,
                        keys: vec![],
                        finger_info: vec![],
                        message: String::new(),
                        branch_ids: vec![],
                    });
                }

                if update.all_keys_mastered {
                    self.milestone_queue.push_back(KeyMilestonePopup {
                        kind: MilestoneKind::AllKeysMastered,
                        keys: vec![],
                        finger_info: vec![],
                        message: String::new(),
                        branch_ids: vec![],
                    });
                }
            }

            let complexity = self.skill_tree.complexity();
            let score = scoring::compute_score(&result, complexity);
            self.profile.total_score += score;
            self.profile.total_drills += 1;
            self.profile.set_skill_tree_for_language(
                &self.config.dictionary_language,
                self.skill_tree.progress.clone(),
            );

            let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
            if self.profile.last_practice_date.as_deref() != Some(&today) {
                if let Some(ref last) = self.profile.last_practice_date {
                    let yesterday = (chrono::Utc::now() - chrono::Duration::days(1))
                        .format("%Y-%m-%d")
                        .to_string();
                    if last == &yesterday {
                        self.profile.streak_days += 1;
                    } else {
                        self.profile.streak_days = 1;
                    }
                } else {
                    self.profile.streak_days = 1;
                }
                self.profile.best_streak = self.profile.best_streak.max(self.profile.streak_days);
                self.profile.last_practice_date = Some(today);
            }

            // Update transition buffer for hesitation baseline
            self.update_transition_buffer(&result.per_key_times);

            self.drill_history.push(result.clone());
            if self.drill_history.len() > 500 {
                self.drill_history.remove(0);
            }

            self.last_result = Some(result);
            if !self.milestone_queue.is_empty() || self.drill_mode != DrillMode::Adaptive {
                self.arm_post_drill_input_lock();
            }

            // Adaptive mode auto-continues unless milestone popups must be shown first.
            if self.drill_mode == DrillMode::Adaptive && self.milestone_queue.is_empty() {
                self.start_drill();
                self.arm_post_drill_input_lock();
            } else {
                self.screen = AppScreen::DrillResult;
            }

            self.save_data();
        }
    }

    pub fn finish_partial_drill(&mut self) {
        if let Some(ref drill) = self.drill {
            let result = DrillResult::from_drill(
                drill,
                &self.drill_events,
                self.drill_mode.as_str(),
                false,
                true,
            );

            // Update timing stats for all completed keystrokes
            for kt in &result.per_key_times {
                if kt.correct {
                    self.key_stats.update_key(kt.key, kt.time_ms);
                } else {
                    self.key_stats.update_key_error(kt.key);
                }
            }

            // Extract and update n-gram stats
            let drill_index = self.drill_history.len() as u32;
            let hesitation_thresh =
                ngram_stats::hesitation_threshold(self.user_median_transition_ms);
            let bigram_events = extract_ngram_events(&result.per_key_times, hesitation_thresh);
            let mut seen_bigrams: std::collections::HashSet<ngram_stats::BigramKey> =
                std::collections::HashSet::new();
            for ev in &bigram_events {
                seen_bigrams.insert(ev.key.clone());
                self.bigram_stats.update(
                    ev.key.clone(),
                    ev.total_time_ms,
                    ev.correct,
                    ev.has_hesitation,
                    drill_index,
                );
            }
            for key in &seen_bigrams {
                self.bigram_stats
                    .update_error_anomaly_streak(key, &self.key_stats);
                self.bigram_stats
                    .update_speed_anomaly_streak(key, &self.key_stats);
            }

            // Update transition buffer for hesitation baseline
            self.update_transition_buffer(&result.per_key_times);

            self.drill_history.push(result.clone());
            if self.drill_history.len() > 500 {
                self.drill_history.remove(0);
            }

            self.last_result = Some(result);
            self.arm_post_drill_input_lock();
            self.screen = AppScreen::DrillResult;
            self.save_data();
        }
    }

    fn save_data(&self) {
        if let Some(ref store) = self.store {
            let _ = store.save_profile(&self.profile);
            let _ = store.save_key_stats(&KeyStatsData {
                schema_version: SCHEMA_VERSION,
                stats: self.key_stats.clone(),
            });
            let _ = store.save_ranked_key_stats(&KeyStatsData {
                schema_version: SCHEMA_VERSION,
                stats: self.ranked_key_stats.clone(),
            });
            let _ = store.save_drill_history(&DrillHistoryData {
                schema_version: SCHEMA_VERSION,
                drills: self.drill_history.clone(),
            });
        }
    }

    /// Replace up to 40% of words with dictionary words containing the target bigram.
    /// No more than 3 consecutive bigram-focused words to prevent repetitive feel.
    /// Update the rolling transition buffer with new inter-keystroke intervals.
    fn update_transition_buffer(&mut self, per_key_times: &[KeyTime]) {
        for kt in per_key_times {
            if kt.key == BACKSPACE {
                continue;
            }
            self.transition_buffer.push(kt.time_ms);
        }
        // Keep only last 200 entries
        if self.transition_buffer.len() > 200 {
            let excess = self.transition_buffer.len() - 200;
            self.transition_buffer.drain(..excess);
        }
        // Recompute median
        let mut buf = self.transition_buffer.clone();
        self.user_median_transition_ms = ngram_stats::compute_median(&mut buf);
    }

    /// Rebuild all n-gram stats and char-level error/total counts from drill history.
    /// This is the sole source of truth for error_count/total_count on KeyStat
    /// and all n-gram stores. Timing EMA on KeyStat is NOT touched here
    /// (it is either loaded from disk or rebuilt by `rebuild_from_history`).
    fn rebuild_ngram_stats(&mut self) {
        // Reset n-gram stores
        self.bigram_stats = BigramStatsStore::default();
        self.ranked_bigram_stats = BigramStatsStore::default();
        self.transition_buffer.clear();
        self.user_median_transition_ms = 0.0;

        // Reset char-level error/total counts and EMA (timing fields are untouched)
        for stat in self.key_stats.stats.values_mut() {
            stat.error_count = 0;
            stat.total_count = 0;
            stat.error_rate_ema = 0.5;
        }
        for stat in self.ranked_key_stats.stats.values_mut() {
            stat.error_count = 0;
            stat.total_count = 0;
            stat.error_rate_ema = 0.5;
        }

        // Take drill_history out temporarily to avoid borrow conflict
        let history = std::mem::take(&mut self.drill_history);

        for (drill_index, result) in history.iter().enumerate() {
            let hesitation_thresh =
                ngram_stats::hesitation_threshold(self.user_median_transition_ms);
            let bigram_events = extract_ngram_events(&result.per_key_times, hesitation_thresh);

            // Rebuild char-level error/total counts and EMA from history
            for kt in &result.per_key_times {
                if kt.correct {
                    let stat = self.key_stats.stats.entry(kt.key).or_default();
                    stat.total_count += 1;
                    // Update error rate EMA for correct stroke
                    if stat.total_count == 1 {
                        stat.error_rate_ema = 0.0;
                    } else {
                        stat.error_rate_ema = 0.1 * 0.0 + 0.9 * stat.error_rate_ema;
                    }
                } else {
                    self.key_stats.update_key_error(kt.key);
                }
            }

            // Collect unique bigram keys seen this drill for per-drill streak updates
            let mut seen_bigrams: std::collections::HashSet<ngram_stats::BigramKey> =
                std::collections::HashSet::new();

            for ev in &bigram_events {
                seen_bigrams.insert(ev.key.clone());
                self.bigram_stats.update(
                    ev.key.clone(),
                    ev.total_time_ms,
                    ev.correct,
                    ev.has_hesitation,
                    drill_index as u32,
                );
            }
            // Update streaks once per drill per unique bigram (not per event)
            for key in &seen_bigrams {
                self.bigram_stats
                    .update_error_anomaly_streak(key, &self.key_stats);
                self.bigram_stats
                    .update_speed_anomaly_streak(key, &self.key_stats);
            }

            if result.ranked {
                let mut seen_ranked_bigrams: std::collections::HashSet<ngram_stats::BigramKey> =
                    std::collections::HashSet::new();

                for kt in &result.per_key_times {
                    if kt.correct {
                        let stat = self.ranked_key_stats.stats.entry(kt.key).or_default();
                        stat.total_count += 1;
                        if stat.total_count == 1 {
                            stat.error_rate_ema = 0.0;
                        } else {
                            stat.error_rate_ema = 0.1 * 0.0 + 0.9 * stat.error_rate_ema;
                        }
                    } else {
                        self.ranked_key_stats.update_key_error(kt.key);
                    }
                }
                for ev in &bigram_events {
                    seen_ranked_bigrams.insert(ev.key.clone());
                    self.ranked_bigram_stats.update(
                        ev.key.clone(),
                        ev.total_time_ms,
                        ev.correct,
                        ev.has_hesitation,
                        drill_index as u32,
                    );
                }
                for key in &seen_ranked_bigrams {
                    self.ranked_bigram_stats
                        .update_error_anomaly_streak(key, &self.ranked_key_stats);
                    self.ranked_bigram_stats
                        .update_speed_anomaly_streak(key, &self.ranked_key_stats);
                }
            }

            // Update transition buffer
            for kt in &result.per_key_times {
                if kt.key != BACKSPACE {
                    self.transition_buffer.push(kt.time_ms);
                }
            }
            if self.transition_buffer.len() > 200 {
                let excess = self.transition_buffer.len() - 200;
                self.transition_buffer.drain(..excess);
            }
            let mut buf = self.transition_buffer.clone();
            self.user_median_transition_ms = ngram_stats::compute_median(&mut buf);
        }

        // Put drill_history back
        self.drill_history = history;
    }

    pub fn retry_drill(&mut self) {
        if let Some(ref drill) = self.drill {
            let text: String = drill.target.iter().collect();
            let mut retry = DrillState::new(&text);
            retry.auto_indent_after_newline = self.drill_mode != DrillMode::Adaptive;
            self.drill = Some(retry);
            self.drill_events.clear();
            self.last_result = None;
            self.screen = AppScreen::Drill;
        } else {
            self.start_drill();
        }
    }

    pub fn continue_drill(&mut self) {
        self.history_confirm_delete = false;
        match self.drill_mode {
            DrillMode::Adaptive => self.start_drill(),
            DrillMode::Code => {
                if self.config.code_language != "all" {
                    if let Some(lang) = self.last_code_drill_language.clone() {
                        self.code_drill_language_override = Some(lang);
                    }
                }
                self.start_code_drill();
            }
            DrillMode::Passage => {
                if self.config.passage_book != "all" {
                    if let Some(selection) = self.last_passage_drill_selection.clone() {
                        self.passage_drill_selection_override = Some(selection);
                    }
                }
                self.start_passage_drill();
            }
        }
    }

    pub fn go_to_menu(&mut self) {
        self.clear_post_drill_input_lock();
        self.screen = AppScreen::Menu;
        self.drill = None;
        self.drill_source_info = None;
        self.drill_events.clear();
    }

    pub fn go_to_stats(&mut self) {
        self.clear_post_drill_input_lock();
        self.stats_tab = 0;
        self.history_selected = 0;
        self.history_scroll = 0;
        self.history_confirm_delete = false;
        self.screen = AppScreen::StatsDashboard;
    }

    pub fn delete_session(&mut self) {
        if self.drill_history.is_empty() {
            return;
        }
        // History tab shows reverse order, so convert display index to actual index
        let actual_idx = self.drill_history.len() - 1 - self.history_selected;
        self.drill_history.remove(actual_idx);
        self.rebuild_from_history();
        self.save_data();

        // Clamp selection to full history range
        if !self.drill_history.is_empty() {
            let max_idx = self.drill_history.len() - 1;
            self.history_selected = self.history_selected.min(max_idx);
            self.history_scroll = self.history_scroll.min(self.history_selected);
        } else {
            self.history_selected = 0;
            self.history_scroll = 0;
        }
    }

    pub fn rebuild_from_history(&mut self) {
        let previous_progress = self
            .profile
            .skill_tree_for_language(&self.config.dictionary_language);

        // Sticky mastery is one-way, so it must survive a rebuild even when the
        // strokes that earned it are no longer in `drill_history` (the retention
        // cap drops old drills, and delete_session() removes arbitrary ones).
        // Without this, a rebuild would demote keys while
        // merge_skill_tree_progress_non_regressive() below still restores the
        // branch as Complete, breaking the "completed branch implies every key
        // mastered" invariant and letting a demoted key emit a second mastery
        // milestone once it re-qualified.
        let previously_mastered = self.ranked_key_stats.mastered_keys();

        // Reset all derived state
        self.key_stats = KeyStatsStore::default();
        self.key_stats.target_cpm = self.config.target_cpm();
        self.ranked_key_stats = KeyStatsStore::default();
        self.ranked_key_stats.target_cpm = self.config.target_cpm();
        // Re-seed before replaying so skill-tree progression sees the same
        // mastered set the live path did.
        for key in previously_mastered {
            self.ranked_key_stats.seed_mastered(key);
        }
        self.skill_tree = SkillTree::default();
        self.profile.total_score = 0.0;
        self.profile.total_drills = 0;
        self.profile.streak_days = 0;
        self.profile.best_streak = 0;
        self.profile.last_practice_date = None;

        // Replay each remaining session oldest->newest.
        // NOTE: mirror the live path and call update_key_error for incorrect strokes
        // so the sticky `mastered` bit reconstructed from history matches the live
        // progression logic (which gates on error_rate_ema in addition to speed).
        for result in &self.drill_history {
            // Update timing stats for all sessions
            for kt in &result.per_key_times {
                if kt.correct {
                    self.key_stats.update_key(kt.key, kt.time_ms);
                } else {
                    self.key_stats.update_key_error(kt.key);
                }
            }
            // Only update skill tree for ranked sessions
            if result.ranked {
                for kt in &result.per_key_times {
                    if kt.correct {
                        self.ranked_key_stats.update_key_ranked(kt.key, kt.time_ms);
                    } else {
                        self.ranked_key_stats.update_key_error_ranked(kt.key);
                    }
                }
                self.skill_tree.update(&self.ranked_key_stats, None);
            }

            // Partial sessions are visible in history but do not affect profile/streak activity.
            if result.partial {
                continue;
            }

            // Compute score
            let complexity = self.skill_tree.complexity();
            let score = scoring::compute_score(result, complexity);
            self.profile.total_score += score;
            self.profile.total_drills += 1;

            // Rebuild streak tracking
            let day = result.timestamp.format("%Y-%m-%d").to_string();
            if self.profile.last_practice_date.as_deref() != Some(&day) {
                if let Some(ref last) = self.profile.last_practice_date {
                    let result_date = result.timestamp.date_naive();
                    let last_date =
                        chrono::NaiveDate::parse_from_str(last, "%Y-%m-%d").unwrap_or(result_date);
                    let diff = result_date.signed_duration_since(last_date).num_days();
                    if diff == 1 {
                        self.profile.streak_days += 1;
                    } else {
                        self.profile.streak_days = 1;
                    }
                } else {
                    self.profile.streak_days = 1;
                }
                self.profile.best_streak = self.profile.best_streak.max(self.profile.streak_days);
                self.profile.last_practice_date = Some(day);
            }
        }

        // Prevent destructive regressions when rebuilding from history:
        // preserve any previously reached branch status/level.
        merge_skill_tree_progress_non_regressive(&mut self.skill_tree, &previous_progress);
        self.profile.set_skill_tree_for_language(
            &self.config.dictionary_language,
            self.skill_tree.progress.clone(),
        );

        // Rebuild n-gram stats from the replayed history
        self.rebuild_ngram_stats();
    }

    pub fn go_to_skill_tree(&mut self) {
        self.skill_tree_selected = 0;
        self.skill_tree_detail_scroll = 0;
        self.skill_tree_confirm_unlock = None;
        self.screen = AppScreen::SkillTree;
    }

    pub fn unlock_branch(&mut self, branch_id: BranchId) -> bool {
        if !self.ensure_ranked_adaptive_readiness() {
            return false;
        }
        // Start the branch if it's Available.
        self.skill_tree.start_branch(branch_id);
        self.profile.set_skill_tree_for_language(
            &self.config.dictionary_language,
            self.skill_tree.progress.clone(),
        );
        self.save_data();
        true
    }

    pub fn start_branch_drill(&mut self, branch_id: BranchId) {
        if !self.unlock_branch(branch_id) {
            return;
        }

        // Use adaptive mode with branch-specific scope
        let old_mode = self.drill_mode;
        let old_scope = self.drill_scope;
        self.drill_mode = DrillMode::Adaptive;
        self.drill_scope = DrillScope::Branch(branch_id);
        if old_mode != DrillMode::Adaptive || old_scope != self.drill_scope {
            self.adaptive_word_history.clear();
        }
        self.start_drill();
    }

    pub fn go_to_keyboard(&mut self) {
        self.keyboard_explorer_selected = None;
        self.explorer_accuracy_cache_overall = None;
        self.explorer_accuracy_cache_ranked = None;
        self.screen = AppScreen::Keyboard;
    }

    pub fn key_accuracy(&mut self, ch: char, ranked_only: bool) -> (usize, usize) {
        let cache = if ranked_only {
            self.explorer_accuracy_cache_ranked
        } else {
            self.explorer_accuracy_cache_overall
        };
        if let Some((cached_key, correct, total)) = cache {
            if cached_key == ch {
                return (correct, total);
            }
        }
        let mut correct = 0usize;
        let mut total = 0usize;
        for result in &self.drill_history {
            if ranked_only && !result.ranked {
                continue;
            }
            for kt in &result.per_key_times {
                if kt.key == ch {
                    total += 1;
                    if kt.correct {
                        correct += 1;
                    }
                }
            }
        }
        if ranked_only {
            self.explorer_accuracy_cache_ranked = Some((ch, correct, total));
        } else {
            self.explorer_accuracy_cache_overall = Some((ch, correct, total));
        }
        (correct, total)
    }

    pub fn go_to_code_language_select(&mut self) {
        let options = code_language_options();
        self.code_language_selected = options
            .iter()
            .position(|(k, _)| *k == self.config.code_language)
            .unwrap_or(0);
        // Center the selected item in the viewport (rough estimate of 15 visible rows)
        self.code_language_scroll = self.code_language_selected.saturating_sub(7);
        self.screen = AppScreen::CodeLanguageSelect;
    }

    /// Rebuild language-derived generation assets after dictionary language changes.
    ///
    /// Contract:
    /// - rebuild `dictionary` and `transition_table`
    /// - clear adaptive cross-drill word history (language-specific cache)
    /// - do not mutate current in-progress drill text
    /// - do not swap the active skill-tree scope (caller handles language scope changes)
    pub fn rebuild_language_assets(&mut self) {
        self.dictionary = Dictionary::load_for_language(&self.config.dictionary_language);
        self.transition_table = TransitionTable::build_from_words(self.dictionary.words_list());
        self.adaptive_word_history.clear();
    }

    fn switch_dictionary_language(&mut self, next_language_key: &str) {
        let previous_language_key = self.config.dictionary_language.clone();
        self.profile
            .set_skill_tree_for_language(&previous_language_key, self.skill_tree.progress.clone());
        self.config.dictionary_language = next_language_key.to_string();
        self.rebuild_language_assets();
        self.skill_tree = Self::build_skill_tree_for_language(
            self.profile
                .skill_tree_for_language(&self.config.dictionary_language),
            &self.config.dictionary_language,
            &self.config.keyboard_layout,
        );
    }

    fn ensure_ranked_adaptive_readiness(&mut self) -> bool {
        match ranked_adaptive_readiness(
            &self.config.dictionary_language,
            &self.config.keyboard_layout,
        ) {
            Ok(()) => true,
            Err(err) => {
                self.settings_status_message = Some(StatusMessage {
                    kind: StatusKind::Error,
                    text: t!("status.adaptive_unavailable", error = err.to_string()).to_string(),
                });
                self.go_to_settings();
                self.settings_selected = SettingItem::DictionaryLanguage.index();
                false
            }
        }
    }

    pub fn start_global_adaptive_drill(&mut self) -> bool {
        if !self.ensure_ranked_adaptive_readiness() {
            return false;
        }
        let old_mode = self.drill_mode;
        let old_scope = self.drill_scope;
        self.drill_mode = DrillMode::Adaptive;
        self.drill_scope = DrillScope::Global;
        if old_mode != DrillMode::Adaptive || old_scope != self.drill_scope {
            self.adaptive_word_history.clear();
        }
        self.start_drill();
        true
    }

    pub fn go_to_settings(&mut self) {
        self.settings_selected = SettingItem::TargetWpm.index();
        self.settings_editing_path = None;
        self.screen = AppScreen::Settings;
    }

    pub fn go_to_ui_language_select(&mut self) {
        self.ui_language_selected = crate::i18n::SUPPORTED_UI_LOCALES
            .iter()
            .position(|&k| k == self.config.ui_language)
            .unwrap_or(0);
        self.ui_language_scroll = 0;
        self.screen = AppScreen::UiLanguageSelect;
    }

    pub fn go_to_dictionary_language_select(&mut self) {
        let options = crate::l10n::language_pack::language_packs();
        self.dictionary_language_selected = options
            .iter()
            .position(|pack| pack.language_key == self.config.dictionary_language)
            .unwrap_or(0);
        self.dictionary_language_scroll = self.dictionary_language_selected.saturating_sub(7);
        self.screen = AppScreen::DictionaryLanguageSelect;
    }

    pub fn go_to_keyboard_layout_select(&mut self) {
        let options = KeyboardModel::supported_layout_keys();
        self.keyboard_layout_selected = options
            .iter()
            .position(|&k| k == self.config.keyboard_layout)
            .unwrap_or(0);
        self.keyboard_layout_scroll = self.keyboard_layout_selected.saturating_sub(7);
        self.screen = AppScreen::KeyboardLayoutSelect;
    }

    pub fn set_dictionary_language(
        &mut self,
        language_key: &str,
    ) -> Result<CapabilityState, LanguageLayoutValidationError> {
        let default_layout =
            default_keyboard_layout_for_language(language_key).ok_or_else(|| {
                LanguageLayoutValidationError::UnknownLanguage(language_key.to_string())
            })?;
        let capability = validate_language_layout_pair(language_key, default_layout)?;
        if !Dictionary::supports_language(language_key) {
            return Err(LanguageLayoutValidationError::UnknownLanguage(
                language_key.to_string(),
            ));
        }
        self.config.keyboard_layout = default_layout.to_string();
        self.keyboard_model = KeyboardModel::from_key(default_layout)
            .expect("language default layout must map to a keyboard model");
        self.switch_dictionary_language(language_key);
        Ok(capability)
    }

    pub fn set_keyboard_layout(
        &mut self,
        layout_key: &str,
    ) -> Result<CapabilityState, LanguageLayoutValidationError> {
        if !KeyboardModel::supported_layout_keys().contains(&layout_key) {
            return Err(LanguageLayoutValidationError::UnknownLayout(
                layout_key.to_string(),
            ));
        }
        let capability =
            validate_language_layout_pair(&self.config.dictionary_language, layout_key)?;
        self.config.keyboard_layout = layout_key.to_string();
        self.keyboard_model = KeyboardModel::from_key(layout_key)
            .expect("validated layout key must map to a keyboard model");
        Ok(capability)
    }

    pub fn go_to_passage_book_select(&mut self) {
        let options = passage_options();
        let selected = options
            .iter()
            .position(|(key, _)| *key == self.config.passage_book)
            .unwrap_or(0);
        self.passage_book_selected = selected;
        self.screen = AppScreen::PassageBookSelect;
    }

    pub fn go_to_passage_intro(&mut self) {
        self.passage_intro_selected = 0;
        self.passage_intro_downloads_enabled = self.config.passage_downloads_enabled;
        self.passage_intro_download_dir = self.config.passage_download_dir.clone();
        self.passage_intro_paragraph_limit = self.config.passage_paragraphs_per_book;
        self.passage_intro_downloading = false;
        self.passage_intro_download_total = 0;
        self.passage_intro_downloaded = 0;
        self.passage_intro_current_book.clear();
        self.passage_intro_download_bytes = 0;
        self.passage_intro_download_bytes_total = 0;
        self.passage_download_queue.clear();
        self.passage_download_job = None;
        self.passage_download_action = PassageDownloadCompleteAction::StartPassageDrill;
        self.screen = AppScreen::PassageIntro;
    }

    pub fn go_to_code_intro(&mut self) {
        self.code_intro_selected = 0;
        self.code_intro_downloads_enabled = self.config.code_downloads_enabled;
        self.code_intro_download_dir = self.config.code_download_dir.clone();
        self.code_intro_snippets_per_repo = self.config.code_snippets_per_repo;
        self.code_intro_downloading = false;
        self.code_intro_download_total = 0;
        self.code_intro_downloaded = 0;
        self.code_intro_current_repo.clear();
        self.code_intro_download_bytes = 0;
        self.code_intro_download_bytes_total = 0;
        self.code_download_queue.clear();
        self.code_download_job = None;
        self.code_download_action = CodeDownloadCompleteAction::StartCodeDrill;
        self.code_download_attempted = false;
        self.screen = AppScreen::CodeIntro;
    }

    pub fn start_code_drill(&mut self) {
        // Step 1: Resolve concrete language (never download with "all" selected)
        if self.code_drill_language_override.is_none() {
            let chosen = if self.config.code_language == "all" {
                let available = languages_with_content(&self.config.code_download_dir);
                if available.is_empty() {
                    "rust".to_string()
                } else {
                    let idx = self.rng.gen_range(0..available.len());
                    available[idx].to_string()
                }
            } else {
                self.config.code_language.clone()
            };
            self.code_drill_language_override = Some(chosen);
        }

        let chosen = self.code_drill_language_override.clone().unwrap();

        // Step 2: Check if we need to download (only if not already attempted)
        if self.config.code_downloads_enabled && !self.code_download_attempted {
            let queue = build_code_download_queue(&chosen, &self.config.code_download_dir);
            if !queue.is_empty() {
                self.code_intro_download_total = queue.len();
                self.code_download_queue = queue;
                self.code_intro_downloaded = 0;
                self.code_intro_downloading = true;
                self.code_intro_current_repo.clear();
                self.code_download_action = CodeDownloadCompleteAction::StartCodeDrill;
                self.code_download_job = None;
                self.code_download_attempted = true;
                self.screen = AppScreen::CodeDownloadProgress;
                return;
            }
        }

        // Step 3: If language has no built-in AND no cache → fallback
        if !is_language_cached(&self.config.code_download_dir, &chosen) {
            if let Some(lang) = language_by_key(&chosen) {
                if !lang.has_builtin {
                    self.code_drill_language_override = Some("rust".to_string());
                }
            }
        }

        // Step 4: Start the drill
        self.code_download_attempted = false;
        self.adaptive_word_history.clear();
        self.drill_mode = DrillMode::Code;
        self.drill_scope = DrillScope::Global;
        self.start_drill();
    }

    pub fn start_code_downloads(&mut self) {
        let queue =
            build_code_download_queue(&self.config.code_language, &self.code_intro_download_dir);

        self.code_intro_download_total = queue.len();
        self.code_download_queue = queue;
        self.code_intro_downloaded = 0;
        self.code_intro_downloading = self.code_intro_download_total > 0;
        self.code_intro_download_bytes = 0;
        self.code_intro_download_bytes_total = 0;
        self.code_download_job = None;
    }

    pub fn start_code_downloads_from_settings(&mut self) {
        self.go_to_code_intro();
        self.code_download_action = CodeDownloadCompleteAction::ReturnToSettings;
        self.start_code_downloads();
        if !self.code_intro_downloading {
            self.go_to_settings();
        }
    }

    pub fn process_code_download_tick(&mut self) {
        if !self.code_intro_downloading {
            return;
        }

        if self.code_download_job.is_none() {
            let Some((lang_key, repo_idx)) = self.code_download_queue.pop() else {
                self.code_intro_downloading = false;
                self.code_intro_current_repo.clear();
                match self.code_download_action {
                    CodeDownloadCompleteAction::StartCodeDrill => self.start_code_drill(),
                    CodeDownloadCompleteAction::ReturnToSettings => self.go_to_settings(),
                }
                return;
            };

            self.spawn_code_download_job(&lang_key, repo_idx);
            return;
        }

        let mut finished = false;
        if let Some(job) = self.code_download_job.as_mut() {
            self.code_intro_download_bytes = job.downloaded_bytes.load(Ordering::Relaxed);
            self.code_intro_download_bytes_total = job.total_bytes.load(Ordering::Relaxed);
            finished = job.done.load(Ordering::Relaxed);
        }

        if !finished {
            return;
        }

        if let Some(mut job) = self.code_download_job.take() {
            if let Some(handle) = job.handle.take() {
                let _ = handle.join();
            }
            self.code_intro_downloaded = self.code_intro_downloaded.saturating_add(1);
        }

        if self.code_intro_downloaded >= self.code_intro_download_total {
            self.code_intro_downloading = false;
            self.code_intro_current_repo.clear();
            self.code_intro_download_bytes = 0;
            self.code_intro_download_bytes_total = 0;
            match self.code_download_action {
                CodeDownloadCompleteAction::StartCodeDrill => self.start_code_drill(),
                CodeDownloadCompleteAction::ReturnToSettings => self.go_to_settings(),
            }
        }
    }

    fn spawn_code_download_job(&mut self, language_key: &str, repo_idx: usize) {
        let Some(lang) = language_by_key(language_key) else {
            return;
        };
        let Some(repo) = lang.repos.get(repo_idx) else {
            return;
        };

        self.code_intro_current_repo = repo.key.to_string();
        self.code_intro_download_bytes = 0;
        self.code_intro_download_bytes_total = 0;

        let downloaded_bytes = Arc::new(AtomicU64::new(0));
        let total_bytes = Arc::new(AtomicU64::new(0));
        let done = Arc::new(AtomicBool::new(false));
        let success = Arc::new(AtomicBool::new(false));

        let dl_clone = Arc::clone(&downloaded_bytes);
        let total_clone = Arc::clone(&total_bytes);
        let done_clone = Arc::clone(&done);
        let success_clone = Arc::clone(&success);

        let cache_dir = self.code_intro_download_dir.clone();
        let lang_key = language_key.to_string();
        let snippets_limit = self.code_intro_snippets_per_repo;

        // Get static references for thread
        let repo_ref: &'static crate::generator::code_syntax::CodeRepo = &lang.repos[repo_idx];
        let block_style_ref: &'static crate::generator::code_syntax::BlockStyle = &lang.block_style;

        let handle = thread::spawn(move || {
            let ok = download_code_repo_to_cache_with_progress(
                &cache_dir,
                &lang_key,
                repo_ref,
                block_style_ref,
                snippets_limit,
                |downloaded, total| {
                    dl_clone.store(downloaded, Ordering::Relaxed);
                    if let Some(total) = total {
                        total_clone.store(total, Ordering::Relaxed);
                    }
                },
            );

            success_clone.store(ok, Ordering::Relaxed);
            done_clone.store(true, Ordering::Relaxed);
        });

        self.code_download_job = Some(DownloadJob {
            downloaded_bytes,
            total_bytes,
            done,
            success,
            handle: Some(handle),
        });
    }

    pub fn start_passage_drill(&mut self) {
        // Lazy source selection: choose a specific source for this drill and
        // download exactly one missing book when needed.
        if self.passage_drill_selection_override.is_none() && self.config.passage_downloads_enabled
        {
            let chosen = if self.config.passage_book == "all" {
                let count = GUTENBERG_BOOKS.len() + 1; // + built-in
                let idx = self.rng.gen_range(0..count);
                if idx == 0 {
                    "builtin".to_string()
                } else {
                    GUTENBERG_BOOKS[idx - 1].key.to_string()
                }
            } else {
                self.config.passage_book.clone()
            };

            if chosen != "builtin"
                && !is_book_cached(&self.config.passage_download_dir, &chosen)
                && book_by_key(&chosen).is_some()
            {
                self.passage_drill_selection_override = Some(chosen.clone());
                self.passage_intro_downloading = true;
                self.passage_intro_download_total = 1;
                self.passage_intro_downloaded = 0;
                self.passage_intro_download_bytes = 0;
                self.passage_intro_download_bytes_total = 0;
                self.passage_intro_current_book = book_by_key(&chosen)
                    .map(|b| b.title.to_string())
                    .unwrap_or_default();
                self.passage_download_queue = GUTENBERG_BOOKS
                    .iter()
                    .enumerate()
                    .filter_map(|(i, b)| (b.key == chosen).then_some(i))
                    .collect();
                self.passage_download_action = PassageDownloadCompleteAction::StartPassageDrill;
                self.passage_download_job = None;
                self.screen = AppScreen::PassageDownloadProgress;
                return;
            }

            self.passage_drill_selection_override = Some(chosen);
        } else if self.passage_drill_selection_override.is_none()
            && self.config.passage_book != "all"
            && self.config.passage_book != "builtin"
        {
            // Downloads disabled: gracefully fall back to built-in if selected book is unavailable.
            if !is_book_cached(&self.config.passage_download_dir, &self.config.passage_book) {
                self.passage_drill_selection_override = Some("builtin".to_string());
            } else {
                self.passage_drill_selection_override = Some(self.config.passage_book.clone());
            }
        }

        self.adaptive_word_history.clear();
        self.drill_mode = DrillMode::Passage;
        self.drill_scope = DrillScope::Global;
        self.start_drill();
    }

    pub fn start_passage_downloads(&mut self) {
        let uncached = uncached_books(&self.passage_intro_download_dir);
        let uncached_keys: std::collections::HashSet<&str> =
            uncached.iter().map(|b| b.key).collect();
        self.passage_download_queue = GUTENBERG_BOOKS
            .iter()
            .enumerate()
            .filter_map(|(i, book)| uncached_keys.contains(book.key).then_some(i))
            .collect();
        self.passage_intro_download_total = self.passage_download_queue.len();
        self.passage_intro_downloaded = 0;
        self.passage_intro_downloading = self.passage_intro_download_total > 0;
        self.passage_intro_download_bytes = 0;
        self.passage_intro_download_bytes_total = 0;
        self.passage_download_job = None;
    }

    pub fn cancel_code_download(&mut self) {
        self.code_download_queue.clear();
        self.code_intro_downloading = false;
        self.code_download_job = None;
        self.code_drill_language_override = None;
        self.code_download_attempted = false;
    }

    pub fn start_passage_downloads_from_settings(&mut self) {
        self.go_to_passage_intro();
        self.passage_download_action = PassageDownloadCompleteAction::ReturnToSettings;
        self.start_passage_downloads();
        if !self.passage_intro_downloading {
            self.go_to_settings();
        }
    }

    fn apply_dictionary_language_by_offset(&mut self, step: isize) {
        let keys: Vec<&'static str> = crate::l10n::language_pack::language_packs()
            .iter()
            .map(|pack| pack.language_key)
            .collect();
        if keys.is_empty() {
            self.settings_status_message = Some(StatusMessage {
                kind: StatusKind::Error,
                text: t!("errors.unknown_language", key = "none").to_string(),
            });
            return;
        }

        let len = keys.len();
        let current = keys
            .iter()
            .position(|&l| l == self.config.dictionary_language.as_str())
            .unwrap_or(0);
        let next = if step >= 0 {
            (current + step as usize) % len
        } else {
            (current + len - ((-step) as usize % len)) % len
        };
        let selected = keys[next];
        match self.set_dictionary_language(selected) {
            Ok(_capability) => {}
            Err(err) => {
                self.settings_status_message = Some(StatusMessage {
                    kind: StatusKind::Error,
                    text: crate::i18n::localized_language_layout_error(&err),
                });
            }
        }
    }

    fn apply_keyboard_layout_by_offset(&mut self, step: isize) {
        let keys = KeyboardModel::supported_layout_keys();
        if keys.is_empty() {
            self.settings_status_message = Some(StatusMessage {
                kind: StatusKind::Error,
                text: t!("errors.unknown_layout", key = "none").to_string(),
            });
            return;
        }
        let len = keys.len();
        let current = keys
            .iter()
            .position(|&k| k == self.config.keyboard_layout)
            .unwrap_or(0);
        let next = if step >= 0 {
            (current + step as usize) % len
        } else {
            (current + len - ((-step) as usize % len)) % len
        };
        let selected = keys[next];
        match self.set_keyboard_layout(selected) {
            Ok(CapabilityState::Enabled | CapabilityState::Disabled) => {}
            Err(err) => {
                self.settings_status_message = Some(StatusMessage {
                    kind: StatusKind::Error,
                    text: crate::i18n::localized_language_layout_error(&err),
                });
            }
        }
    }

    pub fn process_passage_download_tick(&mut self) {
        if !self.passage_intro_downloading {
            return;
        }

        if self.passage_download_job.is_none() {
            let Some(book_index) = self.passage_download_queue.pop() else {
                self.passage_intro_downloading = false;
                self.passage_intro_current_book.clear();
                match self.passage_download_action {
                    PassageDownloadCompleteAction::StartPassageDrill => self.start_passage_drill(),
                    PassageDownloadCompleteAction::ReturnToSettings => self.go_to_settings(),
                }
                return;
            };
            self.spawn_passage_download_job(book_index);
            return;
        }

        let mut finished = false;
        if let Some(job) = self.passage_download_job.as_mut() {
            self.passage_intro_download_bytes = job.downloaded_bytes.load(Ordering::Relaxed);
            self.passage_intro_download_bytes_total = job.total_bytes.load(Ordering::Relaxed);
            finished = job.done.load(Ordering::Relaxed);
        }

        if !finished {
            return;
        }

        if let Some(mut job) = self.passage_download_job.take() {
            if let Some(handle) = job.handle.take() {
                let _ = handle.join();
            }
            if job.success.load(Ordering::Relaxed) {
                self.passage_intro_downloaded = self.passage_intro_downloaded.saturating_add(1);
            } else {
                // Skip failed book and continue queue without hanging.
                self.passage_intro_downloaded = self.passage_intro_downloaded.saturating_add(1);
            }
        }

        if self.passage_intro_downloaded >= self.passage_intro_download_total {
            self.passage_intro_downloading = false;
            self.passage_intro_current_book.clear();
            self.passage_intro_download_bytes = 0;
            self.passage_intro_download_bytes_total = 0;
            match self.passage_download_action {
                PassageDownloadCompleteAction::StartPassageDrill => self.start_passage_drill(),
                PassageDownloadCompleteAction::ReturnToSettings => self.go_to_settings(),
            }
        }
    }

    fn spawn_passage_download_job(&mut self, book_index: usize) {
        let Some(book) = GUTENBERG_BOOKS.get(book_index) else {
            return;
        };

        self.passage_intro_current_book = book.title.to_string();
        self.passage_intro_download_bytes = 0;
        self.passage_intro_download_bytes_total = 0;

        let downloaded_bytes = Arc::new(AtomicU64::new(0));
        let total_bytes = Arc::new(AtomicU64::new(0));
        let done = Arc::new(AtomicBool::new(false));
        let success = Arc::new(AtomicBool::new(false));

        let dl_clone = Arc::clone(&downloaded_bytes);
        let total_clone = Arc::clone(&total_bytes);
        let done_clone = Arc::clone(&done);
        let success_clone = Arc::clone(&success);

        let cache_dir = self.passage_intro_download_dir.clone();
        let book_ref: &'static crate::generator::passage::GutenbergBook =
            &GUTENBERG_BOOKS[book_index];

        let handle = thread::spawn(move || {
            let ok = download_book_to_cache_with_progress(
                cache_dir.as_str(),
                book_ref,
                |downloaded, total| {
                    dl_clone.store(downloaded, Ordering::Relaxed);
                    if let Some(total) = total {
                        total_clone.store(total, Ordering::Relaxed);
                    }
                },
            );

            success_clone.store(ok, Ordering::Relaxed);
            done_clone.store(true, Ordering::Relaxed);
        });

        self.passage_download_job = Some(DownloadJob {
            downloaded_bytes,
            total_bytes,
            done,
            success,
            handle: Some(handle),
        });
    }

    pub fn settings_cycle_forward(&mut self) {
        match SettingItem::from_index(self.settings_selected) {
            SettingItem::TargetWpm => {
                self.config.target_wpm = (self.config.target_wpm + 5).min(200);
                self.key_stats.target_cpm = self.config.target_cpm();
                self.ranked_key_stats.target_cpm = self.config.target_cpm();
            }
            SettingItem::Theme => {
                let themes = Theme::available_themes();
                if let Some(idx) = themes.iter().position(|t| *t == self.config.theme) {
                    let next = (idx + 1) % themes.len();
                    self.config.theme = themes[next].clone();
                } else if let Some(first) = themes.first() {
                    self.config.theme = first.clone();
                }
                if let Some(new_theme) = Theme::load(&self.config.theme) {
                    let theme: &'static Theme = Box::leak(Box::new(new_theme));
                    self.theme = theme;
                    self.menu.theme = theme;
                }
            }
            SettingItem::WordCount => {
                self.config.word_count = (self.config.word_count + 5).min(100);
            }
            SettingItem::UiLanguage => {
                let locales = crate::i18n::SUPPORTED_UI_LOCALES;
                let idx = locales
                    .iter()
                    .position(|&l| l == self.config.ui_language)
                    .unwrap_or(0);
                let next = (idx + 1) % locales.len();
                self.config.ui_language = locales[next].to_string();
                crate::i18n::set_ui_locale(&self.config.ui_language);
            }
            SettingItem::DictionaryLanguage => {
                self.apply_dictionary_language_by_offset(1);
            }
            SettingItem::KeyboardLayout => {
                self.apply_keyboard_layout_by_offset(1);
            }
            SettingItem::CodeLanguage => {
                let options = code_language_options();
                let keys: Vec<&str> = options.iter().map(|(k, _)| *k).collect();
                let idx = keys
                    .iter()
                    .position(|&l| l == self.config.code_language)
                    .unwrap_or(0);
                let next = (idx + 1) % keys.len();
                self.config.code_language = keys[next].to_string();
            }
            SettingItem::CodeDownloads => {
                self.config.code_downloads_enabled = !self.config.code_downloads_enabled;
            }
            SettingItem::CodeDownloadDir => {
                // Editable text field handled directly in key handler.
            }
            SettingItem::SnippetsPerRepo => {
                self.config.code_snippets_per_repo = match self.config.code_snippets_per_repo {
                    0 => 1,
                    n if n >= 200 => 0,
                    n => n + 10,
                };
            }
            SettingItem::PassageDownloads => {
                self.config.passage_downloads_enabled = !self.config.passage_downloads_enabled;
            }
            SettingItem::PassageDownloadDir => {
                // Passage download dir - editable text field handled directly in key handler.
            }
            SettingItem::ParagraphsPerBook => {
                self.config.passage_paragraphs_per_book =
                    match self.config.passage_paragraphs_per_book {
                        0 => 1,
                        n if n >= 500 => 0,
                        n => n + 25,
                    };
            }
            SettingItem::DownloadCodeNow
            | SettingItem::DownloadPassagesNow
            | SettingItem::ExportPath
            | SettingItem::ExportData
            | SettingItem::ImportPath
            | SettingItem::ImportData => {}
        }
    }

    pub fn settings_cycle_backward(&mut self) {
        match SettingItem::from_index(self.settings_selected) {
            SettingItem::TargetWpm => {
                self.config.target_wpm = self.config.target_wpm.saturating_sub(5).max(10);
                self.key_stats.target_cpm = self.config.target_cpm();
                self.ranked_key_stats.target_cpm = self.config.target_cpm();
            }
            SettingItem::Theme => {
                let themes = Theme::available_themes();
                if let Some(idx) = themes.iter().position(|t| *t == self.config.theme) {
                    let next = if idx == 0 { themes.len() - 1 } else { idx - 1 };
                    self.config.theme = themes[next].clone();
                } else if let Some(first) = themes.first() {
                    self.config.theme = first.clone();
                }
                if let Some(new_theme) = Theme::load(&self.config.theme) {
                    let theme: &'static Theme = Box::leak(Box::new(new_theme));
                    self.theme = theme;
                    self.menu.theme = theme;
                }
            }
            SettingItem::WordCount => {
                self.config.word_count = self.config.word_count.saturating_sub(5).max(5);
            }
            SettingItem::UiLanguage => {
                let locales = crate::i18n::SUPPORTED_UI_LOCALES;
                let idx = locales
                    .iter()
                    .position(|&l| l == self.config.ui_language)
                    .unwrap_or(0);
                let next = if idx == 0 { locales.len() - 1 } else { idx - 1 };
                self.config.ui_language = locales[next].to_string();
                crate::i18n::set_ui_locale(&self.config.ui_language);
            }
            SettingItem::DictionaryLanguage => {
                self.apply_dictionary_language_by_offset(-1);
            }
            SettingItem::KeyboardLayout => {
                self.apply_keyboard_layout_by_offset(-1);
            }
            SettingItem::CodeLanguage => {
                let options = code_language_options();
                let keys: Vec<&str> = options.iter().map(|(k, _)| *k).collect();
                let idx = keys
                    .iter()
                    .position(|&l| l == self.config.code_language)
                    .unwrap_or(0);
                let next = if idx == 0 { keys.len() - 1 } else { idx - 1 };
                self.config.code_language = keys[next].to_string();
            }
            SettingItem::CodeDownloads => {
                self.config.code_downloads_enabled = !self.config.code_downloads_enabled;
            }
            SettingItem::CodeDownloadDir => {
                // Editable text field handled directly in key handler.
            }
            SettingItem::SnippetsPerRepo => {
                self.config.code_snippets_per_repo = match self.config.code_snippets_per_repo {
                    0 => 200,
                    1 => 0,
                    n => n.saturating_sub(10).max(1),
                };
            }
            SettingItem::PassageDownloads => {
                self.config.passage_downloads_enabled = !self.config.passage_downloads_enabled;
            }
            SettingItem::PassageDownloadDir => {
                // Passage download dir - editable text field handled directly in key handler.
            }
            SettingItem::ParagraphsPerBook => {
                self.config.passage_paragraphs_per_book =
                    match self.config.passage_paragraphs_per_book {
                        0 => 500,
                        1 => 0,
                        n => n.saturating_sub(25).max(1),
                    };
            }
            SettingItem::DownloadCodeNow
            | SettingItem::DownloadPassagesNow
            | SettingItem::ExportPath
            | SettingItem::ExportData
            | SettingItem::ImportPath
            | SettingItem::ImportData => {}
        }
    }
}

fn branch_status_rank(status: &BranchStatus) -> u8 {
    match status {
        BranchStatus::Locked => 0,
        BranchStatus::Available => 1,
        BranchStatus::InProgress => 2,
        BranchStatus::Complete => 3,
    }
}

fn merge_skill_tree_progress_non_regressive(
    skill_tree: &mut SkillTree,
    previous: &crate::engine::skill_tree::SkillTreeProgress,
) {
    for id in crate::engine::skill_tree::BranchId::all() {
        let Some(prev) = previous.branches.get(id.to_key()) else {
            continue;
        };
        let curr = skill_tree.branch_progress_mut(*id);
        if branch_status_rank(&curr.status) < branch_status_rank(&prev.status) {
            curr.status = prev.status.clone();
        }
        curr.current_level = curr.current_level.max(prev.current_level);
    }
}

/// Insert newlines at sentence boundaries (~60-80 chars per line).
fn insert_line_breaks(text: &str) -> String {
    let mut result = String::with_capacity(text.len());
    let mut col = 0;

    for (i, ch) in text.chars().enumerate() {
        result.push(ch);
        col += 1;

        // After sentence-ending punctuation + space, insert newline if past 60 chars
        if col >= 60 && (ch == '.' || ch == '?' || ch == '!') {
            // Check if next char is a space
            let next = text.chars().nth(i + 1);
            if next == Some(' ') {
                result.push('\n');
                col = 0;
                // Skip the space (will be consumed by next iteration but we already broke the line)
            }
        } else if col >= 80 && ch == ' ' {
            // Hard wrap at spaces if no sentence boundary found
            // Replace the space we just pushed with a newline
            result.pop();
            result.push('\n');
            col = 0;
        }
    }

    result
}

fn lowercase_generation_focus(focused: Option<char>) -> Option<char> {
    focused.and_then(|ch| {
        if ch.is_lowercase() {
            Some(ch)
        } else if ch.is_uppercase() {
            ch.to_lowercase().next()
        } else {
            None
        }
    })
}

fn count_matching_chars(text: &str, keys: &[char]) -> usize {
    text.chars().filter(|ch| keys.contains(ch)).count()
}

fn first_alpha_index(chars: &[char]) -> Option<usize> {
    chars.iter().position(|ch| ch.is_alphabetic())
}

fn top_up_capitals(
    text: String,
    cap_keys: &[char],
    focused: Option<char>,
    needed: usize,
    rng: &mut SmallRng,
) -> String {
    if needed == 0 || cap_keys.is_empty() {
        return text;
    }
    let mut words: Vec<String> = text.split_whitespace().map(|w| w.to_string()).collect();
    if words.is_empty() {
        return text;
    }

    let mut remaining = needed;
    let mut attempts = 0usize;
    let max_attempts = words.len().saturating_mul(10).max(needed);
    while remaining > 0 && attempts < max_attempts {
        attempts += 1;
        let idx = rng.gen_range(0..words.len());
        let mut chars: Vec<char> = words[idx].chars().collect();
        let Some(pos) = first_alpha_index(&chars) else {
            continue;
        };
        if chars[pos].is_uppercase() {
            continue;
        }
        let replacement = if let Some(ch) = focused.filter(|ch| ch.is_uppercase()) {
            ch
        } else {
            cap_keys[rng.gen_range(0..cap_keys.len())]
        };
        chars[pos] = replacement;
        words[idx] = chars.into_iter().collect();
        remaining = remaining.saturating_sub(1);
    }
    words.join(" ")
}

fn top_up_punctuation(
    text: String,
    punct_keys: &[char],
    needed: usize,
    rng: &mut SmallRng,
) -> String {
    if needed == 0 || punct_keys.is_empty() {
        return text;
    }
    let mut words: Vec<String> = text.split_whitespace().map(|w| w.to_string()).collect();
    if words.is_empty() {
        return text;
    }
    for _ in 0..needed {
        let idx = rng.gen_range(0..words.len());
        let p = punct_keys[rng.gen_range(0..punct_keys.len())];
        words[idx].push(p);
    }
    words.join(" ")
}

fn random_number_token(digit_keys: &[char], focused: Option<char>, rng: &mut SmallRng) -> String {
    let len = rng.gen_range(1..=3);
    let focused_digit = focused.filter(|ch| ch.is_ascii_digit());
    (0..len)
        .map(|_| {
            if let Some(fd) = focused_digit {
                if rng.gen_bool(0.45) {
                    return fd;
                }
            }
            digit_keys[rng.gen_range(0..digit_keys.len())]
        })
        .collect()
}

fn top_up_numbers(
    text: String,
    digit_keys: &[char],
    focused: Option<char>,
    needed: usize,
    rng: &mut SmallRng,
) -> String {
    if needed == 0 || digit_keys.is_empty() {
        return text;
    }
    let mut words: Vec<String> = text.split_whitespace().map(|w| w.to_string()).collect();
    if words.is_empty() {
        return text;
    }
    for _ in 0..needed {
        let idx = rng.gen_range(0..words.len());
        words[idx] = random_number_token(digit_keys, focused, rng);
    }
    words.join(" ")
}

fn top_up_code_symbols(
    text: String,
    symbol_keys: &[char],
    focused: Option<char>,
    needed: usize,
    rng: &mut SmallRng,
) -> String {
    if needed == 0 || symbol_keys.is_empty() {
        return text;
    }
    let mut words: Vec<String> = text.split_whitespace().map(|w| w.to_string()).collect();
    if words.is_empty() {
        return text;
    }
    let focused_symbol = focused.filter(|ch| symbol_keys.contains(ch));
    for _ in 0..needed {
        let idx = rng.gen_range(0..words.len());
        let sym = focused_symbol
            .filter(|_| rng.gen_bool(0.5))
            .unwrap_or_else(|| symbol_keys[rng.gen_range(0..symbol_keys.len())]);
        if rng.gen_bool(0.5) {
            words[idx].insert(0, sym);
        } else {
            words[idx].push(sym);
        }
    }
    words.join(" ")
}

fn top_up_whitespace(
    text: String,
    has_newline: bool,
    has_tab: bool,
    needed: usize,
    rng: &mut SmallRng,
) -> String {
    if needed == 0 || (!has_newline && !has_tab) {
        return text;
    }
    let mut chars: Vec<char> = text.chars().collect();
    let mut added = 0usize;
    let mut attempts = 0usize;
    let max_attempts = needed.saturating_mul(12).max(24);

    while added < needed && attempts < max_attempts {
        attempts += 1;
        let space_positions: Vec<usize> = chars
            .iter()
            .enumerate()
            .filter_map(|(i, ch)| if *ch == ' ' { Some(i) } else { None })
            .collect();
        if space_positions.is_empty() {
            break;
        }
        let pos = space_positions[rng.gen_range(0..space_positions.len())];

        // When both are unlocked, prefer indentation-like `\n\t` insertions.
        if has_newline && has_tab && added + 2 <= needed && rng.gen_bool(0.70) {
            chars[pos] = '\n';
            chars.insert(pos + 1, '\t');
            added += 2;
            continue;
        }

        // Newline-only insertion.
        if has_newline && (!has_tab || rng.gen_bool(0.80)) {
            chars[pos] = '\n';
            added += 1;
            continue;
        }

        // Mid-line tabs are allowed but intentionally rare.
        if has_tab {
            chars[pos] = '\t';
            added += 1;
        }
    }

    // Ensure at least one tab is present when tab is unlocked.
    if has_tab && !chars.contains(&'\t') {
        if let Some(pos) = chars.iter().position(|ch| *ch == ' ') {
            if has_newline {
                chars[pos] = '\n';
                chars.insert(pos + 1, '\t');
            } else {
                chars[pos] = '\t';
            }
        } else if has_newline {
            chars.push('\n');
            chars.push('\t');
        } else {
            chars.push('\t');
        }
    }

    chars.into_iter().collect()
}

#[allow(clippy::too_many_arguments)]
fn rebalance_branch_injections(
    mut text: String,
    cap_keys: &[char],
    punct_keys: &[char],
    digit_keys: &[char],
    symbol_keys: &[char],
    has_newline: bool,
    has_tab: bool,
    focused: Option<char>,
    target_per_branch: usize,
    rng: &mut SmallRng,
) -> String {
    let bonus = 2usize;

    if !punct_keys.is_empty() {
        let current = count_matching_chars(&text, punct_keys);
        let target = if focused.is_some_and(|ch| punct_keys.contains(&ch)) {
            target_per_branch + bonus
        } else {
            target_per_branch
        };
        if current < target {
            text = top_up_punctuation(text, punct_keys, target - current, rng);
        }
    }

    if !digit_keys.is_empty() {
        // Digits are intentionally ASCII-only for current keyboard/profile scope.
        let current = text.chars().filter(|ch| ch.is_ascii_digit()).count();
        let target = if focused.is_some_and(|ch| ch.is_ascii_digit()) {
            target_per_branch + bonus
        } else {
            target_per_branch
        };
        if current < target {
            text = top_up_numbers(text, digit_keys, focused, target - current, rng);
        }
    }

    if !symbol_keys.is_empty() {
        let current = count_matching_chars(&text, symbol_keys);
        let target = if focused.is_some_and(|ch| symbol_keys.contains(&ch)) {
            target_per_branch + bonus
        } else {
            target_per_branch
        };
        if current < target {
            text = top_up_code_symbols(text, symbol_keys, focused, target - current, rng);
        }
    }

    // Run capitals late so replacement passes (e.g. numbers) don't erase them.
    if !cap_keys.is_empty() {
        let current = text.chars().filter(|ch| ch.is_uppercase()).count();
        let target = if focused.is_some_and(|ch| ch.is_uppercase()) {
            target_per_branch + bonus
        } else {
            target_per_branch
        };
        if current < target {
            text = top_up_capitals(text, cap_keys, focused, target - current, rng);
        }
    }

    // Run whitespace last because word-based passes normalize separators.
    if has_newline || has_tab {
        let current = text.chars().filter(|ch| *ch == '\n' || *ch == '\t').count();
        let target = if focused.is_some_and(|ch| ch == '\n' || ch == '\t') {
            target_per_branch + bonus
        } else {
            target_per_branch
        };
        if current < target {
            text = top_up_whitespace(text, has_newline, has_tab, target - current, rng);
        }
    }

    text
}

#[cfg(test)]
impl App {
    pub fn new_test() -> Self {
        let config = Config::default();
        let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
        let menu = Menu::new(theme);
        let dictionary = Dictionary::load_for_language(&config.dictionary_language);
        let transition_table = TransitionTable::build_from_words(dictionary.words_list());
        let keyboard_model = KeyboardModel::from_key(&config.keyboard_layout)
            .expect("config validation must ensure a known keyboard layout");

        let mut app = Self {
            screen: AppScreen::Menu,
            drill_mode: DrillMode::Adaptive,
            drill_scope: DrillScope::Global,
            drill: None,
            drill_events: Vec::new(),
            last_result: None,
            drill_history: Vec::new(),
            menu,
            theme,
            config,
            key_stats: KeyStatsStore::default(),
            ranked_key_stats: KeyStatsStore::default(),
            skill_tree: SkillTree::default(),
            profile: ProfileData::default(),
            store: None,
            should_quit: false,
            settings_selected: 0,
            settings_editing_path: None,
            stats_tab: 0,
            depressed_keys: HashSet::new(),
            last_key_time: None,
            history_selected: 0,
            history_scroll: 0,
            history_confirm_delete: false,
            skill_tree_selected: 0,
            skill_tree_detail_scroll: 0,
            skill_tree_confirm_unlock: None,
            drill_source_info: None,
            ui_language_selected: 0,
            ui_language_scroll: 0,
            dictionary_language_selected: 0,
            dictionary_language_scroll: 0,
            keyboard_layout_selected: 0,
            keyboard_layout_scroll: 0,
            code_language_selected: 0,
            code_language_scroll: 0,
            passage_book_selected: 0,
            passage_intro_selected: 0,
            passage_intro_downloads_enabled: false,
            passage_intro_download_dir: String::new(),
            passage_intro_paragraph_limit: 0,
            passage_intro_downloading: false,
            passage_intro_download_total: 0,
            passage_intro_downloaded: 0,
            passage_intro_current_book: String::new(),
            passage_intro_download_bytes: 0,
            passage_intro_download_bytes_total: 0,
            passage_download_queue: Vec::new(),
            passage_drill_selection_override: None,
            last_passage_drill_selection: None,
            passage_download_action: PassageDownloadCompleteAction::StartPassageDrill,
            code_intro_selected: 0,
            code_intro_downloads_enabled: false,
            code_intro_download_dir: String::new(),
            code_intro_snippets_per_repo: 0,
            code_intro_downloading: false,
            code_intro_download_total: 0,
            code_intro_downloaded: 0,
            code_intro_current_repo: String::new(),
            code_intro_download_bytes: 0,
            code_intro_download_bytes_total: 0,
            code_download_queue: Vec::new(),
            code_drill_language_override: None,
            last_code_drill_language: None,
            code_download_attempted: false,
            code_download_action: CodeDownloadCompleteAction::StartCodeDrill,
            shift_held: false,
            caps_lock: false,
            keyboard_model,
            milestone_queue: VecDeque::new(),
            show_adaptive_intro: false,
            settings_confirm_import: false,
            settings_export_conflict: false,
            settings_status_message: None,
            settings_export_path: default_export_path(),
            settings_import_path: default_export_path(),
            keyboard_explorer_selected: None,
            explorer_accuracy_cache_overall: None,
            explorer_accuracy_cache_ranked: None,
            bigram_stats: BigramStatsStore::default(),
            ranked_bigram_stats: BigramStatsStore::default(),
            user_median_transition_ms: 0.0,
            transition_buffer: Vec::new(),
            current_focus: None,
            post_drill_input_lock_until: None,
            adaptive_word_history: VecDeque::new(),
            rng: SmallRng::from_entropy(),
            transition_table,
            dictionary,
            passage_download_job: None,
            code_download_job: None,
        };

        app.start_drill();
        app
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::engine::skill_tree::{ALL_BRANCHES, BranchId, get_branch_definition};
    use chrono::{Duration as ChronoDuration, Utc};

    fn make_ranked_result(
        key_times: Vec<KeyTime>,
        timestamp: chrono::DateTime<chrono::Utc>,
    ) -> DrillResult {
        let correct = key_times.iter().filter(|kt| kt.correct).count();
        let total_chars = key_times.len();
        let incorrect = total_chars.saturating_sub(correct);
        let elapsed_secs = key_times.iter().map(|kt| kt.time_ms).sum::<f64>() / 1000.0;
        let accuracy = if total_chars == 0 {
            100.0
        } else {
            (correct as f64 / total_chars as f64) * 100.0
        };
        let cpm = if elapsed_secs > 0.0 {
            correct as f64 / elapsed_secs * 60.0
        } else {
            0.0
        };
        DrillResult {
            wpm: cpm / 5.0,
            cpm,
            accuracy,
            correct,
            incorrect,
            total_chars,
            elapsed_secs,
            timestamp,
            per_key_times: key_times,
            drill_mode: "adaptive".to_string(),
            ranked: true,
            partial: false,
            completion_percent: 100.0,
        }
    }

    fn apply_ranked_result_live(app: &mut App, result: DrillResult) {
        let before_stats = app.ranked_key_stats.clone();
        for kt in &result.per_key_times {
            if kt.correct {
                app.key_stats.update_key(kt.key, kt.time_ms);
                app.ranked_key_stats.update_key_ranked(kt.key, kt.time_ms);
            } else {
                app.key_stats.update_key_error(kt.key);
                app.ranked_key_stats.update_key_error_ranked(kt.key);
            }
        }
        app.skill_tree
            .update(&app.ranked_key_stats, Some(&before_stats));
        app.drill_history.push(result);
    }

    #[test]
    fn adaptive_word_history_clears_on_code_mode_switch() {
        let mut app = App::new_test();

        // App starts in Adaptive/Global; new() calls start_drill() which populates history
        assert_eq!(app.drill_mode, DrillMode::Adaptive);
        assert!(
            !app.adaptive_word_history.is_empty(),
            "History should be populated after initial adaptive drill"
        );

        // Use the real start_code_drill path. Pre-set language override to skip
        // download logic and ensure it reaches the drill-start code path.
        app.code_drill_language_override = Some("rust".to_string());
        app.start_code_drill();
        assert_eq!(app.drill_mode, DrillMode::Code);
        assert!(
            app.adaptive_word_history.is_empty(),
            "History should clear when switching to Code mode via start_code_drill"
        );
    }

    #[test]
    fn adaptive_word_history_clears_on_passage_mode_switch() {
        let mut app = App::new_test();

        assert_eq!(app.drill_mode, DrillMode::Adaptive);
        assert!(!app.adaptive_word_history.is_empty());

        // Use the real start_passage_drill path. Pre-set selection override to
        // skip download logic and use built-in passages.
        app.config.passage_downloads_enabled = false;
        app.passage_drill_selection_override = Some("builtin".to_string());
        app.start_passage_drill();
        assert_eq!(app.drill_mode, DrillMode::Passage);
        assert!(
            app.adaptive_word_history.is_empty(),
            "History should clear when switching to Passage mode via start_passage_drill"
        );
    }

    #[test]
    fn adaptive_word_history_clears_on_scope_change() {
        let mut app = App::new_test();

        // Start in Adaptive/Global — drill already started in new()
        assert_eq!(app.drill_scope, DrillScope::Global);
        assert!(!app.adaptive_word_history.is_empty());

        // Use start_branch_drill to switch from Global to Branch scope.
        // This is the real production path for scope changes.
        app.start_branch_drill(BranchId::Lowercase);
        assert_eq!(app.drill_scope, DrillScope::Branch(BranchId::Lowercase));
        assert_eq!(app.drill_mode, DrillMode::Adaptive);
        // History was cleared by the Global->Branch scope change, then repopulated
        // by the single start_drill call inside start_branch_drill.
        assert_eq!(
            app.adaptive_word_history.len(),
            1,
            "History should have exactly 1 entry after Global->Branch clear + new drill"
        );

        // Record history state, then switch to a different branch
        let history_before = app.adaptive_word_history.clone();
        app.start_branch_drill(BranchId::Capitals);
        assert_eq!(app.drill_scope, DrillScope::Branch(BranchId::Capitals));
        // History was cleared by scope change and repopulated with new drill words.
        // New history should not contain the old drill's words.
        let old_words: HashSet<String> = history_before.into_iter().flatten().collect();
        let new_words: HashSet<String> = app
            .adaptive_word_history
            .iter()
            .flatten()
            .cloned()
            .collect();
        // After clearing, the new history has exactly 1 drill entry (the one just generated).
        assert_eq!(
            app.adaptive_word_history.len(),
            1,
            "History should have exactly 1 entry after scope-clearing branch switch"
        );
        // The new words should mostly differ from old (not a superset or continuation)
        assert!(
            !new_words.is_subset(&old_words) || new_words.is_empty(),
            "New history should not be a subset of old history"
        );
    }

    #[test]
    fn adaptive_word_history_persists_within_same_context() {
        let mut app = App::new_test();

        // Adaptive/Global: run multiple drills, history should accumulate
        let history_after_first = app.adaptive_word_history.len();
        app.start_drill();
        let history_after_second = app.adaptive_word_history.len();

        assert!(
            history_after_second > history_after_first,
            "History should accumulate across drills: {} -> {}",
            history_after_first,
            history_after_second
        );
        assert!(
            app.adaptive_word_history.len() <= 5,
            "History should be capped at 5 drills"
        );
    }

    #[test]
    fn adaptive_word_history_not_cleared_on_same_branch_redrill() {
        let mut app = App::new_test();

        // Start a branch drill
        app.start_branch_drill(BranchId::Lowercase);
        let history_after_first = app.adaptive_word_history.len();
        assert_eq!(history_after_first, 1);

        // Re-drill the same branch via start_branch_drill — scope doesn't change,
        // so history should NOT clear; it should accumulate.
        app.start_branch_drill(BranchId::Lowercase);
        assert!(
            app.adaptive_word_history.len() > history_after_first,
            "History should accumulate when re-drilling same branch: {} -> {}",
            history_after_first,
            app.adaptive_word_history.len()
        );
    }

    #[test]
    fn rebuild_language_assets_clears_adaptive_history_without_mutating_drill() {
        let mut app = App::new_test();
        let drill_before = app
            .drill
            .as_ref()
            .map(|d| d.target.clone())
            .expect("drill should be present");
        assert!(
            !app.adaptive_word_history.is_empty(),
            "History should be populated after initial adaptive drill"
        );

        app.rebuild_language_assets();

        let drill_after = app
            .drill
            .as_ref()
            .map(|d| d.target.clone())
            .expect("drill should still be present");
        assert_eq!(drill_before, drill_after);
        assert!(app.adaptive_word_history.is_empty());
    }

    #[test]
    fn dictionary_language_cycle_changes_language_without_preview_status() {
        let mut app = App::new_test();
        app.config.keyboard_layout = "qwerty".to_string();
        app.config.dictionary_language = "en".to_string();
        app.settings_selected = SettingItem::DictionaryLanguage.index();
        app.settings_status_message = None;

        app.settings_cycle_forward();

        assert_eq!(app.config.dictionary_language, "de");
        assert_eq!(app.config.keyboard_layout, "de_qwertz");
        assert!(
            app.settings_status_message.is_none(),
            "no preview status should be shown for supported pairs"
        );
    }

    #[test]
    fn dictionary_language_cycle_swaps_skill_tree_scope() {
        let mut app = App::new_test();
        app.config.keyboard_layout = "qwerty".to_string();
        app.config.dictionary_language = "en".to_string();
        app.settings_selected = SettingItem::DictionaryLanguage.index();

        let mut en_progress = app.profile.skill_tree_for_language("en");
        en_progress
            .branches
            .get_mut("lowercase")
            .expect("lowercase branch should exist")
            .current_level = 2;
        app.profile
            .set_skill_tree_for_language("en", en_progress.clone());

        let mut de_progress = app.profile.skill_tree_for_language("de");
        de_progress
            .branches
            .get_mut("lowercase")
            .expect("lowercase branch should exist")
            .current_level = 5;
        app.profile
            .set_skill_tree_for_language("de", de_progress.clone());

        app.skill_tree = App::build_skill_tree_for_language(en_progress, "en", "qwerty");
        app.skill_tree
            .progress
            .branches
            .get_mut("lowercase")
            .expect("lowercase branch should exist")
            .current_level = 7;

        app.settings_cycle_forward();

        assert_eq!(app.config.dictionary_language, "de");
        let active_level = app
            .skill_tree
            .progress
            .branches
            .get("lowercase")
            .expect("lowercase branch should exist")
            .current_level;
        assert_eq!(active_level, 5);

        let persisted_en_level = app
            .profile
            .skill_tree_for_language("en")
            .branches
            .get("lowercase")
            .expect("lowercase branch should exist")
            .current_level;
        assert_eq!(persisted_en_level, 7);
    }

    #[test]
    fn dictionary_language_cycle_recovers_from_unknown_layout_by_resetting_default() {
        let mut app = App::new_test();
        app.config.keyboard_layout = "unknown_layout".to_string();
        app.config.dictionary_language = "en".to_string();
        app.settings_selected = SettingItem::DictionaryLanguage.index();
        app.settings_status_message = None;

        app.settings_cycle_forward();

        assert_eq!(app.config.dictionary_language, "de");
        assert_eq!(app.config.keyboard_layout, "de_qwertz");
        assert!(app.settings_status_message.is_none());
    }

    #[test]
    fn set_keyboard_layout_routes_through_validation_and_keeps_language() {
        let mut app = App::new_test();
        app.config.dictionary_language = "en".to_string();

        let capability = app
            .set_keyboard_layout("fr_azerty")
            .expect("fr_azerty should be supported");

        assert_eq!(capability, CapabilityState::Enabled);
        assert_eq!(app.config.keyboard_layout, "fr_azerty");
        assert_eq!(app.config.dictionary_language, "en");
        assert_eq!(app.keyboard_model.shifted_to_base('2'), Some('é'));
        assert_eq!(app.keyboard_model.shifted_to_base('0'), Some('à'));
    }

    #[test]
    fn german_primary_sequence_includes_umlauts_on_de_layout() {
        let tree =
            App::build_skill_tree_for_language(SkillTreeProgress::default(), "de", "de_qwertz");
        let letters = tree.primary_letters();
        assert!(letters.contains(&'ä'));
        assert!(letters.contains(&'ö'));
        assert!(letters.contains(&'ü'));
        assert!(letters.contains(&'ß'));
    }

    #[test]
    fn german_primary_sequence_filters_umlauts_on_us_qwerty() {
        let tree = App::build_skill_tree_for_language(SkillTreeProgress::default(), "de", "qwerty");
        let letters = tree.primary_letters();
        assert!(!letters.contains(&'ä'));
        assert!(!letters.contains(&'ö'));
        assert!(!letters.contains(&'ü'));
        assert!(!letters.contains(&'ß'));
    }

    #[test]
    fn primary_sequence_keys_are_typeable_for_all_supported_language_layout_pairs() {
        for pack in crate::l10n::language_pack::language_packs() {
            for &layout_key in pack.supported_keyboard_layout_keys {
                let tree = App::build_skill_tree_for_language(
                    SkillTreeProgress::default(),
                    pack.language_key,
                    layout_key,
                );
                let model = KeyboardModel::from_key(layout_key)
                    .expect("supported layout key should have a keyboard model");
                assert!(
                    !tree.primary_letters().is_empty(),
                    "primary letters should not be empty for {} + {}",
                    pack.language_key,
                    layout_key
                );
                for &ch in tree.primary_letters() {
                    assert!(
                        model.physical_key_for(ch).is_some(),
                        "primary letter '{}' should be typeable for {} + {}",
                        ch,
                        pack.language_key,
                        layout_key
                    );
                }
            }
        }
    }

    #[test]
    fn start_global_adaptive_drill_allows_cross_layout_language_pair() {
        let mut app = App::new_test();
        app.config.dictionary_language = "en".to_string();
        app.config.keyboard_layout = "de_qwertz".to_string();
        app.settings_status_message = None;
        app.screen = AppScreen::Menu;

        let started = app.start_global_adaptive_drill();

        assert!(started, "cross-language/layout pairs should be allowed");
        assert!(app.settings_status_message.is_none());
        assert_eq!(app.screen, AppScreen::Drill);
    }

    #[test]
    #[should_panic(expected = "settings index out of range")]
    fn setting_item_from_index_out_of_range_panics_in_debug_builds() {
        let _ = SettingItem::from_index(SettingItem::ALL.len() + 10);
    }

    #[test]
    fn auto_indent_disabled_for_adaptive_enabled_for_code_and_passage() {
        let mut app = App::new_test();
        assert_eq!(app.drill_mode, DrillMode::Adaptive);
        assert!(
            app.drill
                .as_ref()
                .is_some_and(|d| !d.auto_indent_after_newline),
            "adaptive drills should not auto-indent"
        );

        app.code_drill_language_override = Some("rust".to_string());
        app.start_code_drill();
        assert_eq!(app.drill_mode, DrillMode::Code);
        assert!(
            app.drill
                .as_ref()
                .is_some_and(|d| d.auto_indent_after_newline),
            "code drills should auto-indent"
        );

        app.config.passage_downloads_enabled = false;
        app.passage_drill_selection_override = Some("builtin".to_string());
        app.start_passage_drill();
        assert_eq!(app.drill_mode, DrillMode::Passage);
        assert!(
            app.drill
                .as_ref()
                .is_some_and(|d| d.auto_indent_after_newline),
            "passage drills should auto-indent"
        );
    }

    /// Helper: make the current drill look "completed" so finish_drill() processes it.
    fn complete_current_drill(app: &mut App) {
        if let Some(ref mut drill) = app.drill {
            let now = Instant::now();
            drill.started_at = Some(now - Duration::from_millis(500));
            drill.finished_at = Some(now);
            drill.cursor = drill.target.len();
            // Fill input so DrillResult::from_drill doesn't panic on length mismatches
            drill.input = vec![crate::session::input::CharStatus::Correct; drill.target.len()];
        }
    }

    #[test]
    fn adaptive_auto_continue_arms_input_lock() {
        let mut app = App::new_test();
        assert_eq!(app.drill_mode, DrillMode::Adaptive);
        assert_eq!(app.screen, AppScreen::Drill);
        assert!(app.drill.is_some());

        // Make sure no milestones are queued
        app.milestone_queue.clear();

        complete_current_drill(&mut app);
        app.finish_drill();

        // Auto-continue should have started a new drill and armed the lock
        assert_eq!(app.screen, AppScreen::Drill);
        assert!(
            app.post_drill_input_lock_remaining_ms().is_some(),
            "Input lock should be armed after adaptive auto-continue"
        );
    }

    #[test]
    fn adaptive_does_not_auto_continue_with_milestones() {
        let mut app = App::new_test();
        assert_eq!(app.drill_mode, DrillMode::Adaptive);

        // Push a milestone before finishing the drill
        app.milestone_queue.push_back(KeyMilestonePopup {
            kind: MilestoneKind::Unlock,
            keys: vec!['a'],
            finger_info: vec![('a', "left pinky".to_string())],
            message: "Test milestone".to_string(),
            branch_ids: vec![],
        });

        complete_current_drill(&mut app);
        app.finish_drill();

        // Should go to DrillResult (not auto-continue) since milestones are queued
        assert_eq!(app.screen, AppScreen::DrillResult);
        // Lock IS armed via the existing milestone path
        assert!(
            app.post_drill_input_lock_remaining_ms().is_some(),
            "Input lock should be armed for milestone path"
        );
    }

    #[test]
    fn rebuild_from_history_matches_live_ranked_mastery_state_with_errors() {
        let mut live = App::new_test();
        live.drill_history.clear();

        let t0 = Utc::now();
        let initial_mastery: Vec<KeyTime> = ['e', 't', 'a', 'o', 'i', 'n']
            .into_iter()
            .flat_map(|key| {
                let mut times = vec![
                    KeyTime {
                        key,
                        time_ms: 200.0,
                        correct: true,
                    };
                    24
                ];
                times.push(KeyTime {
                    key,
                    time_ms: 550.0,
                    correct: false,
                });
                times
            })
            .collect();
        let follow_up = vec![
            KeyTime {
                key: 's',
                time_ms: 520.0,
                correct: false,
            },
            KeyTime {
                key: 's',
                time_ms: 200.0,
                correct: true,
            },
            KeyTime {
                key: 's',
                time_ms: 205.0,
                correct: true,
            },
            KeyTime {
                key: 'e',
                time_ms: 210.0,
                correct: true,
            },
        ];
        let results = vec![
            make_ranked_result(initial_mastery, t0),
            make_ranked_result(follow_up, t0 + ChronoDuration::minutes(5)),
        ];

        for result in results.iter().cloned() {
            apply_ranked_result_live(&mut live, result);
        }

        let mut rebuilt = App::new_test();
        rebuilt.drill_history = results;
        rebuilt.rebuild_from_history();

        assert_eq!(
            serde_json::to_value(&live.key_stats).unwrap(),
            serde_json::to_value(&rebuilt.key_stats).unwrap()
        );
        assert_eq!(
            serde_json::to_value(&live.ranked_key_stats).unwrap(),
            serde_json::to_value(&rebuilt.ranked_key_stats).unwrap()
        );
        assert_eq!(
            serde_json::to_value(&live.skill_tree.progress).unwrap(),
            serde_json::to_value(&rebuilt.skill_tree.progress).unwrap()
        );
        assert!(
            rebuilt.ranked_key_stats.is_mastered('e'),
            "rebuild should preserve sticky mastery for ranked keys"
        );
        assert!(
            !rebuilt.ranked_key_stats.is_mastered('s'),
            "rebuild should preserve non-mastered keys with recent errors"
        );
        assert_eq!(
            rebuilt
                .skill_tree
                .branch_progress(BranchId::Lowercase)
                .current_level,
            1,
            "rebuild should preserve lowercase unlock progress after mastering the initial set"
        );
    }

    #[test]
    fn rebuild_from_history_preserves_previous_branch_unlocks() {
        let mut app = App::new_test();

        // Simulate previously unlocked branch progress with sparse history replay input.
        if let Some(bp) = app
            .profile
            .skill_tree
            .branches
            .get_mut(BranchId::Capitals.to_key())
        {
            bp.status = BranchStatus::InProgress;
            bp.current_level = 1;
        }
        app.skill_tree = App::build_skill_tree_for_language(
            app.profile
                .skill_tree_for_language(&app.config.dictionary_language),
            &app.config.dictionary_language,
            &app.config.keyboard_layout,
        );

        // No additional ranked drills to advance tree during replay.
        app.drill_history.clear();
        app.rebuild_from_history();

        let capitals = app.skill_tree.branch_progress(BranchId::Capitals);
        assert_eq!(capitals.status, BranchStatus::InProgress);
        assert!(capitals.current_level >= 1);
    }

    #[test]
    fn uppercase_focus_maps_to_lowercase_for_base_generation() {
        assert_eq!(lowercase_generation_focus(Some('w')), Some('w'));
        assert_eq!(lowercase_generation_focus(Some('W')), Some('w'));
        assert_eq!(lowercase_generation_focus(Some('7')), None);
        assert_eq!(lowercase_generation_focus(None), None);
    }

    #[test]
    fn branch_injection_rebalance_hits_shared_minimums() {
        let mut rng = SmallRng::seed_from_u64(42);
        let base = "alpha beta gamma delta epsilon zeta eta theta iota kappa".to_string();
        let out = rebalance_branch_injections(
            base,
            &['A'],
            &['.'],
            &['1'],
            &['='],
            true,
            true,
            None,
            3,
            &mut rng,
        );

        let cap_count = out.chars().filter(|ch| ch.is_uppercase()).count();
        let punct_count = count_matching_chars(&out, &['.']);
        let digit_count = out.chars().filter(|ch| ch.is_ascii_digit()).count();
        let symbol_count = count_matching_chars(&out, &['=']);
        let whitespace_count = out.chars().filter(|ch| *ch == '\n' || *ch == '\t').count();

        assert!(cap_count >= 3, "capitals too low in: {out}");
        assert!(punct_count >= 3, "punctuation too low in: {out}");
        assert!(digit_count >= 3, "digits too low in: {out}");
        assert!(symbol_count >= 3, "symbols too low in: {out}");
        assert!(whitespace_count >= 3, "whitespace too low in: {out}");
    }

    #[test]
    fn branch_injection_rebalance_boosts_focused_branch() {
        let mut rng = SmallRng::seed_from_u64(7);
        let base = "alpha beta gamma delta epsilon zeta eta theta iota kappa".to_string();
        let out = rebalance_branch_injections(
            base,
            &['A'],
            &['.'],
            &['1'],
            &['='],
            true,
            false,
            Some('1'),
            3,
            &mut rng,
        );

        let digit_count = out.chars().filter(|ch| ch.is_ascii_digit()).count();
        assert!(
            digit_count >= 5,
            "focused digit branch should get bonus density, got {digit_count} in: {out}"
        );
    }

    #[test]
    fn branch_injection_rebalance_includes_tab_when_unlocked() {
        let mut rng = SmallRng::seed_from_u64(17);
        let base = "alpha beta gamma delta epsilon zeta eta theta iota kappa".to_string();
        let out =
            rebalance_branch_injections(base, &[], &[], &[], &[], true, true, None, 3, &mut rng);
        let tab_count = out.chars().filter(|ch| *ch == '\t').count();
        assert!(
            tab_count >= 1,
            "expected at least one tab when tab is unlocked, got: {out:?}"
        );
    }

    #[test]
    fn whitespace_top_up_prefers_tabs_after_newlines() {
        let mut rng = SmallRng::seed_from_u64(1234);
        let base = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu".to_string();
        let out = top_up_whitespace(base, true, true, 10, &mut rng);
        let chars: Vec<char> = out.chars().collect();

        let mut tabs = 0usize;
        let mut tabs_after_newline = 0usize;
        for i in 0..chars.len() {
            if chars[i] == '\t' {
                tabs += 1;
                if i > 0 && chars[i - 1] == '\n' {
                    tabs_after_newline += 1;
                }
            }
        }

        assert!(tabs > 0, "expected at least one tab in: {out:?}");
        assert!(
            tabs_after_newline * 2 >= tabs,
            "expected most tabs to be indentation-style after newline; got {tabs_after_newline}/{tabs} in: {out:?}"
        );
    }

    /// Helper: make a key just below sticky mastery in ranked stats.
    /// sample_count (30) and error_rate_ema (0.0) already qualify, but confidence
    /// stays below `MASTERY_MIN_SPEED_CONFIDENCE` (1.05). One additional fast (200ms)
    /// drill hit drops filtered_time enough to push confidence past the threshold,
    /// flipping the sticky bit. target_time ≈ 342.86ms (60000/175 CPM).
    fn make_key_near_mastery(app: &mut App, ch: char) {
        for _ in 0..30 {
            app.ranked_key_stats.update_key_ranked(ch, 330.0);
        }
    }

    /// Helper: make a key sticky-mastered in ranked stats.
    fn make_key_mastered(app: &mut App, ch: char) {
        for _ in 0..50 {
            app.ranked_key_stats.update_key_ranked(ch, 200.0);
        }
    }

    /// Helper: seed a tree where primary-letter progression is nearly complete.
    /// All but the final primary letter are mastered.
    fn seed_near_complete_lowercase(app: &mut App) {
        let all_primary = app.skill_tree.primary_letters().to_vec();
        let almost_all = &all_primary[..all_primary.len().saturating_sub(1)];

        for &ch in almost_all {
            make_key_mastered(app, ch);
        }
        if let Some(&last) = all_primary.last() {
            // Make final primary letter near-mastery so one drill hit completes it.
            make_key_near_mastery(app, last);
        }

        // Advance the skill tree through progressive unlock
        for _ in 0..30 {
            app.skill_tree.update(&app.ranked_key_stats, None);
        }
    }

    /// Helper: set up a drill with a simple target and create events for it.
    /// Each char in `target` gets a correct keystroke event with fast timing.
    fn setup_drill_with_events(app: &mut App, target: &str) {
        use crate::session::input::KeystrokeEvent;

        app.drill = Some(crate::session::drill::DrillState::new(target));
        let now = Instant::now();

        // Create keystroke events — need at least 2 for windows(2) to produce per_key_times
        let mut events = Vec::new();
        for (i, ch) in target.chars().enumerate() {
            events.push(KeystrokeEvent {
                expected: ch,
                actual: ch,
                timestamp: now + Duration::from_millis(200 * (i as u64)),
                correct: true,
            });
        }
        app.drill_events = events;

        // Mark drill as complete
        if let Some(ref mut drill) = app.drill {
            drill.started_at = Some(now);
            drill.finished_at = Some(now + Duration::from_millis(200 * (target.len() as u64)));
            drill.cursor = drill.target.len();
            drill.input = vec![crate::session::input::CharStatus::Correct; drill.target.len()];
        }
    }

    #[test]
    fn finish_drill_lowercase_complete_queue_order() {
        let mut app = App::new_test();
        seed_near_complete_lowercase(&mut app);

        // Set up a drill that types 'z' (the last missing key) to push it to mastery
        setup_drill_with_events(&mut app, "zz");
        app.milestone_queue.clear();
        app.finish_drill();

        let kinds: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();

        // Should contain Mastery and BranchesAvailable, but NOT BranchComplete for lowercase
        assert!(
            kinds.contains(&&MilestoneKind::Mastery),
            "Should have Mastery popup, got: {kinds:?}"
        );
        assert!(
            kinds.contains(&&MilestoneKind::BranchesAvailable),
            "Should have BranchesAvailable popup, got: {kinds:?}"
        );
        assert!(
            !kinds.contains(&&MilestoneKind::BranchComplete),
            "Should NOT have BranchComplete for lowercase, got: {kinds:?}"
        );

        // Verify full ordering: any Unlock before Mastery before BranchesAvailable
        if let Some(unlock_pos) = kinds.iter().position(|k| **k == MilestoneKind::Unlock) {
            let mastery_pos = kinds
                .iter()
                .position(|k| **k == MilestoneKind::Mastery)
                .unwrap();
            assert!(
                unlock_pos < mastery_pos,
                "Unlock should come before Mastery"
            );
        }
        let mastery_pos = kinds
            .iter()
            .position(|k| **k == MilestoneKind::Mastery)
            .unwrap();
        let available_pos = kinds
            .iter()
            .position(|k| **k == MilestoneKind::BranchesAvailable)
            .unwrap();
        assert!(
            mastery_pos < available_pos,
            "Mastery should come before BranchesAvailable"
        );

        // Verify branch_ids in BranchesAvailable popup are in canonical order
        let branches_popup = app
            .milestone_queue
            .iter()
            .find(|m| m.kind == MilestoneKind::BranchesAvailable)
            .unwrap();
        assert_eq!(
            branches_popup.branch_ids,
            vec![
                BranchId::Capitals,
                BranchId::Numbers,
                BranchId::ProsePunctuation,
                BranchId::Whitespace,
                BranchId::CodeSymbols,
            ],
            "BranchesAvailable branch_ids must be in canonical order"
        );
    }

    #[test]
    fn finish_drill_branch_complete_queues_popup() {
        let mut app = App::new_test();

        // Set capitals to InProgress at last level
        app.skill_tree
            .branch_progress_mut(BranchId::Capitals)
            .status = BranchStatus::InProgress;
        app.skill_tree
            .branch_progress_mut(BranchId::Capitals)
            .current_level = 2; // Last level (3 levels, 0-indexed)

        // Make all capitals except 'Z' mastered, with 'Z' just below mastery.
        for ch in 'A'..='Y' {
            make_key_mastered(&mut app, ch);
        }
        make_key_near_mastery(&mut app, 'Z');

        // Advance tree to reflect current confidence state
        app.skill_tree.update(&app.ranked_key_stats, None);

        // Set up a drill that types 'Z' to push it to mastery
        setup_drill_with_events(&mut app, "ZZ");
        app.milestone_queue.clear();
        app.finish_drill();

        let kinds: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();

        assert!(
            kinds.contains(&&MilestoneKind::BranchComplete),
            "Should have BranchComplete popup, got: {kinds:?}"
        );

        // The BranchComplete popup should reference Capitals
        let branch_complete = app
            .milestone_queue
            .iter()
            .find(|m| m.kind == MilestoneKind::BranchComplete)
            .unwrap();
        assert!(
            branch_complete.branch_ids.contains(&BranchId::Capitals),
            "BranchComplete should reference Capitals"
        );
    }

    #[test]
    fn finish_drill_all_keys_unlocked_queues_once() {
        let mut app = App::new_test();

        // Complete primary-letter branch
        let all_primary = app.skill_tree.primary_letters().to_vec();
        for &ch in &all_primary {
            make_key_mastered(&mut app, ch);
        }
        for _ in 0..30 {
            app.skill_tree.update(&app.ranked_key_stats, None);
        }

        // Start all non-lowercase branches. Master all keys through their levels,
        // but leave CodeSymbols level 2's last key ('~') near-mastery so level 3
        // keys are not yet unlocked.
        for &id in &[
            BranchId::Capitals,
            BranchId::Numbers,
            BranchId::ProsePunctuation,
            BranchId::Whitespace,
        ] {
            app.skill_tree.start_branch(id);
            let def = get_branch_definition(id);
            for level in def.levels {
                for &ch in level.keys {
                    make_key_mastered(&mut app, ch);
                }
            }
        }

        // CodeSymbols: master levels 0-1 fully, and level 2 except '~'
        app.skill_tree.start_branch(BranchId::CodeSymbols);
        let code_def = get_branch_definition(BranchId::CodeSymbols);
        for &ch in code_def.levels[0].keys {
            make_key_mastered(&mut app, ch);
        }
        for &ch in code_def.levels[1].keys {
            make_key_mastered(&mut app, ch);
        }
        for &ch in code_def.levels[2].keys {
            if ch == '~' {
                make_key_near_mastery(&mut app, ch);
            } else {
                make_key_mastered(&mut app, ch);
            }
        }
        // Level 3 keys (@#$%_\`) are not mastered and not yet unlocked

        // Advance all branches through their levels
        for _ in 0..20 {
            app.skill_tree.update(&app.ranked_key_stats, None);
        }

        // Verify CodeSymbols is at level 2 (level 3 keys not yet unlocked)
        assert_eq!(
            app.skill_tree
                .branch_progress(BranchId::CodeSymbols)
                .current_level,
            2,
            "CodeSymbols should be at level 2"
        );
        assert!(
            app.skill_tree.total_unlocked_count() < app.skill_tree.total_unique_keys,
            "Not all keys should be unlocked yet"
        );

        // Drill '~' to push it to mastery → advances CodeSymbols to level 3 → all keys unlocked
        setup_drill_with_events(&mut app, "~~");
        app.milestone_queue.clear();
        app.finish_drill();

        let kinds: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();

        // AllKeysUnlocked must be present
        assert!(
            kinds.contains(&&MilestoneKind::AllKeysUnlocked),
            "Should have AllKeysUnlocked popup, got: {kinds:?}"
        );

        // If AllKeysMastered also fires, AllKeysUnlocked must come first
        if let (Some(unlocked_pos), Some(mastered_pos)) = (
            kinds
                .iter()
                .position(|k| **k == MilestoneKind::AllKeysUnlocked),
            kinds
                .iter()
                .position(|k| **k == MilestoneKind::AllKeysMastered),
        ) {
            assert!(
                unlocked_pos < mastered_pos,
                "AllKeysUnlocked should come before AllKeysMastered"
            );
        }

        // Second drill should NOT re-queue
        setup_drill_with_events(&mut app, "~~");
        app.milestone_queue.clear();
        app.finish_drill();

        let kinds2: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();
        assert!(
            !kinds2.contains(&&MilestoneKind::AllKeysUnlocked),
            "AllKeysUnlocked should not fire on subsequent drill"
        );
    }

    #[test]
    fn finish_drill_all_keys_mastered_queues_popup() {
        let mut app = App::new_test();

        // Make every key in every branch mastered except '`'
        for branch_def in crate::engine::skill_tree::ALL_BRANCHES {
            for level in branch_def.levels {
                for &ch in level.keys {
                    if ch == '`' {
                        make_key_near_mastery(&mut app, ch);
                    } else {
                        make_key_mastered(&mut app, ch);
                    }
                }
            }
        }

        // Advance lowercase to Complete
        for _ in 0..30 {
            app.skill_tree.update(&app.ranked_key_stats, None);
        }

        // Start all non-lowercase branches
        for &id in &[
            BranchId::Capitals,
            BranchId::Numbers,
            BranchId::ProsePunctuation,
            BranchId::Whitespace,
            BranchId::CodeSymbols,
        ] {
            app.skill_tree.start_branch(id);
        }

        // Advance all branches through their levels
        for _ in 0..30 {
            app.skill_tree.update(&app.ranked_key_stats, None);
        }

        // Now '`' is the only key not fully mastered. Drill it.
        setup_drill_with_events(&mut app, "``");
        app.milestone_queue.clear();
        app.finish_drill();

        let kinds: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();

        assert!(
            kinds.contains(&&MilestoneKind::AllKeysMastered),
            "Should have AllKeysMastered popup, got: {kinds:?}"
        );
    }

    #[test]
    fn rebuild_from_history_does_not_requeue_mastery_milestones() {
        let mut app = App::new_test();
        seed_near_complete_lowercase(&mut app);

        setup_drill_with_events(&mut app, "zz");
        app.milestone_queue.clear();
        app.finish_drill();

        let mastery_count = app
            .milestone_queue
            .iter()
            .filter(|m| m.kind == MilestoneKind::Mastery)
            .count();
        assert!(
            mastery_count > 0,
            "live drill should queue a mastery milestone"
        );

        app.milestone_queue.clear();
        app.rebuild_from_history();

        assert!(
            app.milestone_queue.is_empty(),
            "rebuild should not requeue mastery milestones for already-mastered keys"
        );
        assert_eq!(
            *app.skill_tree.branch_status(BranchId::Lowercase),
            BranchStatus::Complete,
            "rebuild should preserve the previously reached lowercase progression state"
        );
    }

    /// The retention cap (and delete_session) can drop the very drills that
    /// earned mastery. Because mastery is one-way, rebuilding from what is left
    /// must not demote those keys — otherwise a branch stays Complete while its
    /// keys are unmastered, and a demoted key can re-enter focus selection and
    /// emit a second mastery milestone.
    #[test]
    fn rebuild_after_history_truncation_preserves_sticky_mastery() {
        let mut app = App::new_test();
        app.drill_history.clear();

        let t0 = Utc::now();
        let earning: Vec<KeyTime> = ['e', 't', 'a', 'o', 'i', 'n']
            .into_iter()
            .flat_map(|key| {
                vec![
                    KeyTime {
                        key,
                        time_ms: 200.0,
                        correct: true,
                    };
                    30
                ]
            })
            .collect();
        apply_ranked_result_live(&mut app, make_ranked_result(earning, t0));

        // A later, much lighter drill that on its own could never qualify a key.
        let trailing = vec![
            KeyTime {
                key: 'e',
                time_ms: 210.0,
                correct: true,
            },
            KeyTime {
                key: 't',
                time_ms: 215.0,
                correct: true,
            },
        ];
        apply_ranked_result_live(
            &mut app,
            make_ranked_result(trailing, t0 + ChronoDuration::minutes(5)),
        );

        let mut mastered_before = app.ranked_key_stats.mastered_keys();
        mastered_before.sort_unstable();
        assert!(
            !mastered_before.is_empty(),
            "precondition: live drills earned sticky mastery"
        );
        let progress_before = serde_json::to_value(&app.skill_tree.progress).unwrap();

        // Drop the drill that earned mastery, exactly as the retention cap would.
        app.drill_history.remove(0);
        app.milestone_queue.clear();
        app.rebuild_from_history();

        let mut mastered_after = app.ranked_key_stats.mastered_keys();
        mastered_after.sort_unstable();
        assert_eq!(
            mastered_after, mastered_before,
            "rebuild from a truncated history must not demote mastered keys"
        );
        assert_eq!(
            serde_json::to_value(&app.skill_tree.progress).unwrap(),
            progress_before,
            "progression must not regress when the earning drills are gone"
        );

        // Control: replaying only the retained history really cannot re-earn
        // mastery, so the assertions above are exercising the preservation path
        // rather than an incidental re-promotion.
        let mut control = App::new_test();
        control.drill_history = app.drill_history.clone();
        control.rebuild_from_history();
        assert!(
            control.ranked_key_stats.mastered_keys().is_empty(),
            "control: retained history alone must not qualify any key"
        );

        // Completed branches must still imply sticky mastery for every key.
        for branch_def in ALL_BRANCHES {
            if *app.skill_tree.branch_status(branch_def.id) != BranchStatus::Complete {
                continue;
            }
            for level in branch_def.levels {
                for &key in level.keys {
                    assert!(
                        app.ranked_key_stats.is_mastered(key),
                        "completed branch {:?} has unmastered key {key:?} after rebuild",
                        branch_def.id
                    );
                }
            }
        }

        // A preserved-mastery key must not become the focus target again.
        if let Some(focus) = app
            .skill_tree
            .focused_key(DrillScope::Global, &app.ranked_key_stats)
        {
            assert!(
                !app.ranked_key_stats.is_mastered(focus),
                "focus selection returned already-mastered key {focus:?} after rebuild"
            );
        }

        assert!(
            app.milestone_queue.is_empty(),
            "rebuild must not requeue mastery milestones for preserved keys"
        );
    }
}
