//! egui-based debug UI for the asset viewer.
//!
//! Provides panels for:
//! - File browser (list ISO / extracted game files, filter by name)
//! - Texture inspector (preview + format info)
//! - File info (size, detected format for non-texture files)
//! - RE notes (track discoveries during reverse engineering)

use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};

use crate::iso_loader::{
    AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview,
    MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir,
    RequestOpenIso, RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary,
    ShipBrowser, SkyboxPreview, TextPreview, TexturePreview,
    VideoPreview, VoiceLibrary, IsoLoaderSystemSet,
};
use crate::ViewerState;
use sylpheed_formats::SubLang;

pub struct ViewerUiPlugin;

impl Plugin for ViewerUiPlugin {
    fn build(&self, app: &mut App) {
        app.insert_resource(FileBrowserState::default());
        // Run after the iso_loader chain so we see the frame's final state.
        app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
        app.add_systems(Update, draw_game_data_ui.after(IsoLoaderSystemSet));
        app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet));
    }
}

/// State for the file browser panel.
#[derive(Resource, Default)]
pub struct FileBrowserState {
    pub files: Vec<String>,
    pub selected: Option<usize>,
    pub filter: String,
    /// True from the moment a file is clicked until its content is applied —
    /// drives the "Loading…" indicator so the click registers immediately.
    pub loading: bool,
}

// ── Directory tree ────────────────────────────────────────────────────────────

/// One directory node in the ephemeral file tree. Rebuilt from the flat path
/// list each frame; leaves carry their original index into `browser.files` so
/// selection stays stable regardless of tree shape or filtering.
#[derive(Default)]
struct TreeDir {
    /// Child directories, keyed by name (`BTreeMap` = stable alphabetical order).
    dirs: std::collections::BTreeMap<String, TreeDir>,
    /// Leaf files: (display name, original index into `browser.files`).
    files: Vec<(String, usize)>,
}

/// True for an IPFB data segment (`foo.p00`, `foo.p01`, …). These are the pack's
/// data body, not standalone files — the `.pak` index is what the user opens, so
/// they're hidden from the tree (their content loads when the `.pak` is opened).
fn is_pak_segment(leaf: &str) -> bool {
    matches!(leaf.rsplit_once('.'), Some((_, ext))
        if ext.len() == 3
            && ext.as_bytes()[0] == b'p'
            && ext.as_bytes()[1].is_ascii_digit()
            && ext.as_bytes()[2].is_ascii_digit())
}

/// Split a full path into (directory components, leaf name).
fn split_for_tree(full: &str) -> (Vec<String>, String) {
    let mut comps: Vec<String> = full.split('/').map(str::to_string).collect();
    let leaf = comps.pop().unwrap_or_default();
    (comps, leaf)
}

/// Build the directory tree, keeping only leaves whose full path contains
/// `filter` (case-insensitive; empty filter keeps everything). `.pNN` data
/// segments are skipped — only their `.pak` index is shown.
fn build_tree(files: &[String], filter: &str) -> TreeDir {
    let filter = filter.to_lowercase();
    let mut root = TreeDir::default();
    for (idx, full) in files.iter().enumerate() {
        if !filter.is_empty() && !full.to_lowercase().contains(&filter) {
            continue;
        }
        let (dirs, leaf) = split_for_tree(full);
        if is_pak_segment(&leaf) {
            continue;
        }
        let mut node = &mut root;
        for comp in dirs {
            node = node.dirs.entry(comp).or_default();
        }
        node.files.push((leaf, idx));
    }
    root
}

/// Render a directory node recursively: sub-directories (collapsible) first,
/// then leaf files as selectable labels.
#[allow(clippy::too_many_arguments)]
fn render_dir(
    ui: &mut egui::Ui,
    node: &TreeDir,
    force_open: bool,
    selected: &mut Option<usize>,
    loading: &mut bool,
    events: &mut EventWriter<FileSelected>,
    files: &[String],
) {
    for (name, child) in &node.dirs {
        egui::CollapsingHeader::new(format!("📁 {name}"))
            .id_salt(name)
            .default_open(force_open)
            .show(ui, |ui| {
                render_dir(ui, child, force_open, selected, loading, events, files);
            });
    }
    for (leaf, idx) in &node.files {
        let is_selected = *selected == Some(*idx);
        if ui
            .add(egui::SelectableLabel::new(is_selected, leaf.as_str()))
            .clicked()
        {
            *selected = Some(*idx);
            *loading = true; // show the spinner until the content is applied
            events.send(FileSelected(files[*idx].clone()));
        }
    }
}

/// Bundled event writers for the UI, to keep `draw_viewer_ui` under Bevy's
/// 16-parameter system limit.
#[derive(bevy::ecs::system::SystemParam)]
struct UiEvents<'w> {
    open_iso: EventWriter<'w, RequestOpenIso>,
    open_dir: EventWriter<'w, RequestOpenDir>,
    file_selected: EventWriter<'w, FileSelected>,
    subtitles: EventWriter<'w, RequestSubtitles>,
    audio: EventWriter<'w, RequestAudio>,
    voice_lib: EventWriter<'w, RequestVoiceLibrary>,
    game_data: EventWriter<'w, RequestGameData>,
    ships: EventWriter<'w, RequestShipCatalog>,
}

fn draw_viewer_ui(
    mut contexts: EguiContexts,
    mut viewer: ResMut<ViewerState>,
    mut browser: ResMut<FileBrowserState>,
    iso_state: Res<IsoState>,
    preview: Res<TexturePreview>,
    text_preview: Res<TextPreview>,
    mut pak_view: ResMut<PakView>,
    skybox: Res<SkyboxPreview>,
    model: Res<ModelPreview>,
    mut video: ResMut<VideoPreview>,
    file_info: Res<FileInfo>,
    mut subtitles: ResMut<MovieSubtitles>,
    mut movie_voice: ResMut<MovieVoice>,
    mut audio: ResMut<AudioPreview>,
    mut voice_lib: ResMut<VoiceLibrary>,
    mut events: UiEvents,
) {
    let ctx = contexts.ctx_mut();

    // ── Menu bar ──────────────────────────────────────────────────────────
    egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
        egui::menu::bar(ui, |ui| {
            ui.menu_button("File", |ui| {
                #[cfg(not(target_arch = "wasm32"))]
                {
                    if ui.button("Open ISO disc image…").clicked() {
                        events.open_iso.send_default();
                        ui.close_menu();
                    }
                    if ui.button("Open extracted folder…").clicked() {
                        events.open_dir.send_default();
                        ui.close_menu();
                    }
                    ui.separator();
                    if ui.button("Quit").clicked() {
                        std::process::exit(0);
                    }
                }
                #[cfg(target_arch = "wasm32")]
                ui.colored_label(
                    egui::Color32::GRAY,
                    "File loading not available in browser",
                );
            });

            ui.menu_button("View", |ui| {
                if ui.button("🎙 Voice Lines…").clicked() {
                    voice_lib.open = true;
                    if !voice_lib.loaded && !voice_lib.loading {
                        voice_lib.loading = true;
                        events.voice_lib.send_default();
                    }
                    ui.close_menu();
                }
                if ui.button("🗃 Game Data…").clicked() {
                    // The Game Data window (its own system) reads this event to
                    // open + lazily decode the tables.
                    events.game_data.send_default();
                    ui.close_menu();
                }
                if ui.button("🚀 Ships…").clicked() {
                    // The Ships window (its own system) reads this event to open +
                    // lazily scan the stage containers for assemblable ships.
                    events.ships.send_default();
                    ui.close_menu();
                }
            });

            ui.menu_button("Help", |ui| {
                if ui.button("Controls…").clicked() {
                    // TODO: controls popup
                }
                if ui.button("About").clicked() {
                    // TODO: about dialog
                }
            });
        });
    });

    // ── Standalone voice-line browser (floating window) ───────────────────
    if voice_lib.open {
        let mut open = true;
        egui::Window::new("🎙 Voice Lines")
            .default_width(360.0)
            .default_height(480.0)
            .open(&mut open)
            .show(ctx, |ui| {
                if voice_lib.loading {
                    ui.horizontal(|ui| {
                        ui.spinner();
                        ui.label("Reading sounds.tbl…");
                    });
                    ctx.request_repaint();
                } else if !voice_lib.loaded {
                    ui.label("Open a game source first.");
                } else {
                    ui.horizontal(|ui| {
                        ui.label("Filter:");
                        ui.text_edit_singleline(&mut voice_lib.filter);
                    });
                    let f = voice_lib.filter.to_lowercase();
                    // Group the thousands of entries as directory → speaker so the
                    // list is navigable (e.g. browse `Voice` by character to find a
                    // cutscene's radio line). `name` is `<lang>\<dir>\<file>.slb`.
                    use std::collections::BTreeMap;
                    let mut groups: BTreeMap<&str, BTreeMap<&str, Vec<&sylpheed_formats::slb::VoiceClip>>> =
                        BTreeMap::new();
                    for c in &voice_lib.clips {
                        if !f.is_empty() && !c.name.to_lowercase().contains(&f) {
                            continue;
                        }
                        let dir = c.name.rsplit('\\').nth(1).unwrap_or("?");
                        groups
                            .entry(dir)
                            .or_default()
                            .entry(c.speaker.as_str())
                            .or_default()
                            .push(c);
                    }
                    let shown: usize = groups.values().flat_map(|s| s.values()).map(Vec::len).sum();
                    ui.label(
                        egui::RichText::new(format!("{shown} / {} clips", voice_lib.clips.len()))
                            .weak()
                            .small(),
                    );
                    ui.separator();
                    let filtering = !f.is_empty();
                    egui::ScrollArea::vertical().show(ui, |ui| {
                        for (dir, speakers) in &groups {
                            let dtotal: usize = speakers.values().map(Vec::len).sum();
                            egui::CollapsingHeader::new(format!("📁 {dir}  ({dtotal})"))
                                .id_salt(("vdir", *dir))
                                .default_open(filtering)
                                .show(ui, |ui| {
                                    for (speaker, clips) in speakers {
                                        egui::CollapsingHeader::new(format!(
                                            "{speaker}  ({})",
                                            clips.len()
                                        ))
                                        .id_salt(("vspk", *dir, *speaker))
                                        .default_open(filtering || clips.len() <= 6)
                                        .show(ui, |ui| {
                                            for c in clips {
                                                ui.horizontal(|ui| {
                                                    if ui
                                                        .button("▶")
                                                        .on_hover_text(&c.name)
                                                        .clicked()
                                                    {
                                                        audio.generation =
                                                            audio.generation.wrapping_add(1);
                                                        audio.loading = true;
                                                        audio.active = true;
                                                        audio.name = c.display.clone();
                                                        events.audio.send(RequestAudio {
                                                            clip: c.name.clone(),
                                                            display: c.display.clone(),
                                                            movie: None,
                                                            generation: audio.generation,
                                                        });
                                                    }
                                                    ui.label(&c.display);
                                                });
                                            }
                                        });
                                    }
                                });
                        }
                    });
                }
            });
        voice_lib.open = open;
    }

    // ── Left panel: file browser ──────────────────────────────────────────
    egui::SidePanel::left("file_browser")
        .resizable(true)
        .default_width(280.0)
        .show(ctx, |ui| {
            ui.heading("Game Files");
            ui.separator();

            // Filter input
            ui.horizontal(|ui| {
                ui.label("Filter:");
                ui.text_edit_singleline(&mut browser.filter);
            });
            ui.separator();

            // File list
            egui::ScrollArea::vertical().show(ui, |ui| {
                if iso_state.loading {
                    ui.spinner();
                    ui.label("Loading…");
                } else if let Some(err) = &iso_state.error {
                    ui.colored_label(
                        egui::Color32::RED,
                        format!("⚠ Error:\n{}", err),
                    );
                } else if browser.files.is_empty() {
                    ui.colored_label(
                        egui::Color32::YELLOW,
                        "No files loaded.\nUse File → Open ISO disc image\n\
                         or File → Open extracted folder.",
                    );
                } else {
                    // Build a directory tree from the flat path list. Cheap to
                    // rebuild every frame (a few hundred short paths); cloning
                    // `files` first sidesteps borrowing `browser` immutably and
                    // mutably (selected) at the same time.
                    let files = browser.files.clone();
                    let tree = build_tree(&files, &browser.filter);
                    let force_open = !browser.filter.is_empty();
                    let FileBrowserState {
                        selected, loading, ..
                    } = &mut *browser;
                    render_dir(
                        ui,
                        &tree,
                        force_open,
                        selected,
                        loading,
                        &mut events.file_selected,
                        &files,
                    );
                }
            });
        });

    // ── Bottom panel: status bar ──────────────────────────────────────────
    egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
        // `horizontal_wrapped` so the bar wraps instead of overflowing the
        // window on narrow widths; the controls hint is right-aligned and only
        // the source's final path component is shown (full path on hover).
        ui.horizontal_wrapped(|ui| {
            if let Some(label) = &iso_state.source_label {
                let short = label.rsplit(['/', '\\']).next().unwrap_or(label);
                ui.label(short).on_hover_text(label);
                ui.separator();
            }
            ui.label(format!("{} files", browser.files.len()));
            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                ui.label("LMB orbit · RMB pan · scroll zoom · R reset");
            });
        });
    });

    // ── Central area ──────────────────────────────────────────────────────
    if audio.active {
        // Standalone audio player takes over the central panel.
        egui::CentralPanel::default().show(ctx, |ui| {
            ctx.request_repaint();
            draw_audio_player(ui, &mut audio);
        });
    } else if browser.selected.is_some() && model.active && !browser.loading {
        // 3D model: draw the central panel TRANSPARENT so the real Bevy scene
        // (mesh + orbit camera) shows through, with just an info overlay on top.
        egui::CentralPanel::default()
            .frame(egui::Frame::none())
            .show(ctx, |ui| {
                egui::Frame::none()
                    .fill(egui::Color32::from_black_alpha(160))
                    .inner_margin(egui::Margin::same(8.0))
                    .rounding(egui::Rounding::same(4.0))
                    .show(ui, |ui| {
                        ui.heading(&model.name);
                        ui.label(&model.info);
                        ui.colored_label(
                            egui::Color32::from_gray(160),
                            "3D preview · LMB orbit · RMB pan · scroll zoom · R reset",
                        );
                        ui.colored_label(
                            egui::Color32::from_gray(140),
                            "position + normal + UV from the XBG7 vertex declaration",
                        );
                    });
            });
    } else if browser.selected.is_some() {
        egui::CentralPanel::default().show(ctx, |ui| {
            if browser.loading {
                // A file was just clicked — show immediate feedback while the
                // background read + decode runs, instead of the previous item.
                // Keep repainting so the spinner animates even if the app is in a
                // reactive (idle) winit mode while the worker thread churns.
                ctx.request_repaint();
                let name = browser
                    .selected
                    .and_then(|i| browser.files.get(i))
                    .map(|p| p.rsplit('/').next().unwrap_or(p))
                    .unwrap_or("");
                ui.centered_and_justified(|ui| {
                    ui.horizontal(|ui| {
                        ui.spinner();
                        ui.label(format!("Loading {name}…"));
                    });
                });
            } else if video.active {
                let mut lang_changed = false;
                let mut play_voice_solo = false;
                draw_video_player(
                    ui,
                    &mut video,
                    &mut subtitles,
                    &mut movie_voice,
                    &mut lang_changed,
                    &mut play_voice_solo,
                );
                if play_voice_solo {
                    if let Some(movie) = movie_voice.movie.clone() {
                        video.playing = false; // pause the video; audio takes over
                        audio.generation = audio.generation.wrapping_add(1);
                        audio.loading = true;
                        audio.active = true; // show the panel immediately (spinner)
                        audio.name = format!("VOICE_{movie}");
                        events.audio.send(RequestAudio {
                            clip: String::new(),
                            display: format!("VOICE_{movie}"),
                            movie: Some((movie.clone(), movie_voice.lang)),
                            generation: audio.generation,
                        });
                    }
                }
                if lang_changed {
                    if let Some(movie) = subtitles.movie.clone() {
                        subtitles.generation = subtitles.generation.wrapping_add(1);
                        subtitles.cues.clear();
                        subtitles.loading = true;
                        events.subtitles.send(RequestSubtitles {
                            movie,
                            lang: subtitles.lang,
                            generation: subtitles.generation,
                        });
                    }
                }
            } else if !skybox.faces.is_empty() {
                draw_skybox_grid(ui, &skybox);
            } else if pak_view.loaded {
                draw_pak_browser(ui, &mut pak_view);
            } else if let Some(text) = &text_preview.content {
                // ── Plain-text viewer ──
                ui.horizontal(|ui| {
                    ui.heading(&file_info.name);
                    ui.separator();
                    ui.label(&text_preview.encoding);
                    ui.separator();
                    ui.checkbox(&mut viewer.text_wrap, "Wrap");
                    if text_preview.truncated {
                        ui.separator();
                        ui.colored_label(egui::Color32::YELLOW, "(truncated)");
                    }
                });
                ui.separator();

                egui::ScrollArea::both().show(ui, |ui| {
                    // Read-only: `&str` is a non-editable TextBuffer, and
                    // `.interactive(false)` keeps it selectable/copyable.
                    let mut buf = text.as_str();
                    let mut edit = egui::TextEdit::multiline(&mut buf)
                        .font(egui::TextStyle::Monospace)
                        .code_editor()
                        .desired_width(f32::INFINITY)
                        .interactive(false);
                    if !viewer.text_wrap {
                        // Don't wrap: let ScrollArea scroll horizontally.
                        edit = edit.clip_text(false);
                    }
                    ui.add(edit);
                });
            } else if let Some(egui_id) = preview.egui_id {
                // Texture preview
                ui.heading(&file_info.name);
                ui.label(&preview.format_info);
                if !preview.format_info.is_empty() && preview.format_info.contains("failed") {
                    ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
                }
                ui.separator();

                // Scale to fit, preserving aspect ratio, never upscale
                let avail = ui.available_size();
                let scale = if preview.width == 0 || preview.height == 0 {
                    1.0_f32
                } else {
                    (avail.x / preview.width as f32)
                        .min(avail.y / preview.height as f32)
                        .min(1.0)
                };
                let display_w = preview.width as f32 * scale;
                let display_h = preview.height as f32 * scale;

                ui.add(egui::Image::new(egui::load::SizedTexture::new(
                    egui_id,
                    [display_w, display_h],
                )));
            } else {
                // Non-texture file info
                ui.heading(&file_info.name);
                if !preview.format_info.is_empty() {
                    ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
                }
                if file_info.size_bytes > 0 {
                    ui.separator();
                    ui.label(format!("Size: {} bytes", file_info.size_bytes));
                    if let Some(fmt) = file_info.detected_format {
                        ui.label(format!("Detected format: {:?}", fmt));
                    }
                } else if iso_state.loading {
                    ui.spinner();
                    ui.label("Loading file…");
                }
            }
        });
    } else {
        // No file selected — show welcome / RE notes
        egui::CentralPanel::default().show(ctx, |ui| {
            ui.heading("Project Sylpheed: Arc of Deception");
            ui.label("Asset Viewer — Milestone 1");
            ui.separator();

            ui.collapsing("Getting Started", |ui| {
                ui.label("Option A — open an ISO directly:");
                ui.code("  File → Open ISO disc image…");
                ui.separator();
                ui.label("Option B — extract first, then open folder:");
                ui.code("  xdvdfs unpack sylpheed.iso ./assets/");
                ui.code("  File → Open extracted folder…");
                ui.separator();
                ui.label("Click a .xpr file in the left panel to preview the texture.");
            });

            ui.collapsing("Reverse Engineering Notes", |ui| {
                ui.label("Document your findings here as you RE the formats.");
                ui.separator();
                egui::Grid::new("re_notes").striped(true).show(ui, |ui| {
                    ui.strong("Extension");
                    ui.strong("Status");
                    ui.strong("Notes");
                    ui.end_row();

                    ui.label(".xpr");
                    ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Partial");
                    ui.label("XPR2 container + DXT1/3/5 textures");
                    ui.end_row();

                    ui.label("?");
                    ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
                    ui.label("Mesh format — need to identify extension");
                    ui.end_row();

                    ui.label("?");
                    ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
                    ui.label("Audio format — likely XWB wave banks");
                    ui.end_row();

                    ui.label("?");
                    ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
                    ui.label("Mission/level data — format unknown");
                    ui.end_row();
                });
            });
        });
    }
}

// ── Data pack (IPFB / IDXD) browser ───────────────────────────────────────────

/// Master-detail view for an open `.pak`: entry list on the left, the selected
/// entry's content on the right (IDXD table, subtitle cues, font sample, image,
/// or text — whatever `classify_content` decoded off-thread).
fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) {
    ui.horizontal(|ui| {
        ui.heading(&pak.name);
        ui.separator();
        ui.label(format!("{} entries", pak.rows.len()));
    });
    if let Some(err) = &pak.error {
        ui.separator();
        ui.colored_label(egui::Color32::RED, format!("⚠ {err}"));
        return;
    }
    ui.separator();

    // Destructure so the columns can read `rows` while mutating the selection +
    // the egui-side caches — without cloning the (now heavy) rows each frame.
    let PakView {
        rows,
        selected,
        img_tex,
        ..
    } = pak;
    let mut new_selection = *selected;

    ui.columns(2, |cols| {
        // Left — entry list.
        egui::ScrollArea::vertical()
            .id_salt("pak_list")
            .show(&mut cols[0], |ui| {
                for (i, row) in rows.iter().enumerate() {
                    let text = egui::RichText::new(format!(
                        "{:08x}  {:<7} {}",
                        row.hash,
                        row.format,
                        row_kind(row)
                    ))
                    .monospace();
                    if ui
                        .add(egui::SelectableLabel::new(*selected == Some(i), text))
                        .clicked()
                    {
                        new_selection = Some(i);
                    }
                }
            });

        // Right — detail of the selected entry, dispatched on decoded content.
        egui::ScrollArea::vertical()
            .id_salt("pak_detail")
            .show(&mut cols[1], |ui| match selected.and_then(|i| rows.get(i)) {
                None => {
                    ui.label("Select an entry to inspect.");
                }
                Some(row) => match &row.content {
                    PakContent::Subtitle(sub) => draw_subtitle_detail(ui, row, sub),
                    PakContent::Font { info, sample } => {
                        draw_font_detail(ui, row, info, sample.as_ref(), img_tex)
                    }
                    PakContent::Png(img) => draw_png_detail(ui, row, img, img_tex),
                    PakContent::T8ad(img) => draw_t8ad_detail(ui, row, img, img_tex),
                    PakContent::Lsta(frames) => draw_lsta_detail(ui, row, frames, img_tex),
                    PakContent::Ratc(children) => draw_ratc_detail(ui, row, children, img_tex),
                    PakContent::Text { text, encoding } => {
                        draw_text_detail(ui, row, text, encoding)
                    }
                    PakContent::None => draw_idxd_detail(ui, row),
                },
            });
    });

    *selected = new_selection;
}

/// Short kind hint for the entry list (subtitle/font/image/text), falling back
/// to the IDXD identity.
fn row_kind(row: &crate::iso_loader::PakRow) -> String {
    match &row.content {
        PakContent::Subtitle(s) => format!("subtitle · {} cues", s.cues.len()),
        PakContent::Font { info, .. } => {
            if info.family.is_empty() {
                "font".into()
            } else {
                info.family.clone()
            }
        }
        PakContent::Png(img) => format!("PNG {}×{}", img.width, img.height),
        PakContent::T8ad(img) => format!("T8aD {}×{}", img.width, img.height),
        PakContent::Lsta(f) => format!("LSTA · {} sprite(s)", f.len()),
        PakContent::Ratc(c) => format!("RATC · {} item(s)", c.len()),
        PakContent::Text { .. } => "text".into(),
        PakContent::None => row.identity.clone(),
    }
}

/// `mm:ss.SS` timestamp for subtitle cues.
fn fmt_ts(secs: f32) -> String {
    let s = secs.max(0.0);
    format!("{:02}:{:05.2}", (s / 60.0) as u32, s % 60.0)
}

/// IDXD object detail (or bare format/size for other un-presentable entries).
fn draw_idxd_detail(ui: &mut egui::Ui, row: &crate::iso_loader::PakRow) {
    match &row.detail {
        Some(d) => {
            let title = if row.identity.is_empty() {
                row.format.as_str()
            } else {
                row.identity.as_str()
            };
            ui.heading(title);
            ui.label(format!("schema 0x{:08x}   count {}", d.schema_hash, d.count));
            ui.label(format!("{} bytes   hash {:08x}", row.size, row.hash));
            ui.separator();
            if d.fields.is_empty() {
                ui.label("(no explicit-value fields)");
            } else {
                egui::Grid::new("pak_fields")
                    .striped(true)
                    .num_columns(2)
                    .show(ui, |ui| {
                        for (k, v) in &d.fields {
                            ui.label(k);
                            ui.monospace(v);
                            ui.end_row();
                        }
                    });
            }
        }
        None => {
            ui.heading(&row.format);
            ui.label(format!("{} bytes   hash {:08x}", row.size, row.hash));
            ui.separator();
            ui.label("No decoded preview for this format yet.");
        }
    }
}

/// IXUD subtitle track: a timecode + text cue table.
fn draw_subtitle_detail(
    ui: &mut egui::Ui,
    row: &crate::iso_loader::PakRow,
    sub: &sylpheed_formats::ixud::Subtitle,
) {
    ui.heading("Subtitle track");
    let note = if sub.is_reference_only() {
        "  ·  reference-only (MSG_* keys)"
    } else {
        ""
    };
    ui.label(format!(
        "IXUD  ·  {} cues  ·  hash {:08x}{note}",
        sub.cues.len(),
        row.hash
    ));
    ui.separator();
    if sub.cues.is_empty() {
        ui.label("(empty — no cues)");
        return;
    }
    egui::Grid::new("subtitle_cues")
        .striped(true)
        .num_columns(2)
        .show(ui, |ui| {
            ui.strong("Time");
            ui.strong("Text");
            ui.end_row();
            for c in &sub.cues {
                let t = match c.end {
                    Some(e) => format!("{}–{}", fmt_ts(c.start), fmt_ts(e)),
                    None => fmt_ts(c.start),
                };
                ui.monospace(t);
                ui.label(&c.text);
                ui.end_row();
            }
        });
}

/// Per-entry egui texture cache: `(entry hash, one texture per shown image)`.
type ImgCache = Option<(u32, Vec<egui::TextureHandle>)>;

/// Ensure `cache` holds egui textures for `imgs` under `hash`, rebuilding when
/// the selected entry changes. All image content comes from off-thread decodes;
/// this never touches egui's global font system.
fn ensure_textures(ui: &egui::Ui, hash: u32, imgs: &[&ImageRgba], cache: &mut ImgCache) {
    if cache.as_ref().map(|(h, _)| *h) != Some(hash) {
        let texs = imgs
            .iter()
            .enumerate()
            .map(|(i, img)| {
                let color = egui::ColorImage::from_rgba_unmultiplied(
                    [img.width as usize, img.height as usize],
                    &img.rgba,
                );
                ui.ctx().load_texture(
                    format!("pak_img_{hash:08x}_{i}"),
                    color,
                    egui::TextureOptions::LINEAR,
                )
            })
            .collect();
        *cache = Some((hash, texs));
    }
}

/// Cache + draw a single `ImageRgba`, scaled to fit the available width (capped
/// by `max_scale`). Shared by the PNG / T8aD / font-sample views.
fn show_cached_image(
    ui: &mut egui::Ui,
    hash: u32,
    img: &ImageRgba,
    cache: &mut ImgCache,
    max_scale: f32,
) {
    ensure_textures(ui, hash, &[img], cache);
    if let Some(tex) = cache.as_ref().and_then(|(_, t)| t.first()) {
        let scale = (ui.available_width() / img.width.max(1) as f32).min(max_scale);
        let (dw, dh) = (img.width as f32 * scale, img.height as f32 * scale);
        ui.add(egui::Image::new(egui::load::SizedTexture::new(
            tex.id(),
            [dw, dh],
        )));
    }
}

/// Embedded font: metadata + a pre-rasterized sample line (rendered off-thread
/// with `ab_glyph`, shown as an image — no global egui font install).
fn draw_font_detail(
    ui: &mut egui::Ui,
    row: &crate::iso_loader::PakRow,
    info: &sylpheed_formats::FontInfo,
    sample: Option<&ImageRgba>,
    img_tex: &mut ImgCache,
) {
    ui.heading(if info.family.is_empty() {
        "Font"
    } else {
        &info.family
    });
    ui.label(format!(
        "{}  ·  {} face(s)  ·  {} glyphs  ·  hash {:08x}",
        info.kind, info.faces, info.glyphs, row.hash
    ));
    ui.separator();
    match sample {
        Some(img) => {
            ui.label("Sample:");
            show_cached_image(ui, row.hash, img, img_tex, 1.0);
        }
        None => {
            ui.label("(no sample — font collection or unsupported outlines)");
        }
    }
}

/// Decoded PNG image, shown via a cached egui texture.
fn draw_png_detail(
    ui: &mut egui::Ui,
    row: &crate::iso_loader::PakRow,
    img: &ImageRgba,
    img_tex: &mut ImgCache,
) {
    ui.heading(format!("PNG image  {}×{}", img.width, img.height));
    ui.label(format!("hash {:08x}", row.hash));
    ui.separator();
    show_cached_image(ui, row.hash, img, img_tex, 2.0);
}

/// A decoded T8aD 2D texture. Colours are not yet verified against the game.
fn draw_t8ad_detail(
    ui: &mut egui::Ui,
    row: &crate::iso_loader::PakRow,
    img: &ImageRgba,
    img_tex: &mut ImgCache,
) {
    ui.heading(format!("T8aD 2D texture  {}×{}", img.width, img.height));
    ui.horizontal(|ui| {
        ui.label(format!("hash {:08x}", row.hash));
        ui.separator();
        ui.weak("colours unverified");
    });
    ui.separator();
    show_cached_image(ui, row.hash, img, img_tex, 2.0);
}

/// An LSTA sprite list — the inline T8aD frames in a grid.
fn draw_lsta_detail(
    ui: &mut egui::Ui,
    row: &crate::iso_loader::PakRow,
    frames: &[ImageRgba],
    img_tex: &mut ImgCache,
) {
    ui.horizontal(|ui| {
        ui.heading("LSTA sprite list");
        ui.separator();
        ui.label(format!("{} sprite(s)", frames.len()));
        ui.separator();
        ui.weak("colours unverified");
    });
    ui.separator();
    let refs: Vec<&ImageRgba> = frames.iter().collect();
    ensure_textures(ui, row.hash, &refs, img_tex);
    const CELL: f32 = 150.0;
    egui::Grid::new("lsta_sprites")
        .num_columns(3)
        .spacing([10.0, 10.0])
        .show(ui, |ui| {
            for (i, frame) in frames.iter().enumerate() {
                if let Some(tex) = img_tex.as_ref().and_then(|(_, t)| t.get(i)) {
                    let aspect = frame.height as f32 / frame.width.max(1) as f32;
                    ui.add(egui::Image::new(egui::load::SizedTexture::new(
                        tex.id(),
                        [CELL, CELL * aspect],
                    )));
                }
                if (i + 1) % 3 == 0 {
                    ui.end_row();
                }
            }
        });
}

/// A RATC bundle — a list of its named children, with thumbnails for the
/// decoded T8aD ones.
fn draw_ratc_detail(
    ui: &mut egui::Ui,
    row: &crate::iso_loader::PakRow,
    children: &[crate::iso_loader::RatcEntry],
    img_tex: &mut ImgCache,
) {
    ui.horizontal(|ui| {
        ui.heading("RATC bundle");
        ui.separator();
        ui.label(format!("{} item(s)", children.len()));
        ui.separator();
        ui.weak("colours unverified");
    });
    ui.separator();

    let refs: Vec<&ImageRgba> = children.iter().filter_map(|c| c.image.as_ref()).collect();
    ensure_textures(ui, row.hash, &refs, img_tex);

    let mut img_i = 0;
    for c in children {
        ui.horizontal(|ui| {
            if c.image.is_some() {
                if let Some(tex) = img_tex.as_ref().and_then(|(_, t)| t.get(img_i)) {
                    let sz = tex.size_vec2();
                    let scale = (48.0 / sz.y.max(1.0)).min(1.0);
                    ui.add(egui::Image::new(egui::load::SizedTexture::new(
                        tex.id(),
                        [sz.x * scale, sz.y * scale],
                    )));
                }
                img_i += 1;
            }
            ui.vertical(|ui| {
                let name = if c.name.is_empty() { "(unnamed)" } else { &c.name };
                ui.strong(name);
                ui.weak(format!("{} · {} bytes", c.kind, c.size));
            });
        });
        ui.separator();
    }
}

/// Plain-text / XML entry in a read-only monospace box.
fn draw_text_detail(
    ui: &mut egui::Ui,
    row: &crate::iso_loader::PakRow,
    text: &str,
    encoding: &str,
) {
    ui.horizontal(|ui| {
        ui.heading(&row.format);
        ui.separator();
        ui.label(encoding);
        ui.separator();
        ui.label(format!("{} bytes", row.size));
    });
    ui.separator();
    let mut buf = text;
    ui.add(
        egui::TextEdit::multiline(&mut buf)
            .font(egui::TextStyle::Monospace)
            .code_editor()
            .desired_width(f32::INFINITY)
            .interactive(false),
    );
}

// ── Video player (WMV cutscenes) ──────────────────────────────────────────────

/// Format seconds as `mm:ss`.
fn fmt_time(secs: f32) -> String {
    let s = secs.max(0.0) as u32;
    format!("{:02}:{:02}", s / 60, s % 60)
}

/// Central-panel video player: the current frame plus a transport bar
/// (play/pause, seekable timeline, volume). Also supports click-to-toggle and
/// keyboard shortcuts (space = play/pause, ←/→ = skip 10 s). Playback state
/// lives in `VideoPreview`; the decode/audio engine reacts to it in
/// `advance_video_playback`.
fn draw_video_player(
    ui: &mut egui::Ui,
    video: &mut VideoPreview,
    subs: &mut MovieSubtitles,
    voice: &mut MovieVoice,
    lang_changed: &mut bool,
    play_voice_solo: &mut bool,
) {
    // ── Keyboard shortcuts (consumed so focused widgets don't also act). ──
    let (mut toggle_play, mut skip) = (false, 0.0_f32);
    ui.input_mut(|i| {
        if i.consume_key(egui::Modifiers::NONE, egui::Key::Space) {
            toggle_play = true;
        }
        if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) {
            skip += 10.0;
        }
        if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) {
            skip -= 10.0;
        }
    });
    if toggle_play {
        video.playing = !video.playing;
    }
    if skip != 0.0 {
        let t = (video.position + skip).clamp(0.0, video.duration);
        video.position = t;
        video.scrub_request = Some(t); // instant preview via the grabber…
        video.seek_request = Some(t); // …then the stream restarts here
    }

    ui.horizontal(|ui| {
        ui.heading(&video.name);
        ui.separator();
        ui.label(format!("{}×{}  ·  wmv3 / wmapro", video.width, video.height));
        if !video.has_audio {
            ui.separator();
            ui.colored_label(egui::Color32::YELLOW, "no audio");
        }

        // Subtitle + voice controls (right-aligned): language, CC, and Voice.
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            let before = subs.lang;
            egui::ComboBox::from_id_source("subtitle_lang")
                .selected_text(subs.lang.label())
                .show_ui(ui, |ui| {
                    for lang in SubLang::ALL {
                        ui.selectable_value(&mut subs.lang, lang, lang.label());
                    }
                });
            if subs.lang != before {
                *lang_changed = true;
            }
            ui.toggle_value(&mut subs.enabled, "CC")
                .on_hover_text("Show subtitles");
            if subs.loading {
                ui.spinner();
            } else if subs.enabled && subs.cues.is_empty() {
                ui.weak("(no subtitles)");
            }

            ui.separator();
            // Voice-over track: the movie's own stream carries only music+SFX, so
            // this layers in the localized cutscene voice.
            ui.toggle_value(&mut voice.enabled, "🗣 Voice")
                .on_hover_text("Play the cutscene voice track over the movie");
            if voice.loading {
                ui.spinner();
            } else if !voice.available {
                ui.weak("(no voice)");
            }
            if voice.available
                && ui
                    .button("🎧")
                    .on_hover_text("Listen to the voice track on its own")
                    .clicked()
            {
                *play_voice_solo = true;
            }
        });
    });
    ui.separator();

    // Reserve room for the transport row so the frame never pushes it off-screen.
    const CONTROLS_H: f32 = 34.0;
    let avail = ui.available_size();
    let frame_h = (avail.y - CONTROLS_H).max(0.0);
    let (w, h) = (video.width.max(1) as f32, video.height.max(1) as f32);
    let scale = (avail.x / w).min(frame_h / h);
    let (disp_w, disp_h) = (w * scale, h * scale);

    let mut frame_rect = None;
    if let Some(egui_id) = video.egui_id {
        ui.allocate_ui(egui::vec2(avail.x, frame_h), |ui| {
            ui.vertical_centered(|ui| {
                // Click the frame to toggle play/pause.
                let resp = ui.add(
                    egui::Image::new(egui::load::SizedTexture::new(egui_id, [disp_w, disp_h]))
                        .sense(egui::Sense::click()),
                );
                if resp.clicked() {
                    video.playing = !video.playing;
                }
                frame_rect = Some(resp.rect);
            });
        });
    }

    // Caption overlay: every cue active at the current position, stacked over
    // the lower third of the frame (overlapping spans show together, newest —
    // last in start order — lowest).
    if let Some(rect) = frame_rect {
        let active = subs.active_cues(video.position);
        if !active.is_empty() {
            let joined = active
                .iter()
                .map(|c| c.text.as_str())
                .collect::<Vec<_>>()
                .join("\n");
            paint_caption(ui, rect, &joined);
        }
    }

    // ── Transport bar ──
    ui.horizontal(|ui| {
        if ui.button(if video.playing { "⏸" } else { "▶" }).clicked() {
            video.playing = !video.playing;
        }
        ui.label(fmt_time(video.position));

        // Timeline fills the space left after reserving room for the total-time
        // label and the volume control on the right. Dragging scrubs live (the
        // grabber shows the frame under the knob); release commits the seek.
        let reserved = 170.0;
        let tl_width = (ui.available_width() - reserved).max(80.0);
        ui.spacing_mut().slider_width = tl_width;
        let mut pos = video.position;
        let resp =
            ui.add(egui::Slider::new(&mut pos, 0.0..=video.duration.max(0.1)).show_value(false));
        if resp.changed() {
            video.position = pos; // knob + label follow immediately
            video.scrub_request = Some(pos);
            if resp.dragged() {
                video.scrubbing = true;
            } else {
                // A click on the track (no drag) → commit right away.
                video.seek_request = Some(pos);
                video.scrubbing = false;
            }
        }
        if resp.drag_stopped() {
            video.seek_request = Some(pos);
            video.scrubbing = false;
        }
        ui.label(fmt_time(video.duration));

        ui.separator();
        ui.label("🔊");
        ui.spacing_mut().slider_width = 90.0;
        ui.add_enabled(
            video.has_audio,
            egui::Slider::new(&mut video.volume, 0.0..=1.0).show_value(false),
        );
    });
}

/// Draw a subtitle caption centered along the bottom of `rect`, with a
/// semi-transparent backing box so it stays legible over any frame.
fn paint_caption(ui: &egui::Ui, rect: egui::Rect, text: &str) {
    let painter = ui.painter_at(rect);
    // Font scales with the frame; clamped so it's readable but not huge.
    let size = (rect.height() * 0.045).clamp(13.0, 30.0);
    let font = egui::FontId::proportional(size);
    let wrap = rect.width() * 0.9;
    let galley = painter.layout(
        text.to_string(),
        font,
        egui::Color32::WHITE,
        wrap,
    );
    let margin = egui::vec2(10.0, 6.0);
    let box_size = galley.size() + margin * 2.0;
    let top_left = egui::pos2(
        rect.center().x - box_size.x / 2.0,
        rect.bottom() - box_size.y - rect.height() * 0.04,
    );
    let bg = egui::Rect::from_min_size(top_left, box_size);
    painter.rect_filled(bg, 4.0, egui::Color32::from_black_alpha(160));
    painter.galley(top_left + margin, galley, egui::Color32::WHITE);
}

/// Standalone audio player: a transport bar for a decoded voice/sound track with
/// no video. Playback state lives in `AudioPreview`; `advance_audio_playback`
/// reacts to it.
fn draw_audio_player(ui: &mut egui::Ui, audio: &mut AudioPreview) {
    ui.horizontal(|ui| {
        ui.heading(&audio.name);
        ui.separator();
        if audio.loading {
            ui.spinner();
            ui.label("decoding…");
        } else {
            ui.label("voice track");
        }
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            if ui.button("✖ Close").clicked() {
                audio.active = false;
            }
        });
    });
    ui.separator();
    ui.add_space(ui.available_height() * 0.4);

    // Big centered play/pause + a speaker glyph, since there's nothing to show.
    ui.vertical_centered(|ui| {
        ui.label(egui::RichText::new("🔊").size(64.0));
    });
    ui.add_space(12.0);

    // Transport bar.
    ui.horizontal(|ui| {
        if ui
            .button(if audio.playing { "⏸" } else { "▶" })
            .clicked()
        {
            audio.playing = !audio.playing;
        }
        ui.label(fmt_time(audio.position));
        let tl_width = (ui.available_width() - 170.0).max(80.0);
        ui.spacing_mut().slider_width = tl_width;
        let mut pos = audio.position;
        let resp = ui.add(
            egui::Slider::new(&mut pos, 0.0..=audio.duration.max(0.1)).show_value(false),
        );
        if resp.changed() {
            audio.position = pos;
            audio.seek_request = Some(pos);
        }
        ui.label(fmt_time(audio.duration));
        ui.separator();
        ui.label("🔊");
        ui.spacing_mut().slider_width = 90.0;
        ui.add(egui::Slider::new(&mut audio.volume, 0.0..=1.0).show_value(false));
    });
}

// ── World cubemap (skybox) viewer ─────────────────────────────────────────────

/// Show a world cubemap's 6 faces in a labelled grid (D3D9 order). A true
/// interactive skybox is a follow-up — the face data + order are validated, but
/// the wgpu cube-sampling handedness needs visual confirmation first.
fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) {
    ui.horizontal(|ui| {
        ui.heading("World cubemap");
        ui.separator();
        ui.label(&skybox.info);
    });
    ui.label("6 cube faces, D3D9 order (+X −X +Y −Y +Z −Z).");
    ui.separator();

    egui::ScrollArea::both().show(ui, |ui| {
        const CELL: f32 = 240.0;
        egui::Grid::new("cube_faces")
            .num_columns(3)
            .spacing([10.0, 10.0])
            .show(ui, |ui| {
                for (i, face) in skybox.faces.iter().enumerate() {
                    ui.vertical(|ui| {
                        ui.strong(face.label);
                        let aspect = face.height as f32 / face.width.max(1) as f32;
                        ui.add(egui::Image::new(egui::load::SizedTexture::new(
                            face.egui_id,
                            [CELL, CELL * aspect],
                        )));
                    });
                    if (i + 1) % 3 == 0 {
                        ui.end_row();
                    }
                }
            });
    });
}

// ── Game Data browser ──────────────────────────────────────────────────────────

fn fnum(v: Option<f32>) -> String {
    match v {
        Some(x) if x == x.trunc() => format!("{}", x as i64),
        Some(x) => format!("{x}"),
        None => "·".into(),
    }
}
fn fint(v: Option<i64>) -> String {
    v.map(|x| x.to_string()).unwrap_or_else(|| "·".into())
}

/// The Game Data browser — a floating window over the decoded IDXD tables
/// (weapons, craft, ships, cast, missions, arsenal, flights). Its own system so
/// `draw_viewer_ui` stays under Bevy's system-parameter limit; a `RequestGameData`
/// event (from the View menu) opens it and lazily kicks off decoding.
#[cfg(not(target_arch = "wasm32"))]
fn draw_game_data_ui(
    mut contexts: EguiContexts,
    mut game_data: ResMut<GameData>,
    mut requests: EventReader<RequestGameData>,
) {
    if requests.read().next().is_some() {
        game_data.open = true;
        if !game_data.loaded {
            game_data.loading = true;
        }
    }
    if !game_data.open {
        return;
    }
    let ctx = contexts.ctx_mut().clone();
    let mut open = true;
    egui::Window::new("🗃 Game Data")
        .default_width(700.0)
        .default_height(560.0)
        .open(&mut open)
        .show(&ctx, |ui| {
            if game_data.loading {
                ui.horizontal(|ui| {
                    ui.spinner();
                    ui.label("Decoding game tables…");
                });
                ctx.request_repaint();
                return;
            }
            if !game_data.loaded {
                ui.label("Open a game source first (File ▸ Open…).");
                return;
            }
            let GameData { category, filter, snapshot, .. } = &mut *game_data;

            ui.horizontal_wrapped(|ui| {
                for cat in GameCategory::ALL {
                    ui.selectable_value(category, cat, cat.label());
                }
            });
            let filterable = !matches!(category, GameCategory::Arsenal | GameCategory::Flights);
            if filterable {
                ui.horizontal(|ui| {
                    ui.label("🔍");
                    ui.text_edit_singleline(filter);
                    if !filter.is_empty() && ui.small_button("✖").clicked() {
                        filter.clear();
                    }
                });
            }
            ui.separator();
            let f = filter.to_lowercase();
            let hit = |s: &str| f.is_empty() || s.to_lowercase().contains(&f);

            egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match *category {
                GameCategory::Weapons => {
                    egui::Grid::new("g_weap").striped(true).num_columns(6).show(ui, |ui| {
                        for h in ["Weapon", "Targets", "Power", "Velocity", "Range", "Reload"] {
                            ui.strong(h);
                        }
                        ui.end_row();
                        for w in &snapshot.weapons {
                            let name = w.id.as_deref().unwrap_or("?").trim_start_matches("Weapon_");
                            if !hit(name) {
                                continue;
                            }
                            ui.label(name);
                            ui.label(w.target_type.as_deref().unwrap_or("·"));
                            ui.label(fnum(w.power));
                            ui.label(fnum(w.velocity));
                            ui.label(fnum(w.max_range));
                            ui.label(fint(w.loading_count));
                            ui.end_row();
                        }
                    });
                }
                GameCategory::Craft => {
                    egui::Grid::new("g_craft").striped(true).num_columns(6).show(ui, |ui| {
                        for h in ["Craft", "HP", "Cruise", "Accel", "Radar", "Turrets"] {
                            ui.strong(h);
                        }
                        ui.end_row();
                        for u in &snapshot.craft {
                            let name = u.id.as_deref().unwrap_or("?").trim_start_matches("UN_");
                            if !hit(name) {
                                continue;
                            }
                            ui.label(name);
                            ui.label(fnum(u.hp));
                            ui.label(fnum(u.cruising_velocity));
                            ui.label(fnum(u.acceleration));
                            ui.label(fnum(u.radar_range));
                            ui.label(fint(u.turret_count));
                            ui.end_row();
                        }
                    });
                }
                GameCategory::Vessels => {
                    egui::Grid::new("g_ves").striped(true).num_columns(6).show(ui, |ui| {
                        for h in ["Vessel", "HP", "Length", "Turrets", "Bridges", "Shield gen"] {
                            ui.strong(h);
                        }
                        ui.end_row();
                        for v in &snapshot.vessels {
                            let name = v.id.as_deref().unwrap_or("?").trim_start_matches("UN_");
                            if !hit(name) {
                                continue;
                            }
                            ui.label(name);
                            ui.label(fnum(v.hp));
                            ui.label(fnum(v.size_z));
                            ui.label(fint(v.turret_count));
                            ui.label(fint(v.bridge_count));
                            ui.label(fint(v.shield_generator_count));
                            ui.end_row();
                        }
                    });
                }
                GameCategory::Characters => {
                    egui::Grid::new("g_char").striped(true).num_columns(3).show(ui, |ui| {
                        for h in ["Name", "Faction", "Portraits"] {
                            ui.strong(h);
                        }
                        ui.end_row();
                        for c in &snapshot.characters {
                            if !hit(&c.name) && !hit(&c.faction) {
                                continue;
                            }
                            ui.label(&c.name);
                            let col = if c.faction == "TCAF" {
                                egui::Color32::from_rgb(90, 160, 232)
                            } else if c.faction == "ADAN" {
                                egui::Color32::from_rgb(224, 86, 122)
                            } else {
                                egui::Color32::GRAY
                            };
                            ui.colored_label(col, if c.faction.is_empty() { "—" } else { &c.faction });
                            ui.label(c.faces.to_string());
                            ui.end_row();
                        }
                    });
                }
                GameCategory::Missions => {
                    for m in &snapshot.missions {
                        if !hit(&m.id) && !hit(&m.location) && !m.objectives.iter().any(|o| hit(o)) {
                            continue;
                        }
                        let head = format!("{}  ·  {}  ·  {} phases", m.id, m.location, m.phases);
                        egui::CollapsingHeader::new(head).id_salt(&m.id).show(ui, |ui| {
                            if !m.objectives.is_empty() {
                                ui.strong("Objectives");
                                for o in &m.objectives {
                                    ui.label(format!("▸ {o}"));
                                }
                            }
                            if !m.lose.is_empty() {
                                ui.add_space(4.0);
                                ui.strong("Fail conditions");
                                for l in &m.lose {
                                    ui.colored_label(egui::Color32::from_rgb(224, 86, 122), l);
                                }
                            }
                            if !m.enemies.is_empty() {
                                ui.add_space(4.0);
                                ui.strong("Enemy roster");
                                for e in &m.enemies {
                                    ui.label(format!("• {e}"));
                                }
                            }
                        });
                    }
                }
                GameCategory::Arsenal => {
                    ui.columns(4, |cols| {
                        for (i, (title, list)) in [
                            ("Nose", &snapshot.arsenal.nose),
                            ("Arm 1", &snapshot.arsenal.arm1),
                            ("Arm 2", &snapshot.arsenal.arm2),
                            ("Arm 3", &snapshot.arsenal.arm3),
                        ]
                        .into_iter()
                        .enumerate()
                        {
                            cols[i].strong(format!("{title} ({})", list.len()));
                            for w in list {
                                cols[i].label(w.replace('_', " "));
                            }
                        }
                    });
                }
                GameCategory::Flights => {
                    ui.label(
                        egui::RichText::new("Distinct wingman line-ups (story order)").weak().small(),
                    );
                    ui.add_space(4.0);
                    for (n, lineup) in snapshot.flights.iter().enumerate() {
                        egui::CollapsingHeader::new(format!("Line-up {}", n + 1))
                            .id_salt(n)
                            .default_open(n == 0)
                            .show(ui, |ui| {
                                egui::Grid::new(("g_flight", n)).striped(true).num_columns(2).show(ui, |ui| {
                                    for (cs, pilot) in lineup {
                                        ui.label(cs);
                                        ui.strong(pilot);
                                        ui.end_row();
                                    }
                                });
                            });
                    }
                }
            });
        });
    game_data.open &= open;
}

// ── Ships browser ───────────────────────────────────────────────────────────────

/// The Ships browser — a floating window over the capital ships reconstructed
/// from XBG7 part families. Picking a ship assembles its parts and renders the
/// whole model in the 3D view. Its own system (keeps `draw_viewer_ui` under the
/// parameter limit); a `RequestShipCatalog` event (View menu) opens it and lazily
/// kicks off the stage scan, while clicking a row fires `RequestShipRender`.
#[cfg(not(target_arch = "wasm32"))]
fn draw_ships_ui(
    mut contexts: EguiContexts,
    mut ships: ResMut<ShipBrowser>,
    mut requests: EventReader<RequestShipCatalog>,
    mut render: EventWriter<RequestShipRender>,
) {
    if requests.read().next().is_some() {
        ships.open = true;
    }
    if !ships.open {
        return;
    }
    let ctx = contexts.ctx_mut().clone();
    let mut open = true;
    egui::Window::new("🚀 Ships")
        .default_width(560.0)
        .default_height(560.0)
        .open(&mut open)
        .show(&ctx, |ui| {
            if ships.loading {
                ui.horizontal(|ui| {
                    ui.spinner();
                    ui.label("Scanning stage models for ships…");
                });
                ctx.request_repaint();
                return;
            }
            if !ships.loaded {
                ui.label("Open a game source first (File ▸ Open…).");
                return;
            }

            // Faction filter chips.
            ui.horizontal(|ui| {
                for (label, val) in
                    [("All", ""), ("ADAN", "ADAN"), ("TCAF", "TCAF"), ("Neutral", "Neutral")]
                {
                    let sel = ships.faction == val;
                    if ui.selectable_label(sel, label).clicked() {
                        ships.faction = val.to_string();
                    }
                }
            });
            ui.horizontal(|ui| {
                ui.label("🔍");
                ui.text_edit_singleline(&mut ships.filter);
                if !ships.filter.is_empty() && ui.small_button("✖").clicked() {
                    ships.filter.clear();
                }
            });
            ui.horizontal(|ui| {
                ui.checkbox(&mut ships.show_external, "External parts (bridge/engines/turrets)");
                ui.label(
                    egui::RichText::new("(bridge / shield gens / engines — approximate placement)")
                        .weak()
                        .small(),
                );
            });
            ui.separator();

            let ShipBrowser { rows, filter, faction, selected, show_external, .. } = &mut *ships;
            let show_external = *show_external;
            let needle = filter.to_lowercase();
            let mut to_render: Option<(String, String, String)> = None;

            egui::ScrollArea::vertical().show(ui, |ui| {
                let mut last_section = String::new();
                for row in rows.iter() {
                    if !faction.is_empty() && &row.faction != faction {
                        continue;
                    }
                    if !needle.is_empty()
                        && !row.name.to_lowercase().contains(&needle)
                        && !row.id.contains(&needle)
                    {
                        continue;
                    }
                    // Section header: capital ships first, then other assemblies.
                    let section = if row.has_vessel { "Capital ships" } else { "Other assemblies" };
                    if section != last_section {
                        ui.add_space(4.0);
                        ui.label(egui::RichText::new(section).strong().weak());
                        last_section = section.to_string();
                    }

                    let is_sel = selected.as_deref() == Some(row.id.as_str());
                    let color = match row.faction.as_str() {
                        "TCAF" => egui::Color32::from_rgb(120, 170, 235),
                        "ADAN" => egui::Color32::from_rgb(235, 120, 120),
                        _ => egui::Color32::from_rgb(180, 180, 180),
                    };
                    egui::CollapsingHeader::new(
                        egui::RichText::new(format!("{}  ·  {}", row.name, row.id)).color(color),
                    )
                    .id_salt(&row.id)
                    .default_open(false)
                    .show(ui, |ui| {
                        egui::Grid::new(("shipstat", &row.id)).num_columns(2).show(ui, |ui| {
                            ui.label("Faction");
                            ui.strong(&row.faction);
                            ui.end_row();
                            if let Some(hp) = row.hp {
                                ui.label("Hull HP");
                                ui.strong(format!("{hp:.0}"));
                                ui.end_row();
                            }
                            if let Some((x, y, z)) = row.size {
                                ui.label("Size (m)");
                                ui.strong(format!("{x:.0} × {y:.0} × {z:.0}"));
                                ui.end_row();
                            }
                            if row.turrets.is_some() || row.shield_gens.is_some() {
                                ui.label("Hardpoints");
                                ui.strong(format!(
                                    "{} turrets · {} bridges · {} shield gens",
                                    fint(row.turrets),
                                    fint(row.bridges),
                                    fint(row.shield_gens),
                                ));
                                ui.end_row();
                            }
                            ui.label("Model parts");
                            ui.strong(format!("{}", row.parts.len()));
                            ui.end_row();
                            ui.label("Appears in");
                            ui.strong(row.stages.join(", "));
                            ui.end_row();
                        });
                        let btn = egui::Button::new(if is_sel {
                            "● Showing in 3D view"
                        } else {
                            "▶ Assemble & view in 3D"
                        });
                        if ui.add(btn).clicked() {
                            to_render = Some((
                                row.id.clone(),
                                row.stage_file.clone(),
                                format!("{}  ({})", row.name, row.id),
                            ));
                        }
                    });
                }
            });

            if let Some((id, file, label)) = to_render {
                let id2 = id.clone();
                *selected = Some(id);
                render.send(RequestShipRender { file, id: id2, external: show_external, label });
            }
        });
    ships.open &= open;
}
