//! Application state and TUI event loop.
//!
//! The `App` struct owns long-lived state (config, HTTP client, cache,
//! downloads, and the currently active `AppScreen`). It drives a simple
//! state machine:
//! - render the current screen,
//! - wait for input,
//! - dispatch the key to a small handler per screen.
//!
//! This is intentionally separated from the drawing code in `screens/`
//! so that alternative frontends can reuse the same \"backend\" services.

use anyhow::Result;
use crossterm::event::{
    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
    KeyModifiers,
};
use crossterm::execute;
use crossterm::terminal::{
    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::style::Color;
use ratatui::Terminal;
use std::collections::{HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use crate::client::RommClient;
use crate::commands::library_scan::ScanCacheInvalidate;
use crate::config::{
    auth_for_persist_merge, normalize_romm_origin, resolved_save_dir, Config, ExtrasDefaults,
    SaveSyncConfig,
};
use crate::core::cache::{RomCache, RomCacheKey};
use crate::core::download::DownloadManager;
use crate::core::extras::has_update_or_dlc_extras;
use crate::core::startup_library_snapshot;
use crate::endpoints::device::{DeviceSchema, ListDevices};
use crate::endpoints::roms::GetRoms;
use crate::endpoints::sync::{SyncSessionSchema, TriggerPushPull};
use crate::types::{Collection, Platform, RomList, SaveMetadata};
use crate::update::UpdateStatus;

use super::keyboard_help;
use super::screens::connected_splash::{self, StartupSplash};
use super::screens::settings::SettingsRow;
use super::screens::setup_wizard::SetupWizard;
use super::screens::{
    BrowseScreen, DownloadScreen, ExecuteScreen, ExtrasPickerScreen, GameDetailPrevious,
    GameDetailScreen, LibraryBrowseScreen, MainMenuScreen, ResultDetailScreen, ResultScreen,
    SearchScreen, SettingsScreen,
};
use crate::feature_compat::{save_sync_compatibility, SaveSyncCompatibility};
use crate::openapi::{resolve_path_template, EndpointRegistry};

/// Result of a background library metadata refresh (generation-guarded).
struct LibraryMetadataRefreshDone {
    gen: u64,
    platforms: Vec<Platform>,
    collections: Vec<Collection>,
    collection_digest: Vec<startup_library_snapshot::CollectionDigestEntry>,
    warnings: Vec<String>,
}

struct CollectionPrefetchDone {
    key: RomCacheKey,
    expected: u64,
    roms: Option<RomList>,
    warning: Option<String>,
}

enum RomLoadEvent {
    Batch(RomList),
    Failed(String),
    Complete,
}

/// Background primary ROM list fetch (deferred load path). Generation-guarded against stale completions.
struct RomLoadDone {
    gen: u64,
    key: Option<RomCacheKey>,
    expected: u64,
    event: RomLoadEvent,
    context: &'static str,
    started: Instant,
}

enum SearchLoadEvent {
    Batch(RomList),
    Failed(String),
    Complete,
}

struct SearchLoadDone {
    query: String,
    event: SearchLoadEvent,
}

struct CoverLoadDone {
    rom_id: u64,
    result: Result<image::DynamicImage, String>,
}

struct SaveListDone {
    rom_id: u64,
    result: Result<Vec<SaveMetadata>, String>,
}

struct SaveUploadDone {
    rom_id: u64,
    result: Result<(), String>,
}

struct SaveDownloadDone {
    rom_id: u64,
    result: Result<PathBuf, String>,
}

struct DeviceListDone {
    result: Result<Vec<DeviceSchema>, String>,
}

struct SyncPushPullDone {
    result: Result<SyncSessionSchema, String>,
}

struct StartupUpdatePrompt {
    status: UpdateStatus,
    updating: bool,
}

/// Deferred primary ROM load: cache key, API request, expected count, context label, start time.
type DeferredLoadRoms = (
    Option<RomCacheKey>,
    Option<GetRoms>,
    u64,
    &'static str,
    Instant,
);

#[inline]
fn primary_rom_load_result_is_current(done_gen: u64, current_gen: u64) -> bool {
    done_gen == current_gen
}

fn safe_path_segment(input: &str) -> String {
    let cleaned: String = input
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.') {
                c
            } else {
                '_'
            }
        })
        .collect();
    let trimmed = cleaned.trim().trim_matches('.').trim();
    if trimmed.is_empty() {
        "game".to_string()
    } else {
        trimmed.to_string()
    }
}

fn unique_save_path(dir: &Path, file_name: &str) -> PathBuf {
    let safe_name = safe_path_segment(file_name);
    let base = Path::new(&safe_name)
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("save");
    let ext = Path::new(&safe_name).extension().and_then(|s| s.to_str());
    let mut candidate = dir.join(&safe_name);
    let mut n = 1u32;
    while candidate.exists() {
        let name = match ext {
            Some(ext) if !ext.is_empty() => format!("{base}-{n}.{ext}"),
            _ => format!("{base}-{n}"),
        };
        candidate = dir.join(name);
        n += 1;
    }
    candidate
}

// ---------------------------------------------------------------------------
// Screen enum
// ---------------------------------------------------------------------------

/// All possible high-level screens in the TUI.
///
/// `App` holds exactly one of these at a time and delegates both
/// rendering and key handling based on the current variant.
pub enum AppScreen {
    MainMenu(MainMenuScreen),
    LibraryBrowse(LibraryBrowseScreen),
    Search(SearchScreen),
    Settings(SettingsScreen),
    Browse(BrowseScreen),
    Execute(ExecuteScreen),
    Result(ResultScreen),
    ResultDetail(ResultDetailScreen),
    GameDetail(Box<GameDetailScreen>),
    ExtrasPicker(Box<ExtrasPickerScreen>),
    Download(DownloadScreen),
    SetupWizard(Box<crate::tui::screens::setup_wizard::SetupWizard>),
}

/// Result of a background ROM upload (path validated before spawn).
struct LibraryUploadComplete {
    platform_id: u64,
    scan_after: bool,
}

// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------

/// Root application object for the TUI.
///
/// Owns shared services (`RommClient`, `RomCache`, `DownloadManager`)
/// as well as the currently active [`AppScreen`].
pub struct App {
    pub screen: AppScreen,
    client: RommClient,
    config: Config,
    registry: EndpointRegistry,
    /// RomM server version from `GET /api/heartbeat` (`SYSTEM.VERSION`), if available.
    server_version: Option<String>,
    save_sync_compat: SaveSyncCompatibility,
    rom_cache: RomCache,
    downloads: DownloadManager,
    /// Screen to restore when closing the Download overlay.
    screen_before_download: Option<AppScreen>,
    /// Deferred ROM load: (cache_key, api_request, expected_rom_count, context, start).
    deferred_load_roms: Option<DeferredLoadRoms>,
    /// Brief “connected” banner after setup or when the server responds to heartbeat.
    startup_splash: Option<StartupSplash>,
    pub global_error: Option<String>,
    pub global_notice: Option<String>,
    show_keyboard_help: bool,
    startup_update_prompt: Option<StartupUpdatePrompt>,
    /// Receives completed background metadata refreshes for the library screen.
    library_metadata_rx: Option<tokio::sync::mpsc::UnboundedReceiver<LibraryMetadataRefreshDone>>,
    /// Incremented each time a new refresh is spawned; stale completions are ignored.
    library_metadata_refresh_gen: u64,
    collection_prefetch_rx: tokio::sync::mpsc::UnboundedReceiver<CollectionPrefetchDone>,
    collection_prefetch_tx: tokio::sync::mpsc::UnboundedSender<CollectionPrefetchDone>,
    collection_prefetch_queue: VecDeque<(RomCacheKey, GetRoms, u64)>,
    collection_prefetch_queued_keys: HashSet<RomCacheKey>,
    collection_prefetch_inflight_keys: HashSet<RomCacheKey>,
    /// Latest generation for primary ROM loads; completions with a lower gen are ignored.
    rom_load_gen: u64,
    rom_load_rx: tokio::sync::mpsc::UnboundedReceiver<RomLoadDone>,
    rom_load_tx: tokio::sync::mpsc::UnboundedSender<RomLoadDone>,
    rom_load_task: Option<tokio::task::JoinHandle<()>>,
    search_load_rx: tokio::sync::mpsc::UnboundedReceiver<SearchLoadDone>,
    search_load_tx: tokio::sync::mpsc::UnboundedSender<SearchLoadDone>,
    search_load_task: Option<tokio::task::JoinHandle<()>>,
    cover_load_rx: tokio::sync::mpsc::UnboundedReceiver<CoverLoadDone>,
    cover_load_tx: tokio::sync::mpsc::UnboundedSender<CoverLoadDone>,
    cover_load_task: Option<tokio::task::JoinHandle<()>>,
    /// Receives `Ok(())` when a background `scan_library` (with wait) finishes successfully.
    library_scan_rx: Option<tokio::sync::mpsc::UnboundedReceiver<Result<(), String>>>,
    library_scan_inflight: bool,
    /// Cache policy applied when the current background scan completes successfully.
    library_scan_pending_invalidate: Option<ScanCacheInvalidate>,
    /// After a successful server scan, force ROM list reload once metadata refresh completes.
    force_rom_reload_after_metadata: bool,
    /// Background chunked ROM upload to the selected platform.
    library_upload_inflight: bool,
    library_upload_progress_rx: Option<tokio::sync::mpsc::UnboundedReceiver<(u64, u64)>>,
    library_upload_done_rx:
        Option<tokio::sync::mpsc::UnboundedReceiver<Result<LibraryUploadComplete, String>>>,
    save_list_rx: tokio::sync::mpsc::UnboundedReceiver<SaveListDone>,
    save_list_tx: tokio::sync::mpsc::UnboundedSender<SaveListDone>,
    save_upload_rx: tokio::sync::mpsc::UnboundedReceiver<SaveUploadDone>,
    save_upload_tx: tokio::sync::mpsc::UnboundedSender<SaveUploadDone>,
    save_download_rx: tokio::sync::mpsc::UnboundedReceiver<SaveDownloadDone>,
    save_download_tx: tokio::sync::mpsc::UnboundedSender<SaveDownloadDone>,
    device_list_rx: tokio::sync::mpsc::UnboundedReceiver<DeviceListDone>,
    device_list_tx: tokio::sync::mpsc::UnboundedSender<DeviceListDone>,
    sync_push_pull_rx: tokio::sync::mpsc::UnboundedReceiver<SyncPushPullDone>,
    sync_push_pull_tx: tokio::sync::mpsc::UnboundedSender<SyncPushPullDone>,
}

impl App {
    fn blocks_global_d_shortcut(&self) -> bool {
        let base = match &self.screen {
            AppScreen::Search(_) | AppScreen::Settings(_) | AppScreen::SetupWizard(_) => true,
            AppScreen::LibraryBrowse(lib) => {
                lib.any_search_bar_open() || lib.any_upload_prompt_open()
            }
            _ => false,
        };
        base || self.library_upload_inflight
    }

    fn allows_global_question_help(&self) -> bool {
        match &self.screen {
            AppScreen::Search(_) | AppScreen::SetupWizard(_) | AppScreen::Execute(_) => false,
            AppScreen::LibraryBrowse(lib)
                if lib.any_search_bar_open() || lib.any_upload_prompt_open() =>
            {
                false
            }
            AppScreen::Settings(s) if s.editing || s.path_picker.is_some() => false,
            _ => true,
        }
    }

    fn is_force_quit_key(key: &crossterm::event::KeyEvent) -> bool {
        key.kind == KeyEventKind::Press
            && key.modifiers.contains(KeyModifiers::CONTROL)
            && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
    }

    fn selected_rom_request_for_library(
        lib: &super::screens::library_browse::LibraryBrowseScreen,
    ) -> Option<GetRoms> {
        match lib.subsection {
            super::screens::library_browse::LibrarySubsection::ByConsole => {
                lib.get_roms_request_platform()
            }
            super::screens::library_browse::LibrarySubsection::ByCollection => {
                lib.get_roms_request_collection()
            }
        }
    }

    /// Construct a new `App` with fresh cache and empty download list.
    pub fn new(
        client: RommClient,
        config: Config,
        registry: EndpointRegistry,
        server_version: Option<String>,
        startup_splash: Option<StartupSplash>,
        startup_update: Option<UpdateStatus>,
    ) -> Self {
        let (prefetch_tx, prefetch_rx) = tokio::sync::mpsc::unbounded_channel();
        let (rom_load_tx, rom_load_rx) = tokio::sync::mpsc::unbounded_channel();
        let (search_load_tx, search_load_rx) = tokio::sync::mpsc::unbounded_channel();
        let (cover_load_tx, cover_load_rx) = tokio::sync::mpsc::unbounded_channel();
        let (save_list_tx, save_list_rx) = tokio::sync::mpsc::unbounded_channel();
        let (save_upload_tx, save_upload_rx) = tokio::sync::mpsc::unbounded_channel();
        let (save_download_tx, save_download_rx) = tokio::sync::mpsc::unbounded_channel();
        let (device_list_tx, device_list_rx) = tokio::sync::mpsc::unbounded_channel();
        let (sync_push_pull_tx, sync_push_pull_rx) = tokio::sync::mpsc::unbounded_channel();
        let save_sync_compat = save_sync_compatibility(&registry);
        Self {
            screen: AppScreen::MainMenu(MainMenuScreen::new()),
            client,
            config,
            registry,
            server_version,
            save_sync_compat,
            rom_cache: RomCache::load(),
            downloads: DownloadManager::new(),
            screen_before_download: None,
            deferred_load_roms: None,
            startup_splash,
            global_error: None,
            global_notice: None,
            show_keyboard_help: false,
            startup_update_prompt: startup_update.map(|status| StartupUpdatePrompt {
                status,
                updating: false,
            }),
            library_metadata_rx: None,
            library_metadata_refresh_gen: 0,
            collection_prefetch_rx: prefetch_rx,
            collection_prefetch_tx: prefetch_tx,
            collection_prefetch_queue: VecDeque::new(),
            collection_prefetch_queued_keys: HashSet::new(),
            collection_prefetch_inflight_keys: HashSet::new(),
            rom_load_gen: 0,
            rom_load_rx,
            rom_load_tx,
            rom_load_task: None,
            search_load_rx,
            search_load_tx,
            search_load_task: None,
            cover_load_rx,
            cover_load_tx,
            cover_load_task: None,
            library_scan_rx: None,
            library_scan_inflight: false,
            library_scan_pending_invalidate: None,
            force_rom_reload_after_metadata: false,
            library_upload_inflight: false,
            library_upload_progress_rx: None,
            library_upload_done_rx: None,
            save_list_rx,
            save_list_tx,
            save_upload_rx,
            save_upload_tx,
            save_download_rx,
            save_download_tx,
            device_list_rx,
            device_list_tx,
            sync_push_pull_rx,
            sync_push_pull_tx,
        }
    }

    fn spawn_library_metadata_refresh(&mut self) {
        self.library_metadata_refresh_gen = self.library_metadata_refresh_gen.saturating_add(1);
        let gen = self.library_metadata_refresh_gen;
        let client = self.client.clone();
        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
        self.library_metadata_rx = Some(rx);
        tokio::spawn(async move {
            let fetch = startup_library_snapshot::fetch_merged_library_metadata(&client).await;
            let _ = tx.send(LibraryMetadataRefreshDone {
                gen,
                platforms: fetch.platforms,
                collections: fetch.collections,
                collection_digest: fetch.collection_digest,
                warnings: fetch.warnings,
            });
        });
    }

    /// Drain background work (e.g. library metadata refresh). Safe to call each frame.
    pub fn poll_background_tasks(&mut self) {
        self.poll_library_metadata_refresh();
        self.poll_rom_load_results();
        self.poll_collection_prefetch_results();
        self.poll_search_load_results();
        self.poll_cover_load_results();
        self.poll_save_results();
        self.poll_settings_results();
        self.poll_library_upload();
        self.poll_library_scan();
        self.drive_collection_prefetch_scheduler();
        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
            lib.poll_footer_clear();
        }
    }

    fn spawn_library_rescan_worker(&mut self, cache_on_success: ScanCacheInvalidate) {
        if self.library_scan_inflight {
            return;
        }
        self.library_scan_inflight = true;
        self.library_scan_pending_invalidate = Some(cache_on_success);
        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
            lib.set_metadata_footer(Some("Server library scan running…".into()));
        }
        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
        self.library_scan_rx = Some(rx);
        let client = self.client.clone();
        tokio::spawn(async move {
            let result = async {
                let start =
                    crate::commands::library_scan::start_scan_library(&client, None).await?;
                crate::commands::library_scan::wait_for_task_terminal(
                    &client,
                    &start.task_id,
                    Duration::from_secs(3600),
                    None,
                    |_| {},
                )
                .await?;
                Ok::<(), anyhow::Error>(())
            }
            .await
            .map_err(|e| e.to_string());
            let _ = tx.send(result);
        });
    }

    fn poll_library_scan(&mut self) {
        let Some(rx) = &mut self.library_scan_rx else {
            return;
        };
        match rx.try_recv() {
            Ok(result) => {
                self.library_scan_rx = None;
                self.library_scan_inflight = false;
                match result {
                    Ok(()) => self.on_library_scan_completed_success(),
                    Err(e) => {
                        self.library_scan_pending_invalidate = None;
                        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
                            lib.set_metadata_footer(Some(format!("Library scan failed: {e}")));
                        } else {
                            self.global_error = Some(format!("Library scan failed: {e}"));
                        }
                    }
                }
            }
            Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
                self.library_scan_rx = None;
                self.library_scan_inflight = false;
                self.library_scan_pending_invalidate = None;
            }
        }
    }

    fn apply_library_scan_cache_invalidate(&mut self, inv: &ScanCacheInvalidate) {
        match inv {
            ScanCacheInvalidate::None => {}
            ScanCacheInvalidate::Platform(pid) => {
                self.rom_cache.remove(&RomCacheKey::Platform(*pid));
            }
            ScanCacheInvalidate::AllPlatforms => {
                self.rom_cache.remove_all_platform_entries();
                if let AppScreen::LibraryBrowse(lib) = &self.screen {
                    if let Some(ref k) = lib.cache_key() {
                        if !matches!(k, RomCacheKey::Platform(_)) {
                            self.rom_cache.remove(k);
                        }
                    }
                }
            }
        }
    }

    fn on_library_scan_completed_success(&mut self) {
        let inv = self
            .library_scan_pending_invalidate
            .take()
            .unwrap_or(ScanCacheInvalidate::AllPlatforms);
        self.apply_library_scan_cache_invalidate(&inv);
        if matches!(self.screen, AppScreen::LibraryBrowse(_)) {
            self.force_rom_reload_after_metadata = true;
            self.spawn_library_metadata_refresh();
        }
    }

    fn format_upload_bytes(n: u64) -> String {
        const KB: u64 = 1024;
        const MB: u64 = KB * 1024;
        const GB: u64 = MB * 1024;
        if n >= GB {
            format!("{:.2} GiB", n as f64 / GB as f64)
        } else if n >= MB {
            format!("{:.2} MiB", n as f64 / MB as f64)
        } else if n >= KB {
            format!("{:.1} KiB", n as f64 / KB as f64)
        } else {
            format!("{n} B")
        }
    }

    fn spawn_library_upload_worker(&mut self, platform_id: u64, path: PathBuf, scan_after: bool) {
        if self.library_upload_inflight || self.library_scan_inflight {
            return;
        }
        self.library_upload_inflight = true;
        self.library_upload_progress_rx = None;
        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
            lib.set_metadata_footer(Some("Preparing upload…".into()));
        }
        let (prog_tx, prog_rx) = tokio::sync::mpsc::unbounded_channel();
        let (done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
        self.library_upload_progress_rx = Some(prog_rx);
        self.library_upload_done_rx = Some(done_rx);
        let client = self.client.clone();
        tokio::spawn(async move {
            let result: Result<LibraryUploadComplete, String> = async {
                client
                    .upload_rom(platform_id, &path, move |uploaded, total| {
                        let _ = prog_tx.send((uploaded, total));
                    })
                    .await
                    .map_err(|e| e.to_string())?;
                Ok(LibraryUploadComplete {
                    platform_id,
                    scan_after,
                })
            }
            .await;
            let _ = done_tx.send(result);
        });
    }

    fn poll_library_upload(&mut self) {
        if let Some(rx) = &mut self.library_upload_progress_rx {
            while let Ok((up, tot)) = rx.try_recv() {
                if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
                    lib.set_metadata_footer(Some(format!(
                        "Uploading {} / {}…",
                        Self::format_upload_bytes(up),
                        Self::format_upload_bytes(tot)
                    )));
                }
            }
        }

        let Some(rx) = &mut self.library_upload_done_rx else {
            return;
        };
        match rx.try_recv() {
            Ok(result) => {
                self.library_upload_done_rx = None;
                self.library_upload_progress_rx = None;
                self.library_upload_inflight = false;
                match result {
                    Ok(done) => {
                        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
                            if done.scan_after {
                                lib.set_metadata_footer(Some(
                                    "Upload complete. Starting library scan…".into(),
                                ));
                                self.spawn_library_rescan_worker(ScanCacheInvalidate::Platform(
                                    done.platform_id,
                                ));
                            } else {
                                lib.set_metadata_footer(Some("Upload complete.".into()));
                            }
                        }
                    }
                    Err(e) => {
                        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
                            lib.set_metadata_footer(Some(format!("Upload failed: {e}")));
                        } else {
                            self.global_error = Some(format!("Upload failed: {e}"));
                        }
                    }
                }
            }
            Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
                self.library_upload_done_rx = None;
                self.library_upload_progress_rx = None;
                self.library_upload_inflight = false;
            }
        }
    }

    fn poll_search_load_results(&mut self) {
        loop {
            match self.search_load_rx.try_recv() {
                Ok(done) => {
                    if let AppScreen::Search(ref mut search) = self.screen {
                        match done.event {
                            SearchLoadEvent::Batch(roms) => {
                                search.set_results_for_query(done.query, roms);
                            }
                            SearchLoadEvent::Failed(err) => {
                                search.loading = false;
                                self.global_error = Some(err);
                            }
                            SearchLoadEvent::Complete => {
                                search.loading = false;
                            }
                        }
                    }
                }
                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
            }
        }
    }

    fn spawn_cover_load_worker(&mut self, rom_id: u64, url: String) {
        if let Some(task) = self.cover_load_task.take() {
            task.abort();
        }
        let tx = self.cover_load_tx.clone();
        self.cover_load_task = Some(tokio::spawn(async move {
            let result = async {
                let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
                let status = response.status();
                if !status.is_success() {
                    return Err(format!("HTTP {}", status.as_u16()));
                }
                let bytes = response.bytes().await.map_err(|e| e.to_string())?;
                image::load_from_memory(&bytes).map_err(|e| e.to_string())
            }
            .await;
            let _ = tx.send(CoverLoadDone { rom_id, result });
        }));
    }

    fn poll_cover_load_results(&mut self) {
        loop {
            match self.cover_load_rx.try_recv() {
                Ok(done) => {
                    if let AppScreen::GameDetail(detail) = &mut self.screen {
                        if detail.rom.id != done.rom_id {
                            continue;
                        }
                        match done.result {
                            Ok(image) => detail.apply_cover_image(image),
                            Err(err) => detail.apply_cover_error(format!(
                                "Cover failed: {}",
                                crate::tui::utils::truncate(&err, 120)
                            )),
                        }
                    }
                }
                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
            }
        }
    }

    fn maybe_start_game_detail_cover_load(&mut self) {
        let (rom_id, url) = match &mut self.screen {
            AppScreen::GameDetail(detail) => {
                if !detail.should_request_cover_load() {
                    return;
                }
                detail.set_cover_loading();
                let Some(url) = detail.cover_last_url.clone() else {
                    return;
                };
                (detail.rom.id, url)
            }
            _ => return,
        };
        self.spawn_cover_load_worker(rom_id, url);
    }

    fn spawn_save_list_worker(&mut self, rom_id: u64) {
        if let AppScreen::GameDetail(detail) = &mut self.screen {
            detail.set_saves_loading();
        }
        let client = self.client.clone();
        let tx = self.save_list_tx.clone();
        tokio::spawn(async move {
            let result = async {
                let value = client
                    .request_json(
                        "GET",
                        "/api/saves",
                        &[("rom_id".to_string(), rom_id.to_string())],
                        None,
                    )
                    .await?;
                SaveMetadata::from_api_value(value)
            }
            .await
            .map_err(|e| format!("{e:#}"));
            let _ = tx.send(SaveListDone { rom_id, result });
        });
    }

    fn refresh_current_game_saves(&mut self) {
        if let AppScreen::GameDetail(detail) = &self.screen {
            self.spawn_save_list_worker(detail.rom.id);
        }
    }

    fn poll_save_results(&mut self) {
        while let Ok(done) = self.save_list_rx.try_recv() {
            if let AppScreen::GameDetail(detail) = &mut self.screen {
                if detail.rom.id == done.rom_id {
                    match done.result {
                        Ok(rows) => detail.apply_saves(rows),
                        Err(e) => detail.apply_saves_error(e),
                    }
                }
            }
        }
        while let Ok(done) = self.save_upload_rx.try_recv() {
            if let AppScreen::GameDetail(detail) = &mut self.screen {
                if detail.rom.id == done.rom_id {
                    match done.result {
                        Ok(()) => {
                            detail.message = Some("Save uploaded. Refreshing saves...".into());
                            detail.message_clear_at = Some(Instant::now() + Duration::from_secs(3));
                            self.spawn_save_list_worker(done.rom_id);
                        }
                        Err(e) => {
                            detail.message = Some(format!("Save upload failed: {e}"));
                            detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
                        }
                    }
                }
            }
        }
        while let Ok(done) = self.save_download_rx.try_recv() {
            if let AppScreen::GameDetail(detail) = &mut self.screen {
                if detail.rom.id == done.rom_id {
                    match done.result {
                        Ok(path) => {
                            detail.message = Some(format!("Save downloaded: {}", path.display()));
                            detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
                            self.spawn_save_list_worker(done.rom_id);
                        }
                        Err(e) => {
                            detail.message = Some(format!("Save download failed: {e}"));
                            detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
                        }
                    }
                }
            }
        }
    }

    fn poll_settings_results(&mut self) {
        while let Ok(done) = self.device_list_rx.try_recv() {
            if let AppScreen::Settings(settings) = &mut self.screen {
                match done.result {
                    Ok(devices) => {
                        settings.set_devices(devices);
                        settings.message = None;
                    }
                    Err(e) => {
                        settings.set_device_error(e.clone());
                        settings.message = Some((format!("Device load failed: {e}"), Color::Red));
                    }
                }
            }
        }
        while let Ok(done) = self.sync_push_pull_rx.try_recv() {
            if let AppScreen::Settings(settings) = &mut self.screen {
                settings.sync_inflight = false;
                match done.result {
                    Ok(session) => {
                        settings.message = Some((
                            format!("Sync session #{}: {}", session.id, session.status),
                            Color::Green,
                        ));
                    }
                    Err(e) => {
                        settings.message = Some((format!("Sync failed: {e}"), Color::Red));
                    }
                }
            }
        }
    }

    fn poll_rom_load_results(&mut self) {
        loop {
            match self.rom_load_rx.try_recv() {
                Ok(done) => {
                    if !primary_rom_load_result_is_current(done.gen, self.rom_load_gen) {
                        continue;
                    }
                    let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
                        continue;
                    };
                    match done.event {
                        RomLoadEvent::Batch(roms) => {
                            if let Some(ref k) = done.key {
                                self.rom_cache
                                    .insert(k.clone(), roms.clone(), done.expected);
                            }
                            lib.set_roms(roms);
                            tracing::debug!(
                                "rom-list-render batch context={} latency_ms={}",
                                done.context,
                                done.started.elapsed().as_millis()
                            );
                        }
                        RomLoadEvent::Failed(e) => {
                            lib.set_metadata_footer(Some(format!("Could not load games: {e}")));
                            lib.set_rom_loading(false);
                        }
                        RomLoadEvent::Complete => {
                            lib.set_rom_loading(false);
                        }
                    }
                }
                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
            }
        }
    }

    fn poll_library_metadata_refresh(&mut self) {
        let mut batch = Vec::new();
        let mut disconnected = false;
        if let Some(rx) = &mut self.library_metadata_rx {
            loop {
                match rx.try_recv() {
                    Ok(msg) => batch.push(msg),
                    Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
                    Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
                        disconnected = true;
                        break;
                    }
                }
            }
        }
        if disconnected {
            self.library_metadata_rx = None;
        }
        for msg in batch {
            self.apply_library_metadata_refresh(msg);
        }
    }

    fn apply_library_metadata_refresh(&mut self, msg: LibraryMetadataRefreshDone) {
        if msg.gen != self.library_metadata_refresh_gen {
            return;
        }
        let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
            return;
        };

        let had_cached_lists = !lib.platforms.is_empty() || !lib.collections.is_empty();
        let live_empty = msg.collections.is_empty();
        if live_empty && had_cached_lists && !msg.warnings.is_empty() {
            lib.set_temporary_metadata_footer(
                "Could not refresh library metadata (keeping cached list).".into(),
                std::time::Duration::from_secs(3),
            );
            self.force_rom_reload_after_metadata = false;
            return;
        }

        let old_digest =
            startup_library_snapshot::build_collection_digest_from_collections(&lib.collections);
        let digest_changed = old_digest != msg.collection_digest;
        let update_platforms = !msg.platforms.is_empty();
        let selection_changed = lib.replace_metadata_preserving_selection(
            msg.platforms,
            msg.collections,
            update_platforms,
            true,
        );
        startup_library_snapshot::save_snapshot(&lib.platforms, &lib.collections);

        let footer = if msg.warnings.is_empty() {
            if digest_changed {
                Some("Collection metadata updated.".into())
            } else {
                None
            }
        } else {
            let w = msg.warnings.join(" | ");
            let short: String = if w.chars().count() > 160 {
                let prefix: String = w.chars().take(157).collect();
                format!("{prefix}…")
            } else {
                w
            };
            Some(format!("Partial refresh: {}", short))
        };
        lib.set_metadata_footer(footer);

        if selection_changed && lib.list_len() > 0 {
            lib.clear_roms();
            let key = lib.cache_key();
            let expected = lib.expected_rom_count();
            let req = Self::selected_rom_request_for_library(lib);
            lib.set_rom_loading(expected > 0);
            self.deferred_load_roms =
                Some((key, req, expected, "refresh_selection", Instant::now()));
        }

        let force_reload = std::mem::take(&mut self.force_rom_reload_after_metadata);
        if force_reload && lib.list_len() > 0 && !selection_changed {
            lib.clear_roms();
            let key = lib.cache_key();
            let expected = lib.expected_rom_count();
            let req = Self::selected_rom_request_for_library(lib);
            lib.set_rom_loading(expected > 0);
            self.deferred_load_roms =
                Some((key, req, expected, "post_scan_reload", Instant::now()));
        }

        self.queue_collection_prefetches_from_screen(1, "refresh_warmup");
    }

    fn queue_collection_prefetches_from_screen(&mut self, radius: usize, _reason: &'static str) {
        let AppScreen::LibraryBrowse(ref lib) = self.screen else {
            return;
        };
        for (key, req, expected) in lib.collection_prefetch_candidates(radius) {
            if self.rom_cache.get_valid(&key, expected).is_some() {
                continue;
            }
            if self.collection_prefetch_queued_keys.contains(&key)
                || self.collection_prefetch_inflight_keys.contains(&key)
            {
                continue;
            }
            self.collection_prefetch_queued_keys.insert(key.clone());
            self.collection_prefetch_queue
                .push_back((key, req, expected));
        }
    }

    fn drive_collection_prefetch_scheduler(&mut self) {
        const PREFETCH_MAX_INFLIGHT: usize = 2;
        while self.collection_prefetch_inflight_keys.len() < PREFETCH_MAX_INFLIGHT {
            let Some((key, req, expected)) = self.collection_prefetch_queue.pop_back() else {
                break;
            };
            self.collection_prefetch_queued_keys.remove(&key);
            self.collection_prefetch_inflight_keys.insert(key.clone());
            let tx = self.collection_prefetch_tx.clone();
            let client = self.client.clone();
            tokio::spawn(async move {
                let result = Self::fetch_roms_full(client, req).await;
                let (roms, warning) = match result {
                    Ok(list) => (Some(list), None),
                    Err(e) => (None, Some(format!("Collection prefetch failed: {e:#}"))),
                };
                let _ = tx.send(CollectionPrefetchDone {
                    key,
                    expected,
                    roms,
                    warning,
                });
            });
        }
    }

    fn poll_collection_prefetch_results(&mut self) {
        loop {
            match self.collection_prefetch_rx.try_recv() {
                Ok(done) => {
                    self.collection_prefetch_inflight_keys.remove(&done.key);
                    if let Some(roms) = done.roms {
                        self.rom_cache.insert(done.key, roms, done.expected);
                    } else if let Some(warning) = done.warning {
                        tracing::debug!("{warning}");
                    }
                }
                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
            }
        }
    }

    pub fn set_error(&mut self, err: anyhow::Error) {
        self.global_error = Some(format!("{:#}", err));
    }

    // -----------------------------------------------------------------------
    // Event loop
    // -----------------------------------------------------------------------

    /// Main TUI event loop.
    ///
    /// This method owns the terminal for the lifetime of the app,
    /// repeatedly drawing the current screen and dispatching key
    /// events until the user chooses to quit.
    pub async fn run(&mut self) -> Result<()> {
        enable_raw_mode()?;
        let mut stdout = std::io::stdout();
        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
        let backend = CrosstermBackend::new(stdout);
        let mut terminal = Terminal::new(backend)?;

        loop {
            self.poll_background_tasks();
            if self
                .startup_splash
                .as_ref()
                .is_some_and(|s| s.should_auto_dismiss())
            {
                self.startup_splash = None;
            }
            // Draw the current screen. `App::render` delegates to the
            // appropriate screen type based on `self.screen`.
            terminal.draw(|f| self.render(f))?;

            // If an update was triggered, execute it now (this will block the loop and show the "Updating..." message)
            if let Some(ref mut prompt) = self.startup_update_prompt {
                if prompt.updating {
                    // Safety: Don't actually run self_update if this is a mock
                    if prompt.status.latest_version == "9.9.9-mock" {
                        tokio::time::sleep(std::time::Duration::from_secs(2)).await; // Simulate some work
                        self.global_error =
                            Some("Mock update successful! (No files were changed)".into());
                        self.startup_update_prompt = None;
                    } else {
                        let options = crate::update::ApplyUpdateOptions {
                            show_progress: false,
                            show_output: false,
                            no_confirm: true,
                            target_version_tag: Some(prompt.status.release_tag.clone()),
                        };
                        match crate::update::apply_update(None, options).await {
                            Ok(crate::update::ApplyUpdateOutcome::Updated(version)) => {
                                self.global_notice = Some(format!(
                                    "Updated to {version}. Restart romm-cli to use the new version."
                                ));
                            }
                            Ok(crate::update::ApplyUpdateOutcome::UpToDate(version)) => {
                                self.global_notice =
                                    Some(format!("Already up to date (`{version}`)."));
                            }
                            Err(err) => {
                                self.global_error = Some(format!("Update failed: {err:#}"));
                            }
                        }
                        self.startup_update_prompt = None;
                    }
                    continue;
                }
            }

            // Poll with a short timeout so the UI refreshes during downloads
            // even when the user is not pressing any keys.
            if event::poll(Duration::from_millis(100))? {
                if let Event::Key(key_event) = event::read()? {
                    if Self::is_force_quit_key(&key_event) {
                        break;
                    }
                    if key_event.kind == KeyEventKind::Press
                        && key_event.modifiers.contains(KeyModifiers::CONTROL)
                        && matches!(key_event.code, KeyCode::Char('r') | KeyCode::Char('R'))
                    {
                        if let AppScreen::LibraryBrowse(ref lib) = self.screen {
                            if !lib.any_search_bar_open()
                                && !lib.any_upload_prompt_open()
                                && !self.library_upload_inflight
                                && !self.library_scan_inflight
                            {
                                self.spawn_library_rescan_worker(ScanCacheInvalidate::AllPlatforms);
                            }
                        }
                        continue;
                    }
                    if key_event.kind == KeyEventKind::Press
                        && key_event.modifiers.contains(KeyModifiers::CONTROL)
                        && matches!(key_event.code, KeyCode::Char('u') | KeyCode::Char('U'))
                    {
                        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
                            if lib.any_upload_prompt_open() {
                                lib.close_upload_prompt();
                            } else if !lib.any_search_bar_open()
                                && !self.library_upload_inflight
                                && !self.library_scan_inflight
                            {
                                if lib.subsection
                                    == super::screens::library_browse::LibrarySubsection::ByConsole
                                {
                                    lib.open_upload_prompt();
                                } else {
                                    lib.set_metadata_footer(Some(
                                        "Upload requires Consoles view — press t".into(),
                                    ));
                                }
                            }
                        }
                        continue;
                    }
                    if key_event.kind == KeyEventKind::Press
                        && self.handle_key_event(&key_event).await?
                    {
                        break;
                    }
                }
            }

            // Process deferred ROM fetch (set during LibraryBrowse ↑/↓, subsection switch, refresh).
            // Cache hits apply synchronously; network fetch runs in a background task so the loop
            // never awaits HTTP and the UI stays responsive (see `poll_rom_load_results`).
            if let Some((key, req, expected, context, started)) = self.deferred_load_roms.take() {
                // Fast path: valid disk cache — no await, no spawn, load immediately.
                if let Some(ref k) = key {
                    if let Some(cached) = self.rom_cache.get_valid(k, expected) {
                        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
                            lib.set_roms(cached.clone());
                            lib.set_rom_loading(false);
                            tracing::debug!(
                                "rom-list-render context={} latency_ms={} (cache_hit)",
                                context,
                                started.elapsed().as_millis()
                            );
                        }
                        continue;
                    }
                }

                // Debounce network fetches
                if started.elapsed() < std::time::Duration::from_millis(250) {
                    // Put it back to keep waiting
                    self.deferred_load_roms = Some((key, req, expected, context, started));
                    continue;
                }

                self.rom_load_gen = self.rom_load_gen.saturating_add(1);
                let gen = self.rom_load_gen;
                if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
                    lib.set_rom_loading(expected > 0);
                }
                if expected == 0 {
                    if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
                        lib.set_rom_loading(false);
                    }
                    continue;
                }

                let Some(r) = req else {
                    if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
                        lib.set_rom_loading(false);
                    }
                    continue;
                };
                let client = self.client.clone();
                let tx = self.rom_load_tx.clone();

                if let Some(task) = self.rom_load_task.take() {
                    task.abort();
                }

                self.rom_load_task = Some(tokio::spawn(async move {
                    let mut req = r;
                    let mut aggregated: Option<RomList> = None;

                    loop {
                        match client.call(&req).await {
                            Ok(mut batch) => {
                                if let Some(ref mut all) = aggregated {
                                    if batch.items.is_empty() {
                                        break;
                                    }
                                    all.items.append(&mut batch.items);
                                    let _ = tx.send(RomLoadDone {
                                        gen,
                                        key: key.clone(),
                                        expected,
                                        event: RomLoadEvent::Batch(all.clone()),
                                        context,
                                        started,
                                    });
                                    if all.items.len() as u64 >= all.total {
                                        break;
                                    }
                                    req.offset = Some(all.items.len() as u32);
                                } else {
                                    let loaded = batch.items.len() as u64;
                                    let total = batch.total;
                                    let _ = tx.send(RomLoadDone {
                                        gen,
                                        key: key.clone(),
                                        expected,
                                        event: RomLoadEvent::Batch(batch.clone()),
                                        context,
                                        started,
                                    });
                                    req.offset = Some(loaded as u32);
                                    aggregated = Some(batch);
                                    if loaded >= total {
                                        break;
                                    }
                                }
                            }
                            Err(e) => {
                                let _ = tx.send(RomLoadDone {
                                    gen,
                                    key: key.clone(),
                                    expected,
                                    event: RomLoadEvent::Failed(format!("{e:#}")),
                                    context,
                                    started,
                                });
                                return;
                            }
                        }
                        // Cap at 20k
                        if let Some(ref all) = aggregated {
                            if all.items.len() >= 20000 {
                                break;
                            }
                        }
                    }

                    let _ = tx.send(RomLoadDone {
                        gen,
                        key,
                        expected,
                        event: RomLoadEvent::Complete,
                        context,
                        started,
                    });
                }));
            }
        }

        disable_raw_mode()?;
        execute!(
            terminal.backend_mut(),
            LeaveAlternateScreen,
            DisableMouseCapture
        )?;
        terminal.show_cursor()?;
        Ok(())
    }

    // -----------------------------------------------------------------------
    // ROM fetch (used by background tasks and collection prefetch)
    // -----------------------------------------------------------------------
    async fn fetch_roms_full(client: RommClient, req: GetRoms) -> Result<RomList> {
        let mut roms = client.call(&req).await?;
        let total = roms.total;
        let ceiling = 20000;
        while (roms.items.len() as u64) < total && (roms.items.len() as u64) < ceiling {
            let mut next_req = req.clone();
            next_req.offset = Some(roms.items.len() as u32);
            let next_batch = client.call(&next_req).await?;
            if next_batch.items.is_empty() {
                break;
            }
            roms.items.extend(next_batch.items);
        }
        Ok(roms)
    }

    // -----------------------------------------------------------------------
    // Key dispatch — one small method per screen
    // -----------------------------------------------------------------------

    pub async fn handle_key_event(&mut self, key: &KeyEvent) -> Result<bool> {
        if key.kind != KeyEventKind::Press {
            return Ok(false);
        }

        if self.startup_update_prompt.is_some() {
            return self.handle_startup_update_prompt(key).await;
        }

        if self.global_error.is_some() || self.global_notice.is_some() {
            if key.code == KeyCode::Esc || key.code == KeyCode::Enter {
                self.global_error = None;
                self.global_notice = None;
            }
            return Ok(false);
        }

        if self.startup_splash.is_some() {
            self.startup_splash = None;
            return Ok(false);
        }

        if self.show_keyboard_help {
            if matches!(
                key.code,
                KeyCode::Esc | KeyCode::Enter | KeyCode::F(1) | KeyCode::Char('?')
            ) {
                self.show_keyboard_help = false;
            }
            return Ok(false);
        }

        if key.code == KeyCode::F(1) {
            self.show_keyboard_help = true;
            return Ok(false);
        }
        if key.code == KeyCode::Char('?') && self.allows_global_question_help() {
            self.show_keyboard_help = true;
            return Ok(false);
        }

        // Global shortcut: 'd' toggles Download overlay (not on screens that need free typing / menus).
        if key.code == KeyCode::Char('d') && !self.blocks_global_d_shortcut() {
            self.toggle_download_screen();
            return Ok(false);
        }

        match &self.screen {
            AppScreen::MainMenu(_) => self.handle_main_menu(key).await,
            AppScreen::LibraryBrowse(_) => self.handle_library_browse(key).await,
            AppScreen::Search(_) => self.handle_search(key).await,
            AppScreen::Settings(_) => self.handle_settings(key).await,
            AppScreen::Browse(_) => self.handle_browse(key),
            AppScreen::Execute(_) => self.handle_execute(key).await,
            AppScreen::Result(_) => self.handle_result(key),
            AppScreen::ResultDetail(_) => self.handle_result_detail(key),
            AppScreen::GameDetail(_) => self.handle_game_detail(key),
            AppScreen::ExtrasPicker(_) => self.handle_extras_picker(key),
            AppScreen::Download(_) => self.handle_download(key),
            AppScreen::SetupWizard(_) => self.handle_setup_wizard(key).await,
        }
    }

    async fn handle_startup_update_prompt(&mut self, key: &KeyEvent) -> Result<bool> {
        let Some(ref mut prompt) = self.startup_update_prompt else {
            return Ok(false);
        };
        if prompt.updating {
            return Ok(false); // Ignore keys while updating
        }

        match key.code {
            KeyCode::Char('u')
            | KeyCode::Char('U')
            | KeyCode::Char('y')
            | KeyCode::Char('Y')
            | KeyCode::Enter => {
                prompt.updating = true;
                // We need to return true to trigger a re-draw so the "Updating..." message shows up.
                // But wait, the loop is in run().
                Ok(true)
            }
            KeyCode::Char('c') | KeyCode::Char('C') => {
                if let Err(err) = crate::update::open_changelog_in_browser() {
                    self.global_error = Some(format!("Could not open changelog: {err:#}"));
                } else {
                    self.global_error =
                        Some(format!("Opened changelog: {}", prompt.status.changelog_url));
                }
                Ok(false)
            }
            KeyCode::Esc
            | KeyCode::Char('s')
            | KeyCode::Char('S')
            | KeyCode::Char('n')
            | KeyCode::Char('N')
            | KeyCode::Char('q')
            | KeyCode::Char('Q') => {
                self.startup_update_prompt = None;
                Ok(false)
            }
            _ => Ok(false),
        }
    }

    // -- Download overlay ---------------------------------------------------

    fn toggle_download_screen(&mut self) {
        let current =
            std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
        match current {
            AppScreen::Download(_) => {
                self.screen = self
                    .screen_before_download
                    .take()
                    .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
            }
            other => {
                self.screen_before_download = Some(other);
                self.screen = AppScreen::Download(DownloadScreen::new(
                    self.downloads.shared(),
                    self.downloads.shared_extras(),
                ));
            }
        }
    }

    fn handle_download(&mut self, key: &KeyEvent) -> Result<bool> {
        if key.code == KeyCode::Esc || key.code == KeyCode::Char('d') {
            self.screen = self
                .screen_before_download
                .take()
                .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
        }
        Ok(false)
    }

    // -- Main menu ----------------------------------------------------------

    async fn handle_main_menu(&mut self, key: &KeyEvent) -> Result<bool> {
        let menu = match &mut self.screen {
            AppScreen::MainMenu(m) => m,
            _ => return Ok(false),
        };
        match key.code {
            KeyCode::Up | KeyCode::Char('k') => menu.previous(),
            KeyCode::Down | KeyCode::Char('j') => menu.next(),
            KeyCode::Enter => match menu.selected {
                0 => {
                    let start = Instant::now();
                    let snap = startup_library_snapshot::load_snapshot();
                    let (platforms, collections, from_disk) = match snap {
                        Some(s) => (s.platforms, s.collections, true),
                        None => (Vec::new(), Vec::new(), false),
                    };
                    let mut lib = LibraryBrowseScreen::new(platforms, collections);
                    if from_disk && lib.list_len() > 0 {
                        lib.set_metadata_footer(Some(
                            "Refreshing library metadata in background…".into(),
                        ));
                    } else if lib.list_len() == 0 {
                        lib.set_metadata_footer(Some("Loading library metadata…".into()));
                    }
                    if lib.list_len() > 0 {
                        let key = lib.cache_key();
                        let expected = lib.expected_rom_count();
                        let req = Self::selected_rom_request_for_library(&lib);
                        lib.set_rom_loading(expected > 0);
                        self.deferred_load_roms = Some((
                            key,
                            req,
                            expected,
                            "startup_first_selection",
                            Instant::now(),
                        ));
                    }
                    self.screen = AppScreen::LibraryBrowse(lib);
                    self.spawn_library_metadata_refresh();
                    tracing::debug!(
                        "library-open latency_ms={} snapshot_hit={}",
                        start.elapsed().as_millis(),
                        from_disk
                    );
                }
                1 => self.screen = AppScreen::Search(SearchScreen::new()),
                2 => {
                    self.screen_before_download = Some(AppScreen::MainMenu(MainMenuScreen::new()));
                    self.screen = AppScreen::Download(DownloadScreen::new(
                        self.downloads.shared(),
                        self.downloads.shared_extras(),
                    ));
                }
                3 => {
                    self.screen = AppScreen::Settings(SettingsScreen::new(
                        &self.config,
                        self.server_version.as_deref(),
                        self.save_sync_compat.clone(),
                    ))
                }
                4 => return Ok(true),
                _ => {}
            },
            KeyCode::Esc | KeyCode::Char('q') => return Ok(true),
            _ => {}
        }
        Ok(false)
    }

    // -- Library browse -----------------------------------------------------

    async fn handle_library_browse(&mut self, key: &KeyEvent) -> Result<bool> {
        use super::path_picker::PathPickerEvent;
        use super::screens::library_browse::{LibrarySearchMode, LibraryViewMode};

        if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
            if lib.upload_prompt.is_some() {
                if let Some(up) = lib.upload_prompt.as_mut() {
                    if key.code == KeyCode::Esc {
                        lib.close_upload_prompt();
                        return Ok(false);
                    }
                    if key.modifiers.contains(KeyModifiers::CONTROL)
                        && matches!(key.code, KeyCode::Char('s') | KeyCode::Char('S'))
                    {
                        up.scan_after = !up.scan_after;
                        return Ok(false);
                    }
                    match up.picker.handle_key(key) {
                        PathPickerEvent::Confirmed(path) => {
                            let scan_after = up.scan_after;
                            if !Path::new(&path).is_file() {
                                lib.set_metadata_footer(Some(format!(
                                    "Not a file: {}",
                                    path.display()
                                )));
                                return Ok(false);
                            }
                            let Some(pid) = lib.selected_platform_id() else {
                                lib.set_metadata_footer(Some(
                                    "Select a console before uploading.".into(),
                                ));
                                return Ok(false);
                            };
                            lib.close_upload_prompt();
                            self.spawn_library_upload_worker(pid, path, scan_after);
                        }
                        PathPickerEvent::None => {}
                    }
                }
                return Ok(false);
            }
        }

        if self.library_upload_inflight {
            return Ok(false);
        }

        let lib = match &mut self.screen {
            AppScreen::LibraryBrowse(l) => l,
            _ => return Ok(false),
        };

        // List pane: search typing bar
        if lib.view_mode == LibraryViewMode::List {
            if let Some(mode) = lib.list_search.mode {
                let old_key = lib.cache_key();
                match key.code {
                    KeyCode::Esc => lib.clear_list_search(),
                    KeyCode::Backspace => lib.delete_list_search_char(),
                    KeyCode::Char(c) => lib.add_list_search_char(c),
                    KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.list_jump_match(true),
                    KeyCode::Enter => lib.commit_list_filter_bar(),
                    _ => {}
                }
                let new_key = lib.cache_key();
                if old_key != new_key && lib.list_len() > 0 {
                    lib.clear_roms();
                    let expected = lib.expected_rom_count();
                    if expected > 0 {
                        let req = Self::selected_rom_request_for_library(lib);
                        lib.set_rom_loading(true);
                        self.deferred_load_roms =
                            Some((new_key, req, expected, "search_filter", Instant::now()));
                    } else {
                        lib.set_rom_loading(false);
                        self.deferred_load_roms = None;
                    }
                }
                return Ok(false);
            }
        }

        // Games pane: search typing bar
        if lib.view_mode == LibraryViewMode::Roms {
            if let Some(mode) = lib.rom_search.mode {
                match key.code {
                    KeyCode::Esc => lib.clear_rom_search(),
                    KeyCode::Backspace => lib.delete_rom_search_char(),
                    KeyCode::Char(c) => lib.add_rom_search_char(c),
                    KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.jump_rom_match(true),
                    KeyCode::Enter => lib.commit_rom_filter_bar(),
                    _ => {}
                }
                return Ok(false);
            }
        }

        match key.code {
            KeyCode::Up | KeyCode::Char('k') => {
                if lib.view_mode == LibraryViewMode::List {
                    lib.list_previous();
                    if lib.list_len() > 0 {
                        lib.clear_roms(); // avoid showing previous console's games
                        let key = lib.cache_key();
                        let expected = lib.expected_rom_count();
                        if expected > 0 {
                            let req = Self::selected_rom_request_for_library(lib);
                            lib.set_rom_loading(true);
                            self.deferred_load_roms =
                                Some((key, req, expected, "list_move_up", Instant::now()));
                        } else {
                            lib.set_rom_loading(false);
                            self.deferred_load_roms = None;
                        }
                        if lib.subsection
                            == super::screens::library_browse::LibrarySubsection::ByCollection
                        {
                            tracing::debug!("collections-selection move=up expected={expected}");
                            self.queue_collection_prefetches_from_screen(1, "move_up");
                        }
                    }
                } else {
                    lib.rom_previous();
                }
            }
            KeyCode::Down | KeyCode::Char('j') => {
                if lib.view_mode == LibraryViewMode::List {
                    lib.list_next();
                    if lib.list_len() > 0 {
                        lib.clear_roms(); // avoid showing previous console's games
                        let key = lib.cache_key();
                        let expected = lib.expected_rom_count();
                        if expected > 0 {
                            let req = Self::selected_rom_request_for_library(lib);
                            lib.set_rom_loading(true);
                            self.deferred_load_roms =
                                Some((key, req, expected, "list_move_down", Instant::now()));
                        } else {
                            lib.set_rom_loading(false);
                            self.deferred_load_roms = None;
                        }
                        if lib.subsection
                            == super::screens::library_browse::LibrarySubsection::ByCollection
                        {
                            tracing::debug!("collections-selection move=down expected={expected}");
                            self.queue_collection_prefetches_from_screen(1, "move_down");
                        }
                    }
                } else {
                    lib.rom_next();
                }
            }
            KeyCode::Left | KeyCode::Char('h') if lib.view_mode == LibraryViewMode::Roms => {
                lib.back_to_list();
            }
            KeyCode::Right | KeyCode::Char('l') => lib.switch_view(),
            KeyCode::Tab => {
                if lib.view_mode == LibraryViewMode::List {
                    lib.switch_view();
                } else {
                    lib.switch_view(); // Normal tab also switches panels
                }
            }
            KeyCode::Char('/') => match lib.view_mode {
                LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Filter),
                LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Filter),
            },
            KeyCode::Char('f') => match lib.view_mode {
                LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Jump),
                LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Jump),
            },
            KeyCode::Enter => {
                if lib.view_mode == LibraryViewMode::List {
                    lib.switch_view();
                } else if let Some((primary, others)) = lib.get_selected_group() {
                    let lib_screen = std::mem::replace(
                        &mut self.screen,
                        AppScreen::MainMenu(MainMenuScreen::new()),
                    );
                    if let AppScreen::LibraryBrowse(l) = lib_screen {
                        self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
                            primary,
                            others,
                            GameDetailPrevious::Library(Box::new(l)),
                            self.downloads.shared(),
                        )));
                        self.maybe_start_game_detail_cover_load();
                        self.refresh_current_game_saves();
                    }
                }
            }
            KeyCode::Char('t') => {
                lib.switch_subsection();
                // `switch_subsection` clears ROMs but does not queue a load; mirror list ↑/↓ so the
                // first row in the new subsection (index 0) gets ROMs without an extra keypress.
                if lib.view_mode == LibraryViewMode::List && lib.list_len() > 0 {
                    let key = lib.cache_key();
                    let expected = lib.expected_rom_count();
                    if expected > 0 {
                        let req = Self::selected_rom_request_for_library(lib);
                        lib.set_rom_loading(true);
                        self.deferred_load_roms =
                            Some((key, req, expected, "switch_subsection", Instant::now()));
                    } else {
                        lib.set_rom_loading(false);
                        self.deferred_load_roms = None;
                    }
                }
                if lib.subsection == super::screens::library_browse::LibrarySubsection::ByCollection
                {
                    tracing::debug!("collections-subsection entered");
                    self.queue_collection_prefetches_from_screen(1, "enter_collections");
                }
            }
            KeyCode::Esc => {
                if lib.view_mode == LibraryViewMode::Roms {
                    if lib.rom_search.filter_browsing {
                        lib.clear_rom_search();
                    } else {
                        lib.back_to_list();
                    }
                } else if lib.list_search.filter_browsing {
                    lib.clear_list_search();
                } else {
                    self.screen = AppScreen::MainMenu(MainMenuScreen::new());
                }
            }
            KeyCode::Char('q') => return Ok(true),
            _ => {}
        }
        Ok(false)
    }

    // -- Search -------------------------------------------------------------

    async fn handle_search(&mut self, key: &KeyEvent) -> Result<bool> {
        let search = match &mut self.screen {
            AppScreen::Search(s) => s,
            _ => return Ok(false),
        };
        match key.code {
            KeyCode::Backspace => search.delete_char(),
            KeyCode::Left => search.cursor_left(),
            KeyCode::Right => search.cursor_right(),
            KeyCode::Up => search.previous(),
            KeyCode::Down => search.next(),
            KeyCode::Char(c) => search.add_char(c),
            KeyCode::Enter => {
                if search.query.is_empty() {
                    // no-op (same as before: empty query does not search)
                } else if search.result_groups.is_some() && search.results_match_current_query() {
                    if let Some((primary, others)) = search.get_selected_group() {
                        let prev = std::mem::replace(
                            &mut self.screen,
                            AppScreen::MainMenu(MainMenuScreen::new()),
                        );
                        if let AppScreen::Search(s) = prev {
                            self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
                                primary,
                                others,
                                GameDetailPrevious::Search(s),
                                self.downloads.shared(),
                            )));
                            self.maybe_start_game_detail_cover_load();
                            self.refresh_current_game_saves();
                        }
                    }
                } else {
                    let query = search.query.clone();
                    let req = GetRoms {
                        search_term: Some(query.clone()),
                        limit: Some(50),
                        ..Default::default()
                    };
                    search.loading = true;
                    if let Some(task) = self.search_load_task.take() {
                        task.abort();
                    }
                    let client = self.client.clone();
                    let tx = self.search_load_tx.clone();
                    self.search_load_task = Some(tokio::spawn(async move {
                        let mut req = req;
                        let mut aggregated: Option<RomList> = None;

                        loop {
                            match client.call(&req).await {
                                Ok(mut batch) => {
                                    if let Some(ref mut all) = aggregated {
                                        if batch.items.is_empty() {
                                            break;
                                        }
                                        all.items.append(&mut batch.items);
                                        let _ = tx.send(SearchLoadDone {
                                            query: query.clone(),
                                            event: SearchLoadEvent::Batch(all.clone()),
                                        });
                                        if all.items.len() as u64 >= all.total {
                                            break;
                                        }
                                        req.offset = Some(all.items.len() as u32);
                                    } else {
                                        let loaded = batch.items.len() as u64;
                                        let total = batch.total;
                                        let _ = tx.send(SearchLoadDone {
                                            query: query.clone(),
                                            event: SearchLoadEvent::Batch(batch.clone()),
                                        });
                                        req.offset = Some(loaded as u32);
                                        aggregated = Some(batch);
                                        if loaded >= total {
                                            break;
                                        }
                                    }
                                }
                                Err(e) => {
                                    let _ = tx.send(SearchLoadDone {
                                        query: query.clone(),
                                        event: SearchLoadEvent::Failed(format!("{e:#}")),
                                    });
                                    return;
                                }
                            }
                        }

                        let _ = tx.send(SearchLoadDone {
                            query,
                            event: SearchLoadEvent::Complete,
                        });
                    }));
                }
            }
            KeyCode::Esc => {
                if search.results.is_some() {
                    search.clear_results();
                } else {
                    self.screen = AppScreen::MainMenu(MainMenuScreen::new());
                }
            }
            _ => {}
        }
        Ok(false)
    }

    // -- Settings -----------------------------------------------------------

    async fn refresh_settings_server_version(&mut self) -> Result<()> {
        let (base_url, download_dir, use_https, verbose, auth) = {
            let settings = match &self.screen {
                AppScreen::Settings(s) => s,
                _ => return Ok(()),
            };
            let mut base_url = normalize_romm_origin(settings.base_url.trim());
            if settings.use_https && base_url.starts_with("http://") {
                base_url = base_url.replace("http://", "https://");
            }
            if !settings.use_https && base_url.starts_with("https://") {
                base_url = base_url.replace("https://", "http://");
            }
            (
                base_url,
                settings.download_dir.clone(),
                settings.use_https,
                self.client.verbose(),
                self.config.auth.clone(),
            )
        };
        let cfg = Config {
            base_url,
            download_dir,
            use_https,
            auth,
            extras_defaults: self.config.extras_defaults.clone(),
            save_sync: self.config.save_sync.clone(),
        };
        let client = match RommClient::new(&cfg, verbose) {
            Ok(c) => c,
            Err(_) => {
                if let AppScreen::Settings(s) = &mut self.screen {
                    s.server_version = "unavailable (invalid URL or client error)".to_string();
                    self.server_version = None;
                }
                return Ok(());
            }
        };
        let ver = client.rom_server_version_from_heartbeat().await;
        if let AppScreen::Settings(s) = &mut self.screen {
            match ver {
                Some(v) => {
                    s.server_version = v.clone();
                    self.server_version = Some(v);
                }
                None => {
                    s.server_version = "unavailable (heartbeat failed)".to_string();
                    self.server_version = None;
                }
            }
        }
        Ok(())
    }

    async fn handle_settings(&mut self, key: &KeyEvent) -> Result<bool> {
        use super::path_picker::PathPickerEvent;
        use crate::core::download::validate_configured_download_directory;

        let settings = match &mut self.screen {
            AppScreen::Settings(s) => s,
            _ => return Ok(false),
        };

        if let Some((kind, ref mut picker)) = settings.path_picker {
            if key.code == KeyCode::Esc {
                settings.path_picker = None;
                return Ok(false);
            }
            match picker.handle_key(key) {
                PathPickerEvent::Confirmed(p) => {
                    match validate_configured_download_directory(p.to_string_lossy().as_ref()) {
                        Ok(canonical) => {
                            if kind == super::screens::settings::SettingsPickerKind::RomsDir {
                                settings.download_dir = canonical.display().to_string();
                                settings.message = Some((
                                    "ROMs directory updated (press S to save)".to_string(),
                                    Color::Green,
                                ));
                            } else {
                                settings.save_dir = canonical.display().to_string();
                                settings.message = Some((
                                    "Save directory updated (press S to save)".to_string(),
                                    Color::Green,
                                ));
                            }
                            settings.path_picker = None;
                        }
                        Err(e) => {
                            settings.message =
                                Some((format!("Invalid ROMs directory: {e:#}"), Color::Red));
                        }
                    }
                }
                PathPickerEvent::None => {}
            }
            return Ok(false);
        }

        if settings.device_picker_open {
            match key.code {
                KeyCode::Esc => {
                    settings.device_picker_open = false;
                    settings.device_picker_loading = false;
                }
                KeyCode::Up | KeyCode::Char('k') => settings.device_previous(),
                KeyCode::Down | KeyCode::Char('j') => settings.device_next(),
                KeyCode::Enter => settings.confirm_device(),
                _ => {}
            }
            return Ok(false);
        }

        if settings.confirm.is_some() {
            match key.code {
                KeyCode::Enter => match settings.confirm.take().unwrap() {
                    super::screens::settings::SettingsConfirm::Reset => {
                        let _ = crate::config::reset_all_settings();
                        settings.message = Some((
                            "Settings deleted. Please restart romm-cli.".to_string(),
                            Color::Yellow,
                        ));
                    }
                    super::screens::settings::SettingsConfirm::ClearCache => {
                        match crate::core::cache::RomCache::clear_file() {
                            Ok(true) => {
                                self.rom_cache = crate::core::cache::RomCache::load();
                                settings.message =
                                    Some(("ROM cache cleared.".to_string(), Color::Green));
                            }
                            Ok(false) => {
                                settings.message = Some((
                                    "ROM cache file does not exist.".to_string(),
                                    Color::Yellow,
                                ));
                            }
                            Err(e) => {
                                settings.message =
                                    Some((format!("Failed to clear cache: {e}"), Color::Red));
                            }
                        }
                    }
                },
                KeyCode::Esc => {
                    settings.confirm = None;
                }
                _ => {}
            }
            return Ok(false);
        }

        if settings.editing {
            match key.code {
                KeyCode::Enter => {
                    let row = settings.selected_row();
                    settings.save_edit();
                    if row == SettingsRow::BaseUrl {
                        self.refresh_settings_server_version().await?;
                    }
                }
                KeyCode::Esc => settings.cancel_edit(),
                KeyCode::Backspace => settings.delete_char(),
                KeyCode::Left => settings.move_cursor_left(),
                KeyCode::Right => settings.move_cursor_right(),
                KeyCode::Char(c) => settings.add_char(c),
                _ => {}
            }
            return Ok(false);
        }

        match key.code {
            KeyCode::Up | KeyCode::Char('k') => settings.previous(),
            KeyCode::Down | KeyCode::Char('j') => settings.next(),
            KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => settings.next_tab(),
            KeyCode::Left | KeyCode::Char('h') | KeyCode::BackTab => settings.previous_tab(),
            KeyCode::Enter => {
                let row = settings.selected_row();
                if row == SettingsRow::Auth {
                    self.screen =
                        AppScreen::SetupWizard(Box::new(SetupWizard::new_auth_only(&self.config)));
                } else if row == SettingsRow::SyncDevice {
                    if !settings.save_sync_supported() {
                        settings.set_save_sync_unsupported_message();
                        return Ok(false);
                    }
                    settings.enter_edit();
                    let client = self.client.clone();
                    let tx = self.device_list_tx.clone();
                    tokio::spawn(async move {
                        let result = client
                            .call(&ListDevices)
                            .await
                            .map_err(|e| format!("{e:#}"));
                        let _ = tx.send(DeviceListDone { result });
                    });
                } else if row == SettingsRow::SyncNow {
                    if !settings.save_sync_supported() {
                        settings.set_save_sync_unsupported_message();
                        return Ok(false);
                    }
                    if settings.sync_inflight {
                        return Ok(false);
                    }
                    let Some(device_id) = settings.sync_device_id.clone() else {
                        settings.message =
                            Some(("Choose a Sync Device first".to_string(), Color::Yellow));
                        return Ok(false);
                    };
                    settings.sync_inflight = true;
                    settings.message =
                        Some(("Sync Saves Now running...".to_string(), Color::Yellow));
                    let client = self.client.clone();
                    let tx = self.sync_push_pull_tx.clone();
                    tokio::spawn(async move {
                        let result = client
                            .call(&TriggerPushPull { device_id })
                            .await
                            .map_err(|e| format!("{e:#}"));
                        let _ = tx.send(SyncPushPullDone { result });
                    });
                } else {
                    let toggle_https = row == SettingsRow::UseHttps;
                    settings.enter_edit();
                    if toggle_https {
                        self.refresh_settings_server_version().await?;
                    }
                }
            }
            KeyCode::Char('s' | 'S') => {
                // Save to disk (accept both cases; footer shows "S:")
                use crate::config::persist_user_config;
                let auth = auth_for_persist_merge(self.config.auth.clone());
                let cfg = Config {
                    base_url: settings.base_url.clone(),
                    download_dir: settings.download_dir.clone(),
                    use_https: settings.use_https,
                    auth,
                    extras_defaults: ExtrasDefaults {
                        include_related_roms: settings.extras_include_related_roms,
                        include_cover: settings.extras_include_cover,
                        include_manual: settings.extras_include_manual,
                    },
                    save_sync: SaveSyncConfig {
                        save_dir: Some(settings.save_dir.clone()),
                        device_id: settings.sync_device_id.clone(),
                    },
                };
                if let Err(e) = persist_user_config(&cfg) {
                    settings.message = Some((format!("Error saving: {e}"), Color::Red));
                } else {
                    settings.message = Some(("Saved to config.json".to_string(), Color::Green));
                    // Update app state
                    self.config.base_url = cfg.base_url.clone();
                    self.config.download_dir = cfg.download_dir.clone();
                    self.config.use_https = cfg.use_https;
                    self.config.extras_defaults = cfg.extras_defaults.clone();
                    self.config.save_sync = cfg.save_sync.clone();
                    // Re-create client to pick up new base URL
                    if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
                        self.client = new_client;
                    }
                }
            }
            KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
            KeyCode::Char('q') => return Ok(true),
            _ => {}
        }
        Ok(false)
    }

    // -- API Browse ---------------------------------------------------------

    fn handle_browse(&mut self, key: &KeyEvent) -> Result<bool> {
        use super::screens::browse::ViewMode;

        let browse = match &mut self.screen {
            AppScreen::Browse(b) => b,
            _ => return Ok(false),
        };
        match key.code {
            KeyCode::Up | KeyCode::Char('k') => browse.previous(),
            KeyCode::Down | KeyCode::Char('j') => browse.next(),
            KeyCode::Left | KeyCode::Char('h') if browse.view_mode == ViewMode::Endpoints => {
                browse.switch_view();
            }
            KeyCode::Right | KeyCode::Char('l') if browse.view_mode == ViewMode::Sections => {
                browse.switch_view();
            }
            KeyCode::Tab => browse.switch_view(),
            KeyCode::Enter => {
                if browse.view_mode == ViewMode::Endpoints {
                    if let Some(ep) = browse.get_selected_endpoint() {
                        self.screen = AppScreen::Execute(ExecuteScreen::new(ep.clone()));
                    }
                } else {
                    browse.switch_view();
                }
            }
            KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
            _ => {}
        }
        Ok(false)
    }

    // -- Execute endpoint ---------------------------------------------------

    async fn handle_execute(&mut self, key: &KeyEvent) -> Result<bool> {
        let execute = match &mut self.screen {
            AppScreen::Execute(e) => e,
            _ => return Ok(false),
        };
        match key.code {
            KeyCode::Tab => execute.next_field(),
            KeyCode::BackTab => execute.previous_field(),
            KeyCode::Char(c) => execute.add_char_to_focused(c),
            KeyCode::Backspace => execute.delete_char_from_focused(),
            KeyCode::Enter => {
                let endpoint = execute.endpoint.clone();
                let query = execute.get_query_params();
                let body = if endpoint.has_body && !execute.body_text.is_empty() {
                    Some(serde_json::from_str(&execute.body_text)?)
                } else {
                    None
                };
                let resolved_path =
                    match resolve_path_template(&endpoint.path, &execute.get_path_params()) {
                        Ok(p) => p,
                        Err(e) => {
                            self.screen = AppScreen::Result(ResultScreen::new(
                                serde_json::json!({ "error": format!("{e}") }),
                                None,
                                None,
                            ));
                            return Ok(false);
                        }
                    };
                match self
                    .client
                    .request_json(&endpoint.method, &resolved_path, &query, body)
                    .await
                {
                    Ok(result) => {
                        self.screen = AppScreen::Result(ResultScreen::new(
                            result,
                            Some(&endpoint.method),
                            Some(resolved_path.as_str()),
                        ));
                    }
                    Err(e) => {
                        self.screen = AppScreen::Result(ResultScreen::new(
                            serde_json::json!({ "error": format!("{e}") }),
                            None,
                            None,
                        ));
                    }
                }
            }
            KeyCode::Esc => {
                self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
            }
            _ => {}
        }
        Ok(false)
    }

    // -- Result view --------------------------------------------------------

    fn handle_result(&mut self, key: &KeyEvent) -> Result<bool> {
        use super::screens::result::ResultViewMode;

        let result = match &mut self.screen {
            AppScreen::Result(r) => r,
            _ => return Ok(false),
        };
        match key.code {
            KeyCode::Up | KeyCode::Char('k') => {
                if result.view_mode == ResultViewMode::Json {
                    result.scroll_up(1);
                } else {
                    result.table_previous();
                }
            }
            KeyCode::Down => {
                if result.view_mode == ResultViewMode::Json {
                    result.scroll_down(1);
                } else {
                    result.table_next();
                }
            }
            KeyCode::Char('j') if result.view_mode == ResultViewMode::Json => {
                result.scroll_down(1);
            }
            KeyCode::PageUp => {
                if result.view_mode == ResultViewMode::Table {
                    result.table_page_up();
                } else {
                    result.scroll_up(10);
                }
            }
            KeyCode::PageDown => {
                if result.view_mode == ResultViewMode::Table {
                    result.table_page_down();
                } else {
                    result.scroll_down(10);
                }
            }
            KeyCode::Char('t') if result.table_row_count > 0 => {
                result.switch_view_mode();
            }
            KeyCode::Enter
                if result.view_mode == ResultViewMode::Table && result.table_row_count > 0 =>
            {
                if let Some(item) = result.get_selected_item_value() {
                    let prev = std::mem::replace(
                        &mut self.screen,
                        AppScreen::MainMenu(MainMenuScreen::new()),
                    );
                    if let AppScreen::Result(rs) = prev {
                        self.screen = AppScreen::ResultDetail(ResultDetailScreen::new(rs, item));
                    }
                }
            }
            KeyCode::Esc => {
                result.clear_message();
                self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
            }
            KeyCode::Char('q') => return Ok(true),
            _ => {}
        }
        Ok(false)
    }

    // -- Result detail ------------------------------------------------------

    fn handle_result_detail(&mut self, key: &KeyEvent) -> Result<bool> {
        let detail = match &mut self.screen {
            AppScreen::ResultDetail(d) => d,
            _ => return Ok(false),
        };
        match key.code {
            KeyCode::Up | KeyCode::Char('k') => detail.scroll_up(1),
            KeyCode::Down | KeyCode::Char('j') => detail.scroll_down(1),
            KeyCode::PageUp => detail.scroll_up(10),
            KeyCode::PageDown => detail.scroll_down(10),
            KeyCode::Char('o') => detail.open_image_url(),
            KeyCode::Esc => {
                detail.clear_message();
                let prev =
                    std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
                if let AppScreen::ResultDetail(d) = prev {
                    self.screen = AppScreen::Result(d.parent);
                }
            }
            KeyCode::Char('q') => return Ok(true),
            _ => {}
        }
        Ok(false)
    }

    // -- Game detail --------------------------------------------------------

    fn handle_game_detail(&mut self, key: &KeyEvent) -> Result<bool> {
        use super::path_picker::PathPickerEvent;
        let detail = match &mut self.screen {
            AppScreen::GameDetail(d) => d,
            _ => return Ok(false),
        };

        if let Some(picker) = detail.save_upload_picker.as_mut() {
            if key.code == KeyCode::Esc {
                detail.save_upload_picker = None;
                detail.clear_message();
                return Ok(false);
            }
            match picker.handle_key(key) {
                PathPickerEvent::Confirmed(path) => {
                    let rom_id = detail.rom.id;
                    detail.save_upload_picker = None;
                    detail.message = Some("Uploading save...".into());
                    detail.message_clear_at = None;
                    let client = self.client.clone();
                    let tx = self.save_upload_tx.clone();
                    tokio::spawn(async move {
                        let result = client
                            .upload_save_file(rom_id, None, &path)
                            .await
                            .map(|_| ())
                            .map_err(|e| format!("{e:#}"));
                        let _ = tx.send(SaveUploadDone { rom_id, result });
                    });
                }
                PathPickerEvent::None => {}
            }
            return Ok(false);
        }

        // Acknowledge download completion on any key press
        // (check if there's a completed/errored download for this ROM)
        if !detail.download_completion_acknowledged {
            if let Ok(list) = detail.downloads.lock() {
                let has_completed = list.iter().any(|j| {
                    j.rom_id == detail.rom.id
                        && matches!(
                            j.status,
                            crate::core::download::DownloadStatus::Done
                                | crate::core::download::DownloadStatus::SkippedAlreadyExists
                                | crate::core::download::DownloadStatus::Cancelled
                                | crate::core::download::DownloadStatus::FinalizeFailed(_)
                                | crate::core::download::DownloadStatus::Error(_)
                        )
                });
                let is_still_downloading = list.iter().any(|j| {
                    j.rom_id == detail.rom.id
                        && matches!(j.status, crate::core::download::DownloadStatus::Downloading)
                });
                // Only acknowledge if there's a completion and no active download
                if has_completed && !is_still_downloading {
                    detail.download_completion_acknowledged = true;
                }
            }
        }

        let wants_extras = matches!(key.code, KeyCode::Char('e') | KeyCode::Char('E'))
            || (key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::SHIFT));
        if wants_extras {
            if !detail.has_any_extras() {
                detail.message = Some("No extras available for this ROM".to_string());
                detail.message_clear_at = Some(Instant::now() + Duration::from_secs(3));
                return Ok(false);
            }
            let prev =
                std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
            if let AppScreen::GameDetail(g) = prev {
                self.screen = AppScreen::ExtrasPicker(Box::new(ExtrasPickerScreen::new(
                    g,
                    &self.config.extras_defaults,
                )));
            }
            return Ok(false);
        }

        match key.code {
            KeyCode::Up | KeyCode::Char('k') => detail.save_selection_previous(),
            KeyCode::Down | KeyCode::Char('j') => detail.save_selection_next(),
            KeyCode::Char('u') => detail.open_save_upload_picker(),
            KeyCode::Char('D') => {
                let Some(save) = detail.selected_save().cloned() else {
                    detail.message = Some("No save selected".into());
                    detail.message_clear_at = Some(Instant::now() + Duration::from_secs(3));
                    return Ok(false);
                };
                let rom_id = detail.rom.id;
                let game_name = detail.rom.name.clone();
                let save_dir = resolved_save_dir(&self.config);
                detail.message = Some("Downloading save...".into());
                detail.message_clear_at = None;
                let client = self.client.clone();
                let tx = self.save_download_tx.clone();
                tokio::spawn(async move {
                    let result = async {
                        let bytes = client.download_save_content(save.id, None, None).await?;
                        let target_dir = save_dir.join(safe_path_segment(&game_name));
                        tokio::fs::create_dir_all(&target_dir).await?;
                        let filename = if save.file_name.trim().is_empty() {
                            format!("save-{}.sav", save.id)
                        } else {
                            save.file_name.clone()
                        };
                        let target = unique_save_path(&target_dir, &filename);
                        tokio::fs::write(&target, bytes).await?;
                        Ok::<PathBuf, anyhow::Error>(target)
                    }
                    .await
                    .map_err(|e| format!("{e:#}"));
                    let _ = tx.send(SaveDownloadDone { rom_id, result });
                });
            }
            // Only start a download once per detail view and avoid
            // stacking multiple concurrent downloads for the same ROM.
            KeyCode::Enter if !detail.has_started_download => {
                match self.downloads.start_download(
                    &detail.rom,
                    self.client.clone(),
                    Some(self.config.download_dir.as_str()),
                ) {
                    Ok(()) => {
                        detail.has_started_download = true;
                        if has_update_or_dlc_extras(&detail.rom, &detail.other_files) {
                            detail.message = Some(
                                "Updates/DLC available. Press e to download extras.".to_string(),
                            );
                            detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
                        }
                    }
                    Err(err) => {
                        detail.has_started_download = false;
                        detail.message = Some(format!(
                            "Download blocked: {err}. Fix ROMs directory in settings/setup."
                        ));
                    }
                }
            }
            KeyCode::Char('o') => detail.open_cover(),
            KeyCode::Char('m') => detail.toggle_technical(),
            KeyCode::Esc => {
                detail.clear_message();
                let prev =
                    std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
                if let AppScreen::GameDetail(g) = prev {
                    self.screen = match g.previous {
                        GameDetailPrevious::Library(l) => AppScreen::LibraryBrowse(*l),
                        GameDetailPrevious::Search(s) => AppScreen::Search(s),
                    };
                }
            }
            KeyCode::Char('q') => return Ok(true),
            _ => {}
        }
        Ok(false)
    }

    fn handle_extras_picker(&mut self, key: &KeyEvent) -> Result<bool> {
        let picker = match &mut self.screen {
            AppScreen::ExtrasPicker(p) => p,
            _ => return Ok(false),
        };
        picker.tick_message();

        match key.code {
            KeyCode::Esc => {
                let prev =
                    std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
                if let AppScreen::ExtrasPicker(p) = prev {
                    self.screen = AppScreen::GameDetail(p.previous);
                }
            }
            KeyCode::Up | KeyCode::Char('k') => picker.move_up(),
            KeyCode::Down | KeyCode::Char('j') => picker.move_down(),
            KeyCode::Char(' ') => picker.toggle_current(),
            KeyCode::Char('a') | KeyCode::Char('A') => picker.toggle_all(),
            KeyCode::Enter => {
                if picker.selected_count() == 0 {
                    picker.show_message(
                        "Select at least one item (Space to toggle)",
                        Duration::from_secs(2),
                    );
                    return Ok(false);
                }
                let targets =
                    match picker.build_selected_targets(Some(self.config.download_dir.as_str())) {
                        Ok(t) => t,
                        Err(e) => {
                            picker.show_message(format!("{e:#}"), Duration::from_secs(4));
                            return Ok(false);
                        }
                    };
                let rom = picker.rom.clone();
                let prev =
                    std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
                if let AppScreen::ExtrasPicker(p) = prev {
                    match self.downloads.start_extras_download(
                        &rom,
                        targets,
                        self.client.clone(),
                        Some(self.config.download_dir.as_str()),
                    ) {
                        Ok(()) => {
                            self.screen = AppScreen::GameDetail(p.previous);
                        }
                        Err(e) => {
                            let mut detail = *p.previous;
                            detail.message = Some(format!("Extras: {e:#}"));
                            detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
                            self.screen = AppScreen::GameDetail(Box::new(detail));
                        }
                    }
                }
            }
            KeyCode::Char('q') => return Ok(true),
            _ => {}
        }
        Ok(false)
    }

    // -- Setup Wizard -------------------------------------------------------

    async fn handle_setup_wizard(&mut self, key: &KeyEvent) -> Result<bool> {
        let wizard = match &mut self.screen {
            AppScreen::SetupWizard(w) => w,
            _ => return Ok(false),
        };

        if wizard.handle_key(key)? {
            // Esc pressed
            self.screen = AppScreen::Settings(SettingsScreen::new(
                &self.config,
                self.server_version.as_deref(),
                self.save_sync_compat.clone(),
            ));
            return Ok(false);
        }

        if wizard.testing {
            let result = wizard.try_connect_and_persist(self.client.verbose()).await;
            wizard.testing = false;
            match result {
                Ok(cfg) => {
                    let auth_ok = cfg.auth.is_some();
                    self.config = cfg;
                    if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
                        self.client = new_client;
                    }
                    let mut settings = SettingsScreen::new(
                        &self.config,
                        self.server_version.as_deref(),
                        self.save_sync_compat.clone(),
                    );
                    if auth_ok {
                        settings.message = Some((
                            "Authentication updated successfully".to_string(),
                            Color::Green,
                        ));
                    } else {
                        settings.message = Some((
                            "Saved configuration but credentials could not be loaded from the OS keyring (see logs)."
                                .to_string(),
                            Color::Yellow,
                        ));
                    }
                    self.screen = AppScreen::Settings(settings);
                }
                Err(e) => {
                    wizard.error = Some(format!("{e:#}"));
                }
            }
        }
        Ok(false)
    }

    // -----------------------------------------------------------------------
    // Render
    // -----------------------------------------------------------------------

    fn render(&mut self, f: &mut ratatui::Frame) {
        let area = f.area();
        if let Some(ref splash) = self.startup_splash {
            connected_splash::render(f, area, splash);
            return;
        }
        match &mut self.screen {
            AppScreen::MainMenu(menu) => menu.render(f, area),
            AppScreen::LibraryBrowse(lib) => {
                lib.render(f, area);
                if let Some((x, y)) = lib.upload_prompt_cursor(area) {
                    f.set_cursor_position((x, y));
                }
            }
            AppScreen::Search(search) => {
                search.render(f, area);
                if let Some((x, y)) = search.cursor_position(area) {
                    f.set_cursor_position((x, y));
                }
            }
            AppScreen::Settings(settings) => {
                settings.render(f, area);
                if let Some((x, y)) = settings.cursor_position(area) {
                    f.set_cursor_position((x, y));
                }
            }
            AppScreen::Browse(browse) => browse.render(f, area),
            AppScreen::Execute(execute) => {
                execute.render(f, area);
                if let Some((x, y)) = execute.cursor_position(area) {
                    f.set_cursor_position((x, y));
                }
            }
            AppScreen::Result(result) => result.render(f, area),
            AppScreen::ResultDetail(detail) => detail.render(f, area),
            AppScreen::GameDetail(detail) => detail.render(f, area),
            AppScreen::ExtrasPicker(picker) => picker.render(f, area),
            AppScreen::Download(d) => d.render(f, area),
            AppScreen::SetupWizard(wizard) => {
                wizard.render(f, area);
                if let Some((x, y)) = wizard.cursor_pos(area) {
                    f.set_cursor_position((x, y));
                }
            }
        }

        if self.show_keyboard_help {
            keyboard_help::render_keyboard_help(f, area);
        }

        if let Some(prompt) = &self.startup_update_prompt {
            let popup_w = 44;
            let popup_h = 10;
            let popup_area = ratatui::layout::Rect {
                x: area.width.saturating_sub(popup_w) / 2,
                y: area.height.saturating_sub(popup_h) / 2,
                width: popup_w.min(area.width),
                height: popup_h.min(area.height),
            };
            f.render_widget(ratatui::widgets::Clear, popup_area);

            let block = ratatui::widgets::Block::default()
                .title(" Update Available ")
                .title_alignment(ratatui::layout::Alignment::Center)
                .borders(ratatui::widgets::Borders::ALL)
                .border_style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan));

            if prompt.updating {
                let text = vec![
                    ratatui::text::Line::from(""),
                    ratatui::text::Line::from("Downloading and installing...")
                        .alignment(ratatui::layout::Alignment::Center),
                    ratatui::text::Line::from("Please wait.")
                        .alignment(ratatui::layout::Alignment::Center),
                    ratatui::text::Line::from(""),
                    ratatui::text::Line::from("This may take a few moments.")
                        .alignment(ratatui::layout::Alignment::Center)
                        .style(
                            ratatui::style::Style::default().fg(ratatui::style::Color::DarkGray),
                        ),
                ];
                let paragraph = ratatui::widgets::Paragraph::new(text).block(block);
                f.render_widget(paragraph, popup_area);
            } else {
                let text = vec![
                    ratatui::text::Line::from(vec![
                        ratatui::text::Span::raw("Current: "),
                        ratatui::text::Span::styled(
                            &prompt.status.current_version,
                            ratatui::style::Style::default().fg(ratatui::style::Color::DarkGray),
                        ),
                    ])
                    .alignment(ratatui::layout::Alignment::Center),
                    ratatui::text::Line::from(vec![
                        ratatui::text::Span::raw("Latest:  "),
                        ratatui::text::Span::styled(
                            &prompt.status.latest_version,
                            ratatui::style::Style::default()
                                .fg(ratatui::style::Color::Green)
                                .add_modifier(ratatui::style::Modifier::BOLD),
                        ),
                    ])
                    .alignment(ratatui::layout::Alignment::Center),
                    ratatui::text::Line::from(""),
                    ratatui::text::Line::from("Would you like to update?")
                        .alignment(ratatui::layout::Alignment::Center),
                    ratatui::text::Line::from(""),
                    ratatui::text::Line::from(vec![
                        ratatui::text::Span::styled(
                            "Y",
                            ratatui::style::Style::default().fg(ratatui::style::Color::Yellow),
                        ),
                        ratatui::text::Span::raw(": Yes (update)  "),
                        ratatui::text::Span::styled(
                            "N",
                            ratatui::style::Style::default().fg(ratatui::style::Color::Yellow),
                        ),
                        ratatui::text::Span::raw(": No (skip)"),
                    ])
                    .alignment(ratatui::layout::Alignment::Center),
                    ratatui::text::Line::from(vec![
                        ratatui::text::Span::styled(
                            "C",
                            ratatui::style::Style::default().fg(ratatui::style::Color::Yellow),
                        ),
                        ratatui::text::Span::raw(": View changelog"),
                    ])
                    .alignment(ratatui::layout::Alignment::Center),
                ];
                let paragraph = ratatui::widgets::Paragraph::new(text).block(block);
                f.render_widget(paragraph, popup_area);
            }
        }

        if let Some(ref err) = self.global_error {
            let popup_area = ratatui::layout::Rect {
                x: area.width.saturating_sub(60) / 2,
                y: area.height.saturating_sub(10) / 2,
                width: 60.min(area.width),
                height: 10.min(area.height),
            };
            f.render_widget(ratatui::widgets::Clear, popup_area);
            let block = ratatui::widgets::Block::default()
                .title("Error")
                .borders(ratatui::widgets::Borders::ALL)
                .style(ratatui::style::Style::default().fg(ratatui::style::Color::Red));
            let text = format!("{}\n\nPress Esc to dismiss", err);
            let paragraph = ratatui::widgets::Paragraph::new(text)
                .block(block)
                .wrap(ratatui::widgets::Wrap { trim: true });
            f.render_widget(paragraph, popup_area);
        }

        if let Some(ref notice) = self.global_notice {
            let popup_area = ratatui::layout::Rect {
                x: area.width.saturating_sub(60) / 2,
                y: area.height.saturating_sub(10) / 2,
                width: 60.min(area.width),
                height: 10.min(area.height),
            };
            f.render_widget(ratatui::widgets::Clear, popup_area);
            let block = ratatui::widgets::Block::default()
                .title("Notice")
                .borders(ratatui::widgets::Borders::ALL)
                .style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan));
            let text = format!("{notice}\n\nPress Esc to dismiss");
            let paragraph = ratatui::widgets::Paragraph::new(text)
                .block(block)
                .wrap(ratatui::widgets::Wrap { trim: true });
            f.render_widget(paragraph, popup_area);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{Config, ExtrasDefaults};
    use crate::openapi::EndpointRegistry;
    use crate::tui::screens::library_browse::LibraryBrowseScreen;
    use crate::tui::screens::{GameDetailPrevious, GameDetailScreen, SearchScreen};
    use crate::types::Platform;
    use crate::update::UpdateStatus;
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
    use serde_json::json;

    fn platform(id: u64, name: &str, rom_count: u64) -> Platform {
        serde_json::from_value(json!({
            "id": id,
            "slug": format!("p{id}"),
            "fs_slug": format!("p{id}"),
            "rom_count": rom_count,
            "name": name,
            "igdb_slug": null,
            "moby_slug": null,
            "hltb_slug": null,
            "custom_name": null,
            "igdb_id": null,
            "sgdb_id": null,
            "moby_id": null,
            "launchbox_id": null,
            "ss_id": null,
            "ra_id": null,
            "hasheous_id": null,
            "tgdb_id": null,
            "flashpoint_id": null,
            "category": null,
            "generation": null,
            "family_name": null,
            "family_slug": null,
            "url": null,
            "url_logo": null,
            "firmware": [],
            "aspect_ratio": null,
            "created_at": "",
            "updated_at": "",
            "fs_size_bytes": 0,
            "is_unidentified": false,
            "is_identified": true,
            "missing_from_fs": false,
            "display_name": null
        }))
        .expect("valid platform fixture")
    }

    fn app_with_library(platforms: Vec<Platform>) -> App {
        let config = Config {
            base_url: "http://127.0.0.1:9".into(),
            download_dir: "/tmp".into(),
            use_https: false,
            auth: None,
            extras_defaults: ExtrasDefaults::default(),
            save_sync: Default::default(),
        };
        let client = RommClient::new(&config, false).expect("client");
        let mut app = App::new(
            client,
            config,
            EndpointRegistry::default(),
            None,
            None,
            None,
        );
        app.screen = AppScreen::LibraryBrowse(LibraryBrowseScreen::new(platforms, vec![]));
        app
    }

    fn update_status_fixture() -> UpdateStatus {
        UpdateStatus {
            current_version: "0.25.0".into(),
            latest_version: "0.26.0".into(),
            release_tag: "v0.26.0".into(),
            should_update: true,
            release_url: "https://github.com/patricksmill/romm-cli/releases/tag/v0.26.0".into(),
            changelog_url: "https://github.com/patricksmill/romm-cli/blob/main/CHANGELOG.md".into(),
        }
    }

    fn rom_fixture() -> crate::types::Rom {
        serde_json::from_value(json!({
            "id": 10,
            "platform_id": 1,
            "platform_slug": null,
            "platform_fs_slug": null,
            "platform_custom_name": null,
            "platform_display_name": null,
            "fs_name": "sample.zip",
            "fs_name_no_tags": "sample",
            "fs_name_no_ext": "sample",
            "fs_extension": "zip",
            "fs_path": "/sample.zip",
            "fs_size_bytes": 100,
            "name": "Sample",
            "slug": null,
            "summary": null,
            "path_cover_small": null,
            "path_cover_large": null,
            "url_cover": null,
            "has_manual": false,
            "path_manual": null,
            "url_manual": null,
            "is_unidentified": false,
            "is_identified": true
        }))
        .expect("valid rom fixture")
    }

    fn empty_rom_list_with_total(total: u64) -> RomList {
        RomList {
            items: vec![],
            total,
            limit: 50,
            offset: 0,
        }
    }

    #[tokio::test]
    async fn list_move_to_zero_rom_selection_does_not_queue_deferred_load() {
        let mut app = app_with_library(vec![platform(1, "HasRoms", 5), platform(2, "Empty", 0)]);

        assert!(!app
            .handle_key_event(&KeyEvent::new(KeyCode::Down, KeyModifiers::empty()))
            .await
            .expect("key handled"));
        assert!(
            app.deferred_load_roms.is_none(),
            "selection move to zero-rom platform should not queue deferred ROM load"
        );
    }

    #[test]
    fn ctrl_c_is_treated_as_force_quit() {
        let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
        assert!(App::is_force_quit_key(&ctrl_c));

        let plain_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty());
        assert!(!App::is_force_quit_key(&plain_c));
    }

    #[test]
    fn primary_rom_load_stale_gen_is_ignored() {
        assert!(!super::primary_rom_load_result_is_current(1, 2));
        assert!(super::primary_rom_load_result_is_current(3, 3));
    }

    #[tokio::test]
    async fn game_detail_esc_returns_to_previous_library_screen() {
        let mut app = app_with_library(vec![platform(1, "NES", 1)]);
        let previous = LibraryBrowseScreen::new(vec![platform(1, "NES", 1)], vec![]);
        let detail = GameDetailScreen::new(
            rom_fixture(),
            Vec::new(),
            GameDetailPrevious::Library(Box::new(previous)),
            app.downloads.shared(),
        );
        app.screen = AppScreen::GameDetail(Box::new(detail));

        let quit = app
            .handle_key_event(&KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()))
            .await
            .expect("esc handled");
        assert!(!quit);
        assert!(matches!(app.screen, AppScreen::LibraryBrowse(_)));
    }

    #[tokio::test]
    async fn startup_update_prompt_skip_closes_prompt() {
        let config = Config {
            base_url: "http://127.0.0.1:9".into(),
            download_dir: "/tmp".into(),
            use_https: false,
            auth: None,
            extras_defaults: ExtrasDefaults::default(),
            save_sync: Default::default(),
        };
        let client = RommClient::new(&config, false).expect("client");
        let mut app = App::new(
            client,
            config,
            EndpointRegistry::default(),
            None,
            None,
            Some(update_status_fixture()),
        );
        assert!(app.startup_update_prompt.is_some());
        let quit = app
            .handle_key_event(&KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()))
            .await
            .expect("esc handled");
        assert!(!quit);
        assert!(app.startup_update_prompt.is_none());
    }

    #[test]
    fn search_batch_updates_results_without_stopping_loading() {
        let config = Config {
            base_url: "http://127.0.0.1:9".into(),
            download_dir: "/tmp".into(),
            use_https: false,
            auth: None,
            extras_defaults: ExtrasDefaults::default(),
            save_sync: Default::default(),
        };
        let client = RommClient::new(&config, false).expect("client");
        let mut app = App::new(
            client,
            config,
            EndpointRegistry::default(),
            None,
            None,
            None,
        );
        let mut search = SearchScreen::new();
        search.loading = true;
        app.screen = AppScreen::Search(search);

        app.search_load_tx
            .send(SearchLoadDone {
                query: "zelda".to_string(),
                event: SearchLoadEvent::Batch(empty_rom_list_with_total(120)),
            })
            .expect("send batch");

        app.poll_search_load_results();

        match &app.screen {
            AppScreen::Search(search) => {
                assert!(search.loading, "loading should continue after batch");
                assert!(search.results.is_some(), "batch should populate results");
                assert_eq!(search.last_searched_query.as_deref(), Some("zelda"));
            }
            _ => panic!("expected search screen"),
        }
    }

    #[test]
    fn search_complete_event_stops_loading() {
        let config = Config {
            base_url: "http://127.0.0.1:9".into(),
            download_dir: "/tmp".into(),
            use_https: false,
            auth: None,
            extras_defaults: ExtrasDefaults::default(),
            save_sync: Default::default(),
        };
        let client = RommClient::new(&config, false).expect("client");
        let mut app = App::new(
            client,
            config,
            EndpointRegistry::default(),
            None,
            None,
            None,
        );
        let mut search = SearchScreen::new();
        search.loading = true;
        app.screen = AppScreen::Search(search);

        app.search_load_tx
            .send(SearchLoadDone {
                query: "zelda".to_string(),
                event: SearchLoadEvent::Complete,
            })
            .expect("send complete");

        app.poll_search_load_results();

        match &app.screen {
            AppScreen::Search(search) => {
                assert!(!search.loading, "loading should stop after completion");
            }
            _ => panic!("expected search screen"),
        }
    }

    #[tokio::test]
    async fn pressing_e_with_no_extras_shows_toast_not_picker() {
        let mut app = app_with_library(vec![platform(1, "NES", 1)]);
        let previous = LibraryBrowseScreen::new(vec![platform(1, "NES", 1)], vec![]);
        let detail = GameDetailScreen::new(
            rom_fixture(),
            Vec::new(),
            GameDetailPrevious::Library(Box::new(previous)),
            app.downloads.shared(),
        );
        app.screen = AppScreen::GameDetail(Box::new(detail));

        app.handle_key_event(&KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()))
            .await
            .expect("handled");

        match &app.screen {
            AppScreen::GameDetail(d) => {
                assert!(
                    d.message
                        .as_deref()
                        .is_some_and(|m| m.contains("No extras")),
                    "expected toast, got {:?}",
                    d.message
                );
            }
            _ => panic!("expected game detail"),
        }
    }

    #[tokio::test]
    async fn pressing_e_with_extras_opens_picker() {
        let mut rom = rom_fixture();
        rom.url_cover = Some("https://example.com/c.png".into());
        let mut app = app_with_library(vec![platform(1, "NES", 1)]);
        let previous = LibraryBrowseScreen::new(vec![platform(1, "NES", 1)], vec![]);
        let detail = GameDetailScreen::new(
            rom,
            Vec::new(),
            GameDetailPrevious::Library(Box::new(previous)),
            app.downloads.shared(),
        );
        app.screen = AppScreen::GameDetail(Box::new(detail));

        app.handle_key_event(&KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()))
            .await
            .expect("handled");

        assert!(
            matches!(app.screen, AppScreen::ExtrasPicker(_)),
            "expected extras picker"
        );
    }
}
