//! ISO / extracted-directory loading pipeline for the viewer.
//!
//! Bridges the async `XisoReader` API (and synchronous `GameAssets`) with
//! Bevy's ECS via background threads and `std::sync::mpsc` channels polled
//! each frame — no blocking on the main thread.
//!
//! ## Data flow (native)
//!
//! ```text
//! UI click → Event → handle_open_iso / handle_open_dir / handle_file_selected
//!                     └─ std::thread ─→ mpsc::Sender<IsoLoaderMsg>
//!                                               ↓ (next frame)
//!                         poll_loader_channel  → FileBrowserState / PendingFileBytes
//!                                               ↓
//!                         apply_loaded_texture → TexturePreview (Image + egui TextureId)
//!                                               ↓
//!                         draw_viewer_ui       → renders preview panel
//! ```

#[cfg(not(target_arch = "wasm32"))]
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(not(target_arch = "wasm32"))]
use std::sync::{mpsc, Arc, Mutex};

#[cfg(not(target_arch = "wasm32"))]
use std::io::Read;
#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
#[cfg(not(target_arch = "wasm32"))]
use std::process::{Child, Command, Stdio};

#[cfg(not(target_arch = "wasm32"))]
use bevy::render::render_asset::RenderAssetUsages;
#[cfg(not(target_arch = "wasm32"))]
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};

use std::path::PathBuf;

use bevy::prelude::*;
use bevy_egui::egui;

use sylpheed_formats::vfs::FileFormat;

use crate::ui::FileBrowserState;

// ── Events ────────────────────────────────────────────────────────────────────

/// Fired by the "Open ISO disc image…" menu item.
#[derive(Event, Default)]
pub struct RequestOpenIso;

/// Fired by the "Open extracted folder…" menu item.
#[derive(Event, Default)]
pub struct RequestOpenDir;

/// Fired when the user clicks a file in the browser panel.
#[derive(Event)]
pub struct FileSelected(pub String);

// ── Resources (platform-agnostic) ────────────────────────────────────────────

/// Which kind of source is currently open.
#[derive(Default, Clone)]
pub enum SourceKind {
    #[default]
    None,
    #[cfg(not(target_arch = "wasm32"))]
    Iso(PathBuf),
    #[cfg(not(target_arch = "wasm32"))]
    Directory(PathBuf),
}

/// State of the currently-open file source (ISO or extracted folder).
#[derive(Resource, Default)]
pub struct IsoState {
    /// Short description shown in the status bar (filename or directory path).
    pub source_label: Option<String>,
    /// True while a background thread is loading.
    pub loading: bool,
    /// The most recent error message (cleared when a new operation starts).
    pub error: Option<String>,
    /// Which backend to use when reading individual files.
    pub source_kind: SourceKind,
}

/// The texture currently shown in the central panel.
#[derive(Resource, Default)]
pub struct TexturePreview {
    /// Bevy handle — kept alive so the GPU texture stays allocated.
    pub handle: Option<Handle<Image>>,
    /// egui texture ID registered via `EguiContexts::add_image`.
    pub egui_id: Option<egui::TextureId>,
    pub width: u32,
    pub height: u32,
    /// Human-readable format / dimension summary (or error message).
    pub format_info: String,
}

/// Info shown for any selected file (texture or not).
#[derive(Resource, Default)]
pub struct FileInfo {
    pub name: String,
    pub size_bytes: usize,
    pub detected_format: Option<FileFormat>,
}

/// Decoded contents of the currently-selected text file, shown in the central
/// panel. `content` is `None` whenever the selection isn't text.
#[derive(Resource, Default)]
pub struct TextPreview {
    /// Decoded UTF-8 (any BOM / UTF-16 already resolved), or `None`.
    pub content: Option<String>,
    /// Encoding label for the header, e.g. "UTF-16LE (BOM)".
    pub encoding: String,
    /// True when the file was larger than `MAX_TEXT_BYTES` and got clipped.
    pub truncated: bool,
}

/// Cap on the decoded text we hand to egui — its text layout cost grows
/// super-linearly, and disc config files are only a few KiB anyway.
const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024;

/// One row in the pack browser's entry list. Owned/plain so it crosses the
/// loader channel and lives in a resource without borrowing a `PakArchive`.
#[derive(Clone)]
pub struct PakRow {
    pub hash: u32,
    pub comp_size: u32,
    /// Decompressed payload length, or `comp_size` when the entry wasn't decoded.
    pub size: usize,
    /// Inner-format label (`IDXD`, a 4-char tag, `(not decoded)`, …).
    pub format: String,
    /// Short identity (`ID=…` / `Name=…`), empty for non-IDXD entries.
    pub identity: String,
    /// Parsed IDXD detail for the property table, when the entry is an object.
    pub detail: Option<PakDetail>,
    /// Decoded presentable payload for the detail pane (subtitle / font / image
    /// / text). `None` for IDXD (uses `detail`) and un-presentable formats.
    pub content: PakContent,
}

/// Presentable, off-thread-decoded content behind a pack entry, shown in the
/// browser's detail pane without touching the Bevy texture pipeline.
#[derive(Clone, Default)]
pub enum PakContent {
    #[default]
    None,
    /// IXUD subtitle / localized-string track.
    Subtitle(sylpheed_formats::ixud::Subtitle),
    /// Embedded font: metadata + a pre-rasterized sample line (RGBA8), if the
    /// font could be rendered off-thread (`None` for collections / CFF that our
    /// rasterizer declines — metadata still shows).
    Font {
        info: sylpheed_formats::FontInfo,
        sample: Option<ImageRgba>,
    },
    /// Decoded PNG image (RGBA8), shown via an egui texture.
    Png(ImageRgba),
    /// A decoded T8aD 2D texture.
    T8ad(ImageRgba),
    /// An LSTA sprite list — inline T8aD frames.
    Lsta(Vec<ImageRgba>),
    /// A RATC bundle — its listed children (T8aD children carry a decoded image).
    Ratc(Vec<RatcEntry>),
    /// Plain-text / XML payload, with its encoding label.
    Text { text: String, encoding: String },
}

/// One child in a presented RATC bundle.
#[derive(Clone)]
pub struct RatcEntry {
    pub name: String,
    pub kind: String,
    pub size: usize,
    /// Decoded image when the child is a (decodable) T8aD within the pixel budget.
    pub image: Option<ImageRgba>,
}

/// A plain RGBA8 image handed to the UI for an egui texture.
#[derive(Clone)]
pub struct ImageRgba {
    pub width: u32,
    pub height: u32,
    pub rgba: Vec<u8>,
}

impl ImageRgba {
    fn from_t8ad(img: sylpheed_formats::T8adImage) -> Self {
        Self {
            width: img.width,
            height: img.height,
            rgba: img.rgba,
        }
    }
    fn pixels(&self) -> usize {
        (self.width as usize) * (self.height as usize)
    }
}

/// The parsed IDXD detail behind one entry, shown in the master-detail pane.
#[derive(Clone)]
pub struct PakDetail {
    pub schema_hash: u32,
    pub count: u32,
    /// Explicit (key, value) fields (defaulted fields omitted), owned.
    pub fields: Vec<(String, String)>,
}

/// One decoded cubemap face, registered as an egui image.
pub struct FaceTex {
    pub label: &'static str,
    pub handle: Handle<Image>,
    pub egui_id: egui::TextureId,
    pub width: u32,
    pub height: u32,
}

/// The currently-open world cubemap (`TXCM`), shown as a labelled 6-face grid.
#[derive(Resource, Default)]
pub struct SkyboxPreview {
    pub faces: Vec<FaceTex>,
    /// Header summary (dimensions / format).
    pub info: String,
}

/// Marker for entities spawned to preview an XBG7 3D model, so they can all be
/// despawned when a new file is selected.
#[derive(Component)]
pub struct PreviewModel;

/// The currently-previewed XBG7 3D model. When `active`, the central panel is
/// drawn transparent so the real Bevy scene (mesh + orbit camera) shows through.
#[derive(Resource, Default)]
pub struct ModelPreview {
    pub active: bool,
    pub name: String,
    /// Summary line: sub-meshes / vertices / triangles / texture.
    pub info: String,
}


/// The currently-open `.wmv` cutscene, shown in the central panel with a
/// transport bar. Registered unconditionally (the decode/audio engine below is
/// native-only); on wasm `active` stays false and `.wmv` shows the info panel.
#[derive(Resource)]
pub struct VideoPreview {
    /// True while a video is loaded and the player owns the central panel.
    pub active: bool,
    pub name: String,
    pub width: u32,
    pub height: u32,
    /// Total length in seconds (from `ffprobe`).
    pub duration: f32,
    /// Current playback position in seconds (drives the frame clock + timeline).
    pub position: f32,
    pub playing: bool,
    /// The frame texture — one `Image`, overwritten in place each tick.
    pub handle: Option<Handle<Image>>,
    pub egui_id: Option<egui::TextureId>,
    /// 0.0..=1.0 audio level (ignored when `!has_audio`).
    pub volume: f32,
    pub has_audio: bool,
    /// Set on drag-release / click / keyboard skip; restarts the streaming
    /// decoder (and audio) at this position.
    pub seek_request: Option<f32>,
    /// Set continuously while dragging the timeline; asks the one-shot grabber
    /// for the frame at this position so scrubbing shows a live preview.
    pub scrub_request: Option<f32>,
    /// True while the knob is being dragged — display grabbed scrub frames and
    /// hold the streaming decoder until release.
    pub scrubbing: bool,
}

impl Default for VideoPreview {
    fn default() -> Self {
        Self {
            active: false,
            name: String::new(),
            width: 0,
            height: 0,
            duration: 0.0,
            position: 0.0,
            playing: false,
            handle: None,
            egui_id: None,
            volume: 0.8,
            has_audio: false,
            seek_request: None,
            scrub_request: None,
            scrubbing: false,
        }
    }
}

/// Timed subtitles for the currently-playing cutscene, plus the language
/// selection. Populated asynchronously (see [`RequestSubtitles`]); the UI
/// overlays the active cue on the video frame. Registered on all platforms so
/// the UI can read it; the loader that fills it is native-only.
#[derive(Resource)]
pub struct MovieSubtitles {
    /// Whether to show captions.
    pub enabled: bool,
    /// Selected caption language.
    pub lang: sylpheed_formats::SubLang,
    /// Cues for `movie`, in start order.
    pub cues: Vec<sylpheed_formats::SubCue>,
    /// Basename (no extension) of the movie the cues belong to.
    pub movie: Option<String>,
    /// True while a load is in flight.
    pub loading: bool,
    /// Bumped on each request so stale async results are ignored.
    pub generation: u64,
}

impl Default for MovieSubtitles {
    fn default() -> Self {
        Self {
            enabled: true,
            lang: sylpheed_formats::SubLang::English,
            cues: Vec::new(),
            movie: None,
            loading: false,
            generation: 0,
        }
    }
}

impl MovieSubtitles {
    /// The caption active at `t` seconds, if any.
    ///
    /// Cues with an explicit `start-end` range (the inline narration tracks) use
    /// it directly. Reference tracks (radio / resupply) carry only a *start*
    /// time, so we show each line for an estimated reading duration — capped by
    /// the next cue's start — instead of leaving it on screen until the next line
    /// (which, for a single-line resupply movie, meant the whole video).
    pub fn active_at(&self, t: f32) -> Option<&sylpheed_formats::SubCue> {
        self.active_cues(t).into_iter().next()
    }

    /// Every caption active at `t` seconds, in start order.
    ///
    /// Cues with an explicit `start-end` range use it directly; range-only tracks
    /// (radio / resupply) carry a *start* only, so each shows for an estimated
    /// reading duration capped by the next cue's start. Two captions whose spans
    /// overlap are **both** returned (some tracks cross-fade lines), so the caller
    /// can stack them rather than showing only the first.
    pub fn active_cues(&self, t: f32) -> Vec<&sylpheed_formats::SubCue> {
        if !self.enabled {
            return Vec::new();
        }
        let mut out = Vec::new();
        for (i, c) in self.cues.iter().enumerate() {
            if t < c.start {
                continue;
            }
            let end = match c.end {
                Some(e) => e,
                None => {
                    // For an open-ended cue, cap at the next cue that starts
                    // *after* it (a later cue may itself start earlier if the
                    // list has overlaps, so scan forward for the first greater
                    // start rather than blindly taking i+1).
                    let next = self
                        .cues
                        .iter()
                        .skip(i + 1)
                        .map(|n| n.start)
                        .find(|&s| s > c.start)
                        .unwrap_or(f32::INFINITY);
                    (c.start + estimated_read_secs(&c.text)).min(next)
                }
            };
            if t < end {
                out.push(c);
            }
        }
        out
    }
}

/// Estimated on-screen time for a caption that carries no explicit end time,
/// from its length (~13 characters/second reading speed), clamped to a sensible
/// 2–7 s so a short line still lingers and a long one doesn't overstay.
fn estimated_read_secs(text: &str) -> f32 {
    let chars = text.chars().filter(|c| !c.is_whitespace()).count() as f32;
    (1.2 + chars / 13.0).clamp(2.0, 7.0)
}

/// Ask the loader to (re)load subtitles for `movie` in `lang`. Fired on video
/// open and on language change.
#[derive(Event)]
pub struct RequestSubtitles {
    pub movie: String,
    pub lang: sylpheed_formats::SubLang,
    pub generation: u64,
}

/// The cutscene voice-over track for the current movie. The audio itself lives
/// in the video engine (a second rodio sink); this resource holds the UI toggle
/// and async-load bookkeeping. Registered on all platforms; the loader is
/// native-only.
#[derive(Resource)]
pub struct MovieVoice {
    /// Whether the voice track is audible (user toggle).
    pub enabled: bool,
    /// Voice language (only English/Japanese exist on disc).
    pub lang: sylpheed_formats::slb::VoiceLang,
    /// Basename of the movie the current voice belongs to.
    pub movie: Option<String>,
    /// True while decoding.
    pub loading: bool,
    /// True once a decoded voice track exists for `movie` (else the movie has none).
    pub available: bool,
    /// Bumped per request to discard stale async results.
    pub generation: u64,
    /// A freshly-decoded WAV awaiting sink construction on the engine thread.
    pub(crate) pending_wav: Option<PathBuf>,
}

impl Default for MovieVoice {
    fn default() -> Self {
        Self {
            enabled: true,
            lang: sylpheed_formats::slb::VoiceLang::English,
            movie: None,
            loading: false,
            available: false,
            generation: 0,
            pending_wav: None,
        }
    }
}

/// Ask the loader to decode the voice track for `movie`. `duration` clamps the
/// decode to the media length (dropping any trailing bank data past the video).
#[derive(Event)]
pub struct RequestVoice {
    pub movie: String,
    pub lang: sylpheed_formats::slb::VoiceLang,
    pub duration: f32,
    pub generation: u64,
}

/// Standalone audio player: plays a decoded voice/sound track on its own (no
/// video), with its own transport. Populated via [`RequestAudio`].
#[derive(Resource)]
pub struct AudioPreview {
    pub active: bool,
    pub name: String,
    pub position: f32,
    pub duration: f32,
    pub playing: bool,
    pub volume: f32,
    pub loading: bool,
    pub seek_request: Option<f32>,
    /// A freshly-decoded WAV + duration awaiting sink construction.
    pub(crate) pending: Option<(PathBuf, f32)>,
    pub(crate) generation: u64,
}

impl Default for AudioPreview {
    fn default() -> Self {
        Self {
            active: false,
            name: String::new(),
            position: 0.0,
            duration: 0.0,
            playing: false,
            volume: 0.9,
            loading: false,
            seek_request: None,
            pending: None,
            generation: 0,
        }
    }
}

/// Ask the loader to decode a clip for standalone playback (full length, no
/// clamp). Either `clip` names the `.slb` entry directly (the Voice Lines
/// browser), or `movie` asks the loader to resolve the voice bank from the movie
/// manifest (the movie player's 🎧 solo button). `display` is the UI label.
#[derive(Event)]
pub struct RequestAudio {
    pub clip: String,
    pub display: String,
    /// When set, resolve the `.slb` from the movie manifest instead of `clip`.
    pub movie: Option<(String, sylpheed_formats::slb::VoiceLang)>,
    pub generation: u64,
}

/// The browseable library of voice-line clips (from `sounds.tbl`), for the
/// standalone voice player. Loaded lazily the first time the browser opens.
#[derive(Resource, Default)]
pub struct VoiceLibrary {
    pub open: bool,
    pub loading: bool,
    pub loaded: bool,
    pub clips: Vec<sylpheed_formats::slb::VoiceClip>,
    pub filter: String,
}

/// Ask the loader to enumerate the voice library from `sounds.tbl`.
#[derive(Event, Default)]
pub struct RequestVoiceLibrary;

// ── Game-data browser (decoded IDXD tables) ────────────────────────────────────

/// Which decoded table the Game Data browser is showing.
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum GameCategory {
    #[default]
    Weapons,
    Craft,
    Vessels,
    Characters,
    Missions,
    Arsenal,
    Flights,
}

impl GameCategory {
    pub const ALL: [GameCategory; 7] = [
        GameCategory::Weapons,
        GameCategory::Craft,
        GameCategory::Vessels,
        GameCategory::Characters,
        GameCategory::Missions,
        GameCategory::Arsenal,
        GameCategory::Flights,
    ];
    pub fn label(self) -> &'static str {
        match self {
            GameCategory::Weapons => "Weapons",
            GameCategory::Craft => "Craft",
            GameCategory::Vessels => "Capital Ships",
            GameCategory::Characters => "Characters",
            GameCategory::Missions => "Missions",
            GameCategory::Arsenal => "Arsenal",
            GameCategory::Flights => "Flights",
        }
    }
}

/// A character row, name resolved via localization.
pub struct CharRow {
    pub name: String,
    pub faction: String,
    pub faces: usize,
}

/// A mission row: stage + its resolved briefing.
pub struct MissionRow {
    pub id: String,
    pub location: String,
    pub phases: u32,
    pub objectives: Vec<String>,
    pub lose: Vec<String>,
    pub enemies: Vec<String>,
}

/// Everything the Game Data browser shows, decoded off-thread from the paks.
#[derive(Default)]
pub struct GameSnapshot {
    pub weapons: Vec<sylpheed_formats::game_data::Weapon>,
    pub craft: Vec<sylpheed_formats::game_data::CraftUnit>,
    pub vessels: Vec<sylpheed_formats::game_data::Vessel>,
    pub characters: Vec<CharRow>,
    pub missions: Vec<MissionRow>,
    pub arsenal: sylpheed_formats::game_data::Arsenal,
    /// Distinct flight line-ups: `(callsign, pilot)` rows.
    pub flights: Vec<Vec<(String, String)>>,
}

/// The Game Data browser state (decoded combat / mission / cast tables).
#[derive(Resource, Default)]
pub struct GameData {
    pub open: bool,
    pub loading: bool,
    pub loaded: bool,
    pub category: GameCategory,
    pub filter: String,
    pub snapshot: GameSnapshot,
}

/// Ask the loader to decode the game-data tables.
#[derive(Event, Default)]
pub struct RequestGameData;

/// One assemblable ship in the Ships browser: an XBG7 part family reconstructed
/// into a whole model, with any linked [`sylpheed_formats::game_data::Vessel`]
/// stats.
#[derive(Clone)]
pub struct ShipRow {
    /// Resource-family id, e.g. `e106`.
    pub id: String,
    /// `ADAN` / `TCAF` / `Neutral` / `Other`.
    pub faction: String,
    /// Display name (from the linked Vessel, else the id).
    pub name: String,
    /// The stage container to render from, e.g. `hidden/resource3d/Stage_S02.xpr`.
    pub stage_file: String,
    /// Stage labels the ship appears in (`S02`, `S04`).
    pub stages: Vec<String>,
    /// Base part resource names to draw together.
    pub parts: Vec<String>,
    /// True when the id matched a `Vessel` recipe (a real capital ship).
    pub has_vessel: bool,
    pub hp: Option<f32>,
    pub size: Option<(f32, f32, f32)>,
    pub turrets: Option<i64>,
    pub bridges: Option<i64>,
    pub shield_gens: Option<i64>,
}

/// The Ships browser state — capital ships assembled from XBG7 part families.
#[derive(Resource)]
pub struct ShipBrowser {
    pub open: bool,
    pub loading: bool,
    pub loaded: bool,
    pub filter: String,
    /// Faction filter: "" = all, else "ADAN"/"TCAF"/"Neutral".
    pub faction: String,
    pub rows: Vec<ShipRow>,
    /// Family id of the ship currently rendered (for row highlight).
    pub selected: Option<String>,
    /// Also place the external parts: bridge / shield generators / the engine
    /// cluster rig / cross-mounted turrets. EXACT (capture-validated) since the
    /// node-TRS fix, so ON by default; off = bare hull bodies only.
    pub show_external: bool,
}

impl Default for ShipBrowser {
    fn default() -> Self {
        Self {
            open: false,
            loading: false,
            loaded: false,
            filter: String::new(),
            faction: String::new(),
            rows: Vec::new(),
            selected: None,
            show_external: true,
        }
    }
}

/// Ask the loader to scan the stage containers and build the ship catalog.
#[derive(Event, Default)]
pub struct RequestShipCatalog;

/// Ask the loader to assemble + render one ship's part family.
#[derive(Event)]
pub struct RequestShipRender {
    /// Stage container path to read the parts from.
    pub file: String,
    /// Ship family id (`e106`) — drives the scene-graph assembly.
    pub id: String,
    /// Also place the approximate external hardpoint parts.
    pub external: bool,
    /// Display label for the model info line.
    pub label: String,
}

/// The currently-open IPFB data pack, shown as a master-detail browser.
#[derive(Resource, Default)]
pub struct PakView {
    pub loaded: bool,
    pub name: String,
    pub rows: Vec<PakRow>,
    pub selected: Option<usize>,
    pub error: Option<String>,
    /// egui-side cache: (entry hash, textures) for the shown entry's image(s) —
    /// one for PNG/T8aD/font-sample, many for LSTA/RATC.
    pub img_tex: Option<(u32, Vec<egui::TextureHandle>)>,
}

/// Total decompressed bytes we're willing to decode when labelling a pack's
/// entries — bounds work on a huge `sound.pak`; entries past it show as
/// `(not decoded)`.
const PAK_DECODE_BUDGET: usize = 64 * 1024 * 1024;
/// Skip decoding any single entry whose stored size exceeds this (cheap guard
/// against a pathological entry, since `decompress_entry` has no output cap).
const PAK_ENTRY_COMP_CAP: u32 = 16 * 1024 * 1024;

/// Bounded decoded-frame queue between the ffmpeg reader thread and the main
/// thread. Also the back-pressure valve: when we stop draining (paused/behind),
/// the queue fills, the reader blocks on `send`, and ffmpeg blocks on its pipe.
#[cfg(not(target_arch = "wasm32"))]
const VIDEO_FRAME_QUEUE: usize = 8;

// ── SystemSet label ───────────────────────────────────────────────────────────

/// `draw_viewer_ui` runs `.after(IsoLoaderSystemSet)` to see the frame's
/// final state before rendering.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct IsoLoaderSystemSet;

// ── Native-only types ─────────────────────────────────────────────────────────

/// Messages sent from background threads back to the Bevy main thread.
#[cfg(not(target_arch = "wasm32"))]
enum IsoLoaderMsg {
    FilesListed {
        source: SourceKind,
        label: String,
        files: Vec<String>,
    },
    FileLoaded {
        path: String,
        bytes: Vec<u8>,
    },
    /// A `.pak` archive assembled + labelled off-thread (`error` set on failure).
    PakLoaded {
        name: String,
        rows: Vec<PakRow>,
        error: Option<String>,
    },
    /// A `.wmv` materialised to a temp file + probed (+ audio pre-decoded) off
    /// the main thread; the frame pipe is opened later in `apply_loaded_video`.
    VideoLoaded(Box<PreparedVideo>),
    /// An `XPR2` container fully decoded + prepared off the main thread (meshes
    /// built, textures decoded). `apply_prepared_xpr` only registers the result
    /// into Bevy `Assets`, so the heavy work never blocks the UI. `generation`
    /// lets a stale result (the user clicked something else) be discarded.
    XprPrepared {
        generation: u64,
        prepared: Box<PreparedXpr>,
    },
    /// Subtitles loaded for a cutscene (empty if the movie has none). `movie` +
    /// `generation` gate stale results when the user switches movie/language.
    SubtitlesLoaded {
        movie: String,
        lang: sylpheed_formats::SubLang,
        generation: u64,
        cues: Vec<sylpheed_formats::SubCue>,
    },
    /// Voice track decoded to a temp mono WAV (`wav` is `None` if the movie has
    /// no voice). `movie` + `generation` gate stale results.
    VoiceLoaded {
        movie: String,
        generation: u64,
        wav: Option<PathBuf>,
    },
    /// Standalone audio decoded (`wav` + duration seconds), or `None` on failure.
    AudioLoaded {
        name: String,
        generation: u64,
        wav: Option<(PathBuf, f32)>,
    },
    /// The voice-clip library enumerated from `sounds.tbl`.
    VoiceLibraryLoaded {
        clips: Vec<sylpheed_formats::slb::VoiceClip>,
    },
    /// The assembled-ship catalog for the Ships browser.
    ShipCatalogLoaded(Vec<ShipRow>),
    /// The decoded game-data tables for the browser.
    GameDataLoaded(Box<GameSnapshot>),
    /// User dismissed the file dialog — not an error.
    Cancelled,
    Error(String),
}

/// Result of decoding an `XPR2` container off the main thread. Bevy `Mesh` and
/// `Image` are CPU-side (no GPU handle until added to `Assets`), so they are
/// `Send` and can be fully built on a worker; the applier just registers them.
/// The CPU-side texture maps of one material, built off-thread. `_col` → albedo,
/// `_spc` (specular) → metallic and `_gls` (gloss) → roughness packed into one
/// linear metallic-roughness image (G=roughness, B=metallic), `_lum` → emissive.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default)]
pub struct PreparedMat {
    pub albedo: Option<Image>,
    pub metallic_roughness: Option<Image>,
    pub emissive: Option<Image>,
}

#[cfg(not(target_arch = "wasm32"))]
pub enum PreparedXpr {
    /// A 3D model / stage: meshes already baked into world space and merged by
    /// material, plus the per-material texture maps (slot 0 → neutral tint).
    Model {
        meshes: Vec<(Mesh, usize)>, // (mesh, material index)
        materials: Vec<PreparedMat>,
        focus: Vec3,
        radius: f32,
        min_radius: f32,
        max_radius: f32,
        name: String,
        info: String,
    },
    /// A 2D texture (`width`/`height` carried on the `Image`).
    Texture { image: Image, info: String },
    /// A world cubemap: six labelled faces.
    Cubemap {
        faces: Vec<(&'static str, Image, u32, u32)>,
        info: String,
    },
    /// Nothing previewable — just a status line (parse error / declined layout).
    Info(String),
    /// The decode was cancelled because a newer selection arrived.
    Cancelled,
}

/// A probed, temp-materialised video ready for playback. Boxed inside the
/// message so the enum stays small.
#[cfg(not(target_arch = "wasm32"))]
pub struct PreparedVideo {
    name: String,
    /// Temp `.wmv` on disk (ffmpeg reads it by path).
    temp_video: PathBuf,
    /// Temp stereo WAV of the audio track, or `None` (no audio / decode failed).
    temp_wav: Option<PathBuf>,
    width: u32,
    height: u32,
    fps: f32,
    duration: f32,
}

/// Channel resource. `Receiver<T>` is `!Sync`, so we wrap it in `Mutex`
/// to satisfy Bevy's `Resource: Send + Sync` bound.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Resource)]
struct IsoChannels {
    sender: mpsc::Sender<IsoLoaderMsg>,
    receiver: Mutex<mpsc::Receiver<IsoLoaderMsg>>,
}

#[cfg(not(target_arch = "wasm32"))]
impl Default for IsoChannels {
    fn default() -> Self {
        let (sender, receiver) = mpsc::channel();
        Self {
            sender,
            receiver: Mutex::new(receiver),
        }
    }
}

/// One-frame staging buffer: `poll_loader_channel` deposits raw bytes here;
/// `apply_loaded_texture` (chained after) consumes them.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Resource, Default)]
struct PendingFileBytes {
    ready: bool,
    path: String,
    bytes: Vec<u8>,
}

/// Native-only playback engine for the standalone [`AudioPreview`]. Held as a
/// `NonSend` resource because `rodio::OutputStream` is `!Send`.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default)]
struct AudioEngine {
    _stream: Option<rodio::OutputStream>,
    handle: Option<rodio::OutputStreamHandle>,
    sink: Option<rodio::Sink>,
    wav: Option<PathBuf>,
    applied_volume: f32,
    was_playing: bool,
}

/// One-frame staging buffer for a loaded pack; `apply_pak` (chained after)
/// consumes it, freeing the previous preview and populating `PakView`.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Resource, Default)]
struct PendingPak {
    ready: bool,
    name: String,
    rows: Vec<PakRow>,
    error: Option<String>,
}

/// One-frame staging buffer for a prepared video; `apply_loaded_video` (chained
/// after) consumes it, spawns the frame pipe + audio sink, and fills `VideoPreview`.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Resource, Default)]
struct PendingVideo {
    ready: bool,
    video: Option<Box<PreparedVideo>>,
}

/// Tracks the in-flight off-thread XPR decode so a new selection can supersede
/// it: each dispatch bumps `generation` and flips the previous `cancel` flag, so
/// the old worker aborts at the next resource boundary and its (now stale)
/// result is dropped by `apply_prepared_xpr`.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Resource, Default)]
struct XprLoadState {
    generation: u64,
    cancel: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}

/// One-frame staging buffer for a prepared XPR container; `apply_prepared_xpr`
/// consumes it and registers the meshes / textures into Bevy `Assets`.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Resource, Default)]
struct PendingXpr {
    ready: bool,
    generation: u64,
    prepared: Option<Box<PreparedXpr>>,
}

/// Native-only playback engine backing the current `VideoPreview`. Held as a
/// `NonSend` resource because `rodio::OutputStream` is `!Send`. `None` when no
/// video is playing.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default)]
struct VideoEngine {
    inner: Option<VideoEngineInner>,
}

#[cfg(not(target_arch = "wasm32"))]
struct VideoEngineInner {
    /// Decoded RGBA frames `(pts_seconds, bytes)` from the ffmpeg reader thread.
    frames: mpsc::Receiver<(f32, Vec<u8>)>,
    /// The running `ffmpeg … -f rawvideo` child (killed on seek/teardown).
    child: Child,
    width: u32,
    height: u32,
    fps: f32,
    temp_video: PathBuf,
    temp_wav: Option<PathBuf>,
    /// A frame pulled from the queue whose pts is still in the future — shown
    /// once the clock reaches it (so we never drop or mis-order frames).
    held: Option<(f32, Vec<u8>)>,
    /// Desired scrub positions → one-shot grabber thread (coalesced to latest).
    scrub_tx: mpsc::Sender<f32>,
    /// Grabbed scrub frames back from the grabber thread.
    scrub_frame_rx: mpsc::Receiver<Vec<u8>>,
    /// After a commit-seek, keep showing the last grabbed frame until the
    /// restarted streaming decoder produces its first frame (no black flash).
    awaiting_seek_frame: bool,
    // ── audio (present only when a temp WAV was produced) ──
    /// Kept alive to keep the audio device open; dropping it stops playback.
    _stream: Option<rodio::OutputStream>,
    stream_handle: Option<rodio::OutputStreamHandle>,
    sink: Option<rodio::Sink>,
    /// Last volume pushed to the sink, to avoid redundant `set_volume` calls.
    applied_volume: f32,
    /// Last `playing` state mirrored to the sink, to detect transitions.
    was_playing: bool,
    // ── voice-over track (the localized cutscene voice, a second sink) ──
    /// Decoded mono voice WAV (from `<lang>\Movie\VOICE_<movie>.slb`), if any.
    voice_wav: Option<PathBuf>,
    /// The voice sink, layered over the movie's own music+SFX. Muted (volume 0)
    /// when the voice toggle is off; otherwise mirrors play/pause/seek exactly.
    voice_sink: Option<rodio::Sink>,
    /// Last effective voice volume pushed (0.0 = disabled).
    voice_applied_volume: f32,
}

// ── Plugin ────────────────────────────────────────────────────────────────────

pub struct IsoLoaderPlugin;

impl Plugin for IsoLoaderPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<RequestOpenIso>()
            .add_event::<RequestOpenDir>()
            .add_event::<FileSelected>()
            .init_resource::<IsoState>()
            .init_resource::<TexturePreview>()
            .init_resource::<FileInfo>()
            // Registered unconditionally (even on wasm, where the load systems
            // below are cfg'd out) so the UI can always read them.
            .init_resource::<TextPreview>()
            .init_resource::<PakView>()
            .init_resource::<SkyboxPreview>()
            .init_resource::<ModelPreview>()
            .init_resource::<VideoPreview>()
            .init_resource::<MovieSubtitles>()
            .init_resource::<MovieVoice>()
            .init_resource::<AudioPreview>()
            .init_resource::<VoiceLibrary>()
            .init_resource::<GameData>()
            .init_resource::<ShipBrowser>()
            .add_event::<RequestSubtitles>()
            .add_event::<RequestVoice>()
            .add_event::<RequestAudio>()
            .add_event::<RequestVoiceLibrary>()
            .add_event::<RequestGameData>()
            .add_event::<RequestShipCatalog>()
            .add_event::<RequestShipRender>();

        #[cfg(not(target_arch = "wasm32"))]
        {
            app.init_resource::<IsoChannels>()
                .init_resource::<PendingFileBytes>()
                .init_resource::<PendingPak>()
                .init_resource::<PendingVideo>()
                .init_resource::<XprLoadState>()
                .init_resource::<PendingXpr>()
                .init_non_send_resource::<VideoEngine>()
                .init_non_send_resource::<AudioEngine>()
                .add_systems(
                    Update,
                    (
                        handle_open_iso,
                        handle_open_dir,
                        handle_file_selected,
                        poll_loader_channel,
                        apply_loaded_texture,
                        apply_prepared_xpr,
                        apply_pak,
                        apply_loaded_video,
                        advance_video_playback,
                        handle_subtitle_request,
                        handle_voice_request,
                        handle_audio_request,
                        advance_audio_playback,
                        handle_voice_library_request,
                        handle_game_data_request,
                        handle_ship_catalog_request,
                        handle_ship_render_request,
                    )
                        .chain()
                        .in_set(IsoLoaderSystemSet),
                );
        }
    }
}

// ── Systems (native-only) ─────────────────────────────────────────────────────

/// Listens for `RequestOpenIso`, opens a native file-picker in a background
/// thread, then lists all files in the selected ISO.
#[cfg(not(target_arch = "wasm32"))]
fn handle_open_iso(
    mut events: EventReader<RequestOpenIso>,
    mut iso_state: ResMut<IsoState>,
    channels: Res<IsoChannels>,
) {
    let count = events.read().count();
    if count == 0 || iso_state.loading {
        return;
    }

    iso_state.loading = true;
    iso_state.error = None;

    let sender = channels.sender.clone();
    std::thread::spawn(move || {
        let path = rfd::FileDialog::new()
            .set_title("Open Xbox 360 Disc Image")
            .add_filter("Xbox 360 ISO", &["iso", "xiso"])
            .pick_file();

        let Some(path) = path else {
            let _ = sender.send(IsoLoaderMsg::Cancelled);
            return;
        };

        let label = path.display().to_string();
        let iso_path = path.clone();

        let result = futures::executor::block_on(async move {
            let mut reader = sylpheed_formats::xiso::open_iso(&path)
                .await
                .map_err(|e| e.to_string())?;
            let files = reader
                .list_all_files()
                .await
                .map_err(|e| e.to_string())?;
            Ok::<_, String>((iso_path, label, files))
        });

        match result {
            Ok((iso_path, label, files)) => {
                let _ = sender.send(IsoLoaderMsg::FilesListed {
                    source: SourceKind::Iso(iso_path),
                    label,
                    files,
                });
            }
            Err(e) => {
                let _ = sender.send(IsoLoaderMsg::Error(e));
            }
        }
    });
}

/// Listens for `RequestOpenDir`, opens a folder picker, lists files via
/// `GameAssets` (synchronous).
#[cfg(not(target_arch = "wasm32"))]
fn handle_open_dir(
    mut events: EventReader<RequestOpenDir>,
    mut iso_state: ResMut<IsoState>,
    channels: Res<IsoChannels>,
) {
    let count = events.read().count();
    if count == 0 || iso_state.loading {
        return;
    }

    iso_state.loading = true;
    iso_state.error = None;

    let sender = channels.sender.clone();
    std::thread::spawn(move || {
        let path = rfd::FileDialog::new()
            .set_title("Open Extracted Game Directory")
            .pick_folder();

        let Some(path) = path else {
            let _ = sender.send(IsoLoaderMsg::Cancelled);
            return;
        };

        let label = path.display().to_string();
        let assets = sylpheed_formats::vfs::GameAssets::from_directory(&path);
        match assets.list("") {
            Ok(files) => {
                let _ = sender.send(IsoLoaderMsg::FilesListed {
                    source: SourceKind::Directory(path),
                    label,
                    files,
                });
            }
            Err(e) => {
                let _ = sender.send(IsoLoaderMsg::Error(e.to_string()));
            }
        }
    });
}

/// Listens for `FileSelected`, reads the file from the active source,
/// sends bytes back via the channel.
#[cfg(not(target_arch = "wasm32"))]
fn handle_file_selected(
    mut events: EventReader<FileSelected>,
    iso_state: Res<IsoState>,
    channels: Res<IsoChannels>,
) {
    for FileSelected(file_path) in events.read() {
        let file_path = file_path.clone();
        let sender = channels.sender.clone();

        // `.pak` archives need their index plus sibling `.pNN` segments, and the
        // decode is heavy — assemble + label off-thread, then hand the UI plain
        // rows. Everything else falls through to the single-file read below.
        if file_path.to_ascii_lowercase().ends_with(".pak") {
            let name = file_path.rsplit('/').next().unwrap_or(&file_path).to_string();
            let base = file_path[..file_path.len() - 4].to_string(); // strip ".pak"
            match &iso_state.source_kind {
                SourceKind::Iso(iso_path) => {
                    let iso_path = iso_path.clone();
                    let pak_path = file_path.clone();
                    std::thread::spawn(move || {
                        let result = futures::executor::block_on(async {
                            let mut reader = sylpheed_formats::xiso::open_iso(&iso_path)
                                .await
                                .map_err(|e| e.to_string())?;
                            let index = reader
                                .read_file(&pak_path)
                                .await
                                .map_err(|e| e.to_string())?;
                            let mut data = Vec::new();
                            for i in 0..100u32 {
                                match reader.read_file(&format!("{base}.p{i:02}")).await {
                                    Ok(mut b) => data.append(&mut b),
                                    Err(_) => break, // first gap = end of segments
                                }
                            }
                            if data.is_empty() {
                                return Err("no .pNN data segments found".to_string());
                            }
                            build_pak_rows(&index, data)
                        });
                        let (rows, error) = match result {
                            Ok(rows) => (rows, None),
                            Err(e) => (Vec::new(), Some(e)),
                        };
                        let _ = sender.send(IsoLoaderMsg::PakLoaded { name, rows, error });
                    });
                }
                SourceKind::Directory(root) => {
                    let root = root.clone();
                    let pak_path = file_path.clone();
                    std::thread::spawn(move || {
                        let assets = sylpheed_formats::vfs::GameAssets::from_directory(&root);
                        let result = (|| {
                            let index = assets.read(&pak_path).map_err(|e| e.to_string())?;
                            let mut data = Vec::new();
                            for i in 0..100u32 {
                                match assets.read(&format!("{base}.p{i:02}")) {
                                    Ok(mut b) => data.append(&mut b),
                                    Err(_) => break,
                                }
                            }
                            if data.is_empty() {
                                return Err("no .pNN data segments found".to_string());
                            }
                            build_pak_rows(&index, data)
                        })();
                        let (rows, error) = match result {
                            Ok(rows) => (rows, None),
                            Err(e) => (Vec::new(), Some(e)),
                        };
                        let _ = sender.send(IsoLoaderMsg::PakLoaded { name, rows, error });
                    });
                }
                SourceKind::None => {}
            }
            continue;
        }

        // `.wmv` cutscenes: read the bytes, spill to a temp file (ffmpeg needs a
        // real path), probe dimensions/duration, and pre-decode the audio track
        // to a temp WAV — all off-thread. The video frame pipe is opened later.
        if file_path.to_ascii_lowercase().ends_with(".wmv") {
            let name = file_path.rsplit('/').next().unwrap_or(&file_path).to_string();
            match &iso_state.source_kind {
                SourceKind::Iso(iso_path) => {
                    let iso_path = iso_path.clone();
                    let vid_path = file_path.clone();
                    std::thread::spawn(move || {
                        let result = futures::executor::block_on(async {
                            let mut reader = sylpheed_formats::xiso::open_iso(&iso_path)
                                .await
                                .map_err(|e| e.to_string())?;
                            reader.read_file(&vid_path).await.map_err(|e| e.to_string())
                        });
                        let msg = match result.and_then(|bytes| prepare_video(&name, bytes)) {
                            Ok(prepared) => IsoLoaderMsg::VideoLoaded(Box::new(prepared)),
                            Err(e) => IsoLoaderMsg::Error(e),
                        };
                        let _ = sender.send(msg);
                    });
                }
                SourceKind::Directory(root) => {
                    let root = root.clone();
                    let vid_path = file_path.clone();
                    std::thread::spawn(move || {
                        let assets = sylpheed_formats::vfs::GameAssets::from_directory(&root);
                        let msg = match assets
                            .read(&vid_path)
                            .map_err(|e| e.to_string())
                            .and_then(|bytes| prepare_video(&name, bytes))
                        {
                            Ok(prepared) => IsoLoaderMsg::VideoLoaded(Box::new(prepared)),
                            Err(e) => IsoLoaderMsg::Error(e),
                        };
                        let _ = sender.send(msg);
                    });
                }
                SourceKind::None => {}
            }
            continue;
        }

        match &iso_state.source_kind {
            SourceKind::Iso(iso_path) => {
                let iso_path = iso_path.clone();
                std::thread::spawn(move || {
                    let result = futures::executor::block_on(async {
                        let mut reader =
                            sylpheed_formats::xiso::open_iso(&iso_path)
                                .await
                                .map_err(|e| e.to_string())?;
                        let bytes = reader
                            .read_file(&file_path)
                            .await
                            .map_err(|e| e.to_string())?;
                        Ok::<_, String>((file_path, bytes))
                    });

                    match result {
                        Ok((path, bytes)) => {
                            let _ = sender
                                .send(IsoLoaderMsg::FileLoaded { path, bytes });
                        }
                        Err(e) => {
                            let _ = sender.send(IsoLoaderMsg::Error(e));
                        }
                    }
                });
            }
            SourceKind::Directory(root) => {
                let assets =
                    sylpheed_formats::vfs::GameAssets::from_directory(root);
                match assets.read(&file_path) {
                    Ok(bytes) => {
                        let _ = sender.send(IsoLoaderMsg::FileLoaded {
                            path: file_path,
                            bytes,
                        });
                    }
                    Err(e) => {
                        let _ = sender.send(IsoLoaderMsg::Error(e.to_string()));
                    }
                }
            }
            SourceKind::None => {}
        }
    }
}

/// Assemble a `PakArchive` from its index + concatenated segment data, then
/// build one `PakRow` per entry (decompressing + IDXD-parsing under the decode
/// budget). Runs on the loader thread; returns owned rows for the UI.
#[cfg(not(target_arch = "wasm32"))]
fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
    use sylpheed_formats::pak::inner_format_label;
    use sylpheed_formats::{IdxdObject, PakArchive};

    let arc = PakArchive::from_parts(index, data).map_err(|e| e.to_string())?;
    let mut rows = Vec::with_capacity(arc.entries().len());
    let mut decoded_budget: usize = 0;

    for e in arc.entries() {
        // Skip decoding oversized or over-budget entries — bounds a huge pack.
        if e.comp_size > PAK_ENTRY_COMP_CAP || decoded_budget > PAK_DECODE_BUDGET {
            rows.push(PakRow {
                hash: e.name_hash,
                comp_size: e.comp_size,
                size: e.comp_size as usize,
                format: "(not decoded)".into(),
                identity: String::new(),
                detail: None,
                content: PakContent::None,
            });
            continue;
        }
        match arc.read(e) {
            Ok(payload) => {
                decoded_budget += payload.len();
                let format = inner_format_label(&payload);
                let (identity, detail) = if IdxdObject::is_idxd(&payload) {
                    match IdxdObject::parse(&payload) {
                        Ok(obj) => {
                            let fields = obj
                                .resolved_fields()
                                .iter()
                                .map(|(k, v)| (k.to_string(), v.to_string()))
                                .collect();
                            (
                                obj.identity(),
                                Some(PakDetail {
                                    schema_hash: obj.schema_hash,
                                    count: obj.count,
                                    fields,
                                }),
                            )
                        }
                        Err(_) => (String::new(), None),
                    }
                } else {
                    (String::new(), None)
                };
                // Presentable content for non-IDXD entries (subtitle/font/png/text).
                let content = if detail.is_some() {
                    PakContent::None
                } else {
                    classify_content(&payload)
                };
                rows.push(PakRow {
                    hash: e.name_hash,
                    comp_size: e.comp_size,
                    size: payload.len(),
                    format,
                    identity,
                    detail,
                    content,
                });
            }
            Err(err) => rows.push(PakRow {
                hash: e.name_hash,
                comp_size: e.comp_size,
                size: e.comp_size as usize,
                format: "(read error)".into(),
                identity: err.to_string(),
                detail: None,
                content: PakContent::None,
            }),
        }
    }
    Ok(rows)
}

/// Cap on decoded text we retain per pack entry (xml/text preview).
#[cfg(not(target_arch = "wasm32"))]
const PAK_TEXT_CAP: usize = 512 * 1024;

/// Cap on decoded child-image texels per LSTA/RATC entry (~8 M texels = 32 MB
/// RGBA). Further children past it are listed but not decoded to an image.
#[cfg(not(target_arch = "wasm32"))]
const PAK_IMAGE_TEXEL_BUDGET: usize = 8 * 1024 * 1024;

/// Classify a (non-IDXD) decompressed entry into presentable content: subtitle
/// track, embedded font, PNG image, or plain text/XML. Runs off-thread.
#[cfg(not(target_arch = "wasm32"))]
fn classify_content(payload: &[u8]) -> PakContent {
    use sylpheed_formats::{font, ixud, vfs};

    if ixud::is_ixud(payload) {
        if let Some(sub) = ixud::parse(payload) {
            return PakContent::Subtitle(sub);
        }
    }
    if font::is_font(payload) {
        if let Some(info) = font::parse_info(payload) {
            // Rasterize a sample line here (off-thread) for single-face fonts —
            // never touch egui's global font system on the main thread.
            let sample = if info.faces <= 1 && info.kind != "TrueType Collection" {
                render_font_sample(payload, "The quick brown fox — 0123456789 !?&", 34.0)
            } else {
                None
            };
            return PakContent::Font { info, sample };
        }
    }
    if payload.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
        if let Ok(img) = image::load_from_memory_with_format(payload, image::ImageFormat::Png) {
            let rgba = img.to_rgba8();
            let (width, height) = rgba.dimensions();
            return PakContent::Png(ImageRgba {
                width,
                height,
                rgba: rgba.into_raw(),
            });
        }
    }
    if sylpheed_formats::t8ad::is_t8ad(payload) {
        if let Some(img) = sylpheed_formats::t8ad::parse(payload) {
            return PakContent::T8ad(ImageRgba::from_t8ad(img));
        }
    }
    if sylpheed_formats::lsta::is_lsta(payload) {
        if let Some(frames) = sylpheed_formats::lsta::parse(payload) {
            let mut budget = PAK_IMAGE_TEXEL_BUDGET;
            let out: Vec<ImageRgba> = frames
                .into_iter()
                .map(ImageRgba::from_t8ad)
                .take_while(|f| {
                    budget = budget.saturating_sub(f.pixels());
                    budget > 0
                })
                .collect();
            if !out.is_empty() {
                return PakContent::Lsta(out);
            }
        }
    }
    if sylpheed_formats::ratc::is_ratc(payload) {
        if let Some(children) = sylpheed_formats::ratc::parse(payload) {
            let mut budget = PAK_IMAGE_TEXEL_BUDGET;
            let entries: Vec<RatcEntry> = children
                .into_iter()
                .map(|c| {
                    // Decode T8aD children up to the pixel budget; list the rest.
                    let mut image = None;
                    if c.kind == "T8aD" && budget > 0 {
                        if let Some(img) = payload
                            .get(c.offset..c.offset + c.size)
                            .and_then(sylpheed_formats::t8ad::parse)
                            .map(ImageRgba::from_t8ad)
                        {
                            budget = budget.saturating_sub(img.pixels());
                            image = Some(img);
                        }
                    }
                    RatcEntry {
                        name: c.name,
                        kind: c.kind,
                        size: c.size,
                        image,
                    }
                })
                .collect();
            if !entries.is_empty() {
                return PakContent::Ratc(entries);
            }
        }
    }
    if payload.starts_with(b"<?xml") || vfs::identify_format(payload) == vfs::FileFormat::Text {
        let (mut text, encoding) = vfs::decode_text(payload);
        if text.len() > PAK_TEXT_CAP {
            let end = text
                .char_indices()
                .take_while(|(i, _)| *i < PAK_TEXT_CAP)
                .last()
                .map(|(i, c)| i + c.len_utf8())
                .unwrap_or(0);
            text.truncate(end);
        }
        return PakContent::Text {
            text,
            encoding: encoding.to_string(),
        };
    }
    PakContent::None
}

/// Rasterize a sample line with the embedded font into a white-on-transparent
/// RGBA image. Fully off-thread and panic-free: `ab_glyph` returns `None` for
/// unusable fonts/glyphs rather than unwrapping (unlike egui's global fonts).
#[cfg(not(target_arch = "wasm32"))]
fn render_font_sample(bytes: &[u8], text: &str, px: f32) -> Option<ImageRgba> {
    use ab_glyph::{point, Font, FontRef, ScaleFont};

    let font = FontRef::try_from_slice(bytes).ok()?;
    let scaled = font.as_scaled(px);
    let (ascent, descent) = (scaled.ascent(), scaled.descent());
    let pad = 2.0;

    // Lay glyphs left-to-right on a single baseline.
    let mut caret = pad;
    let mut glyphs = Vec::new();
    for ch in text.chars() {
        let id = font.glyph_id(ch);
        glyphs.push(id.with_scale_and_position(px, point(caret, pad + ascent)));
        caret += scaled.h_advance(id);
    }
    let width = (caret + pad).ceil().max(1.0) as u32;
    let height = (pad * 2.0 + ascent - descent).ceil().max(1.0) as u32;
    if width > 8192 || height > 512 {
        return None; // sanity bound
    }

    let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4];
    let mut drew_any = false;
    for g in glyphs {
        if let Some(outline) = font.outline_glyph(g) {
            let bb = outline.px_bounds();
            outline.draw(|gx, gy, cov| {
                let x = bb.min.x as i32 + gx as i32;
                let y = bb.min.y as i32 + gy as i32;
                if x >= 0 && y >= 0 && (x as u32) < width && (y as u32) < height {
                    let i = ((y as u32 * width + x as u32) * 4) as usize;
                    let a = (cov * 255.0) as u8;
                    rgba[i] = 255;
                    rgba[i + 1] = 255;
                    rgba[i + 2] = 255;
                    rgba[i + 3] = rgba[i + 3].max(a);
                    drew_any = true;
                }
            });
        }
    }
    drew_any.then_some(ImageRgba {
        width,
        height,
        rgba,
    })
}

// ── Video preparation (loader thread) ─────────────────────────────────────────

/// Make a filesystem-safe stem for temp files from a leaf name.
#[cfg(not(target_arch = "wasm32"))]
fn sanitize_stem(name: &str) -> String {
    name.chars()
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
        .collect()
}

/// Spill the video bytes to a temp file, probe it, and pre-decode its audio.
/// Runs on the loader thread. Maps a missing `ffmpeg`/`ffprobe` to a friendly
/// error so the UI degrades gracefully instead of panicking.
#[cfg(not(target_arch = "wasm32"))]
fn prepare_video(name: &str, bytes: Vec<u8>) -> Result<PreparedVideo, String> {
    let dir = std::env::temp_dir();
    let stem = format!("sylph_vid_{}_{}", std::process::id(), sanitize_stem(name));
    let temp_video = dir.join(format!("{stem}.wmv"));
    std::fs::write(&temp_video, &bytes)
        .map_err(|e| format!("writing temp video: {e}"))?;

    let (width, height, fps, duration) = ffprobe_video(&temp_video)?;

    // Audio is best-effort: decode the track to a temp stereo WAV so rodio can
    // play it with volume/seek. Failure (no track / codec issue) → silent video.
    let temp_wav = if ffprobe_has_audio(&temp_video) {
        let wav = dir.join(format!("{stem}.wav"));
        match decode_audio_wav(&temp_video, &wav) {
            Ok(()) => Some(wav),
            Err(e) => {
                warn!("audio pre-decode failed ({name}): {e}");
                None
            }
        }
    } else {
        None
    };

    Ok(PreparedVideo {
        name: name.to_string(),
        temp_video,
        temp_wav,
        width,
        height,
        fps,
        duration,
    })
}

/// Run a command to completion, mapping a not-found spawn error to a friendly
/// "install ffmpeg" message and a non-zero exit to its stderr.
#[cfg(not(target_arch = "wasm32"))]
fn run_tool(cmd: &mut Command, tool: &str) -> Result<std::process::Output, String> {
    let out = cmd.output().map_err(|e| {
        if e.kind() == std::io::ErrorKind::NotFound {
            format!("`{tool}` not found in PATH — install FFmpeg to play videos")
        } else {
            format!("running `{tool}`: {e}")
        }
    })?;
    if !out.status.success() {
        return Err(format!(
            "`{tool}` failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        ));
    }
    Ok(out)
}

/// Probe width/height/fps/duration of the first video stream via `ffprobe`.
#[cfg(not(target_arch = "wasm32"))]
fn ffprobe_video(path: &Path) -> Result<(u32, u32, f32, f32), String> {
    let out = run_tool(
        Command::new("ffprobe")
            .arg("-v")
            .arg("error")
            .arg("-select_streams")
            .arg("v:0")
            .arg("-show_entries")
            .arg("stream=width,height,avg_frame_rate:format=duration")
            .arg("-of")
            .arg("default=noprint_wrappers=1")
            .arg(path),
        "ffprobe",
    )?;
    let text = String::from_utf8_lossy(&out.stdout);
    let (mut w, mut h, mut fps, mut dur) = (0u32, 0u32, 30.0f32, 0.0f32);
    for line in text.lines() {
        let Some((k, v)) = line.split_once('=') else { continue };
        match k.trim() {
            "width" => w = v.trim().parse().unwrap_or(0),
            "height" => h = v.trim().parse().unwrap_or(0),
            "avg_frame_rate" => {
                // "30/1" (or "0/0" for VFR/unknown → keep the 30 fps default).
                if let Some((n, d)) = v.trim().split_once('/') {
                    let (n, d) = (n.parse::<f32>().unwrap_or(0.0), d.parse::<f32>().unwrap_or(0.0));
                    if n > 0.0 && d > 0.0 {
                        fps = n / d;
                    }
                }
            }
            "duration" => dur = v.trim().parse().unwrap_or(0.0),
            _ => {}
        }
    }
    if w == 0 || h == 0 {
        return Err("ffprobe: no video stream / zero dimensions".to_string());
    }
    Ok((w, h, fps, dur))
}

/// True if the file has at least one audio stream.
#[cfg(not(target_arch = "wasm32"))]
fn ffprobe_has_audio(path: &Path) -> bool {
    Command::new("ffprobe")
        .arg("-v")
        .arg("error")
        .arg("-select_streams")
        .arg("a")
        .arg("-show_entries")
        .arg("stream=index")
        .arg("-of")
        .arg("csv=p=0")
        .arg(path)
        .output()
        .map(|o| o.status.success() && !o.stdout.iter().all(u8::is_ascii_whitespace))
        .unwrap_or(false)
}

/// Decode the audio track to a temp stereo 48 kHz WAV (downmixing surround).
#[cfg(not(target_arch = "wasm32"))]
fn decode_audio_wav(video: &Path, wav: &Path) -> Result<(), String> {
    run_tool(
        Command::new("ffmpeg")
            .arg("-v")
            .arg("error")
            .arg("-y")
            .arg("-i")
            .arg(video)
            .arg("-vn")
            .arg("-ac")
            .arg("2")
            .arg("-ar")
            .arg("48000")
            .arg(wav),
        "ffmpeg",
    )
    .map(|_| ())
}

// ── Video decode + audio (main thread) ────────────────────────────────────────

/// Spawn an `ffmpeg` process decoding to raw RGBA on stdout, plus a reader
/// thread that chunks it into frames and forwards `(pts, bytes)` over a bounded
/// channel. Optional `-ss start` seeks the input; output pts re-base to 0, so we
/// add `start` back to each frame's timestamp.
#[cfg(not(target_arch = "wasm32"))]
fn spawn_video_decoder(
    path: &Path,
    w: u32,
    h: u32,
    fps: f32,
    start: f32,
) -> Result<(Child, mpsc::Receiver<(f32, Vec<u8>)>), String> {
    let mut cmd = Command::new("ffmpeg");
    cmd.arg("-v").arg("error");
    if start > 0.0 {
        cmd.arg("-ss").arg(format!("{start:.3}"));
    }
    cmd.arg("-i")
        .arg(path)
        .arg("-f")
        .arg("rawvideo")
        .arg("-pix_fmt")
        .arg("rgba")
        .arg("-vsync")
        .arg("0")
        .arg("pipe:1")
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::null());

    let mut child = cmd.spawn().map_err(|e| {
        if e.kind() == std::io::ErrorKind::NotFound {
            "`ffmpeg` not found in PATH — install FFmpeg to play videos".to_string()
        } else {
            format!("spawning ffmpeg: {e}")
        }
    })?;

    let mut stdout = child.stdout.take().expect("piped stdout");
    let frame_len = (w as usize) * (h as usize) * 4;
    // Bounded: when the main thread stops draining, `send` blocks and ffmpeg
    // back-pressures on its pipe (natural pause / flow control).
    let (tx, rx) = mpsc::sync_channel::<(f32, Vec<u8>)>(VIDEO_FRAME_QUEUE);
    std::thread::spawn(move || {
        let mut idx: u64 = 0;
        loop {
            let mut buf = vec![0u8; frame_len];
            if stdout.read_exact(&mut buf).is_err() {
                break; // EOF or the child was killed
            }
            let pts = start + idx as f32 / fps.max(1.0);
            idx += 1;
            if tx.send((pts, buf)).is_err() {
                break; // receiver dropped (video closed / seeking)
            }
        }
    });
    Ok((child, rx))
}

/// Build a rodio `Sink` playing `wav` from `start` seconds, at `volume`, paused
/// unless `playing`. Returns `None` on any audio failure (video still plays).
#[cfg(not(target_arch = "wasm32"))]
fn build_audio_sink(
    handle: &rodio::OutputStreamHandle,
    wav: &Path,
    start: f32,
    volume: f32,
    playing: bool,
) -> Option<rodio::Sink> {
    use rodio::Source;
    let file = std::fs::File::open(wav).ok()?;
    let decoder = rodio::Decoder::new(std::io::BufReader::new(file)).ok()?;
    let sink = rodio::Sink::try_new(handle).ok()?;
    if start > 0.0 {
        sink.append(decoder.skip_duration(std::time::Duration::from_secs_f32(start)));
    } else {
        sink.append(decoder);
    }
    sink.set_volume(volume);
    if !playing {
        sink.pause();
    }
    Some(sink)
}

/// Extract a single RGBA frame at `t` seconds via a fast one-shot ffmpeg seek.
/// Much cheaper than restarting the streaming decoder — used for live scrubbing.
#[cfg(not(target_arch = "wasm32"))]
fn grab_one_frame(path: &Path, t: f32, frame_len: usize) -> Option<Vec<u8>> {
    let mut child = Command::new("ffmpeg")
        .arg("-v")
        .arg("error")
        .arg("-ss")
        .arg(format!("{t:.3}"))
        .arg("-i")
        .arg(path)
        .arg("-frames:v")
        .arg("1")
        .arg("-f")
        .arg("rawvideo")
        .arg("-pix_fmt")
        .arg("rgba")
        .arg("pipe:1")
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .ok()?;
    let mut out = child.stdout.take()?;
    let mut buf = vec![0u8; frame_len];
    let ok = out.read_exact(&mut buf).is_ok();
    let _ = child.wait();
    ok.then_some(buf)
}

/// The scrub grabber: waits for requested positions, **coalesces to the most
/// recent** (so fast dragging skips stale targets), grabs that frame, and sends
/// it back. One ffmpeg at a time; exits when the request channel closes.
#[cfg(not(target_arch = "wasm32"))]
fn scrub_worker(
    rx: mpsc::Receiver<f32>,
    tx: mpsc::Sender<Vec<u8>>,
    path: PathBuf,
    w: u32,
    h: u32,
) {
    let frame_len = (w as usize) * (h as usize) * 4;
    while let Ok(mut t) = rx.recv() {
        while let Ok(newer) = rx.try_recv() {
            t = newer; // jump to the latest requested position
        }
        if let Some(frame) = grab_one_frame(&path, t, frame_len) {
            if tx.send(frame).is_err() {
                break; // main side gone
            }
        }
    }
}

/// Polls the mpsc channel, updating `IsoState`, `FileBrowserState`, and
/// `PendingFileBytes` as messages arrive.
#[cfg(not(target_arch = "wasm32"))]
fn poll_loader_channel(
    channels: Res<IsoChannels>,
    mut iso_state: ResMut<IsoState>,
    mut browser: ResMut<FileBrowserState>,
    mut pending: ResMut<PendingFileBytes>,
    mut pending_pak: ResMut<PendingPak>,
    mut pending_video: ResMut<PendingVideo>,
    mut pending_xpr: ResMut<PendingXpr>,
    mut subtitles: ResMut<MovieSubtitles>,
    mut mvoice: ResMut<MovieVoice>,
    mut audio_preview: ResMut<AudioPreview>,
    mut voice_lib: ResMut<VoiceLibrary>,
    mut game_data: ResMut<GameData>,
    mut ships: ResMut<ShipBrowser>,
) {
    let receiver = channels.receiver.lock().unwrap();
    loop {
        use std::sync::mpsc::TryRecvError;
        match receiver.try_recv() {
            Ok(IsoLoaderMsg::FilesListed {
                source,
                label,
                files,
            }) => {
                iso_state.loading = false;
                iso_state.error = None;
                iso_state.source_label = Some(label);
                iso_state.source_kind = source;
                browser.files = files;
                browser.selected = None;
                browser.filter.clear();
                // A new game source is loaded — invalidate the voice library so the
                // browser re-reads sounds.tbl from THIS source next time it opens
                // (otherwise a stale/failed empty result from before load persists).
                *voice_lib = VoiceLibrary::default();
                info!("Loaded {} files", browser.files.len());
            }
            Ok(IsoLoaderMsg::FileLoaded { path, bytes }) => {
                pending.path = path;
                pending.bytes = bytes;
                pending.ready = true;
                browser.loading = false;
            }
            Ok(IsoLoaderMsg::PakLoaded { name, rows, error }) => {
                pending_pak.name = name;
                pending_pak.rows = rows;
                pending_pak.error = error;
                pending_pak.ready = true;
                browser.loading = false;
            }
            Ok(IsoLoaderMsg::VideoLoaded(prepared)) => {
                pending_video.video = Some(prepared);
                pending_video.ready = true;
                browser.loading = false;
            }
            Ok(IsoLoaderMsg::XprPrepared {
                generation,
                prepared,
            }) => {
                pending_xpr.generation = generation;
                pending_xpr.prepared = Some(prepared);
                pending_xpr.ready = true;
                // browser.loading stays true here; `apply_prepared_xpr` clears it
                // once the (non-stale) result is registered.
            }
            Ok(IsoLoaderMsg::SubtitlesLoaded {
                movie,
                lang,
                generation,
                cues,
            }) => {
                // Ignore results for a movie/language the user has moved on from.
                if generation == subtitles.generation
                    && subtitles.movie.as_deref() == Some(movie.as_str())
                    && subtitles.lang == lang
                {
                    subtitles.cues = cues;
                    subtitles.loading = false;
                }
            }
            Ok(IsoLoaderMsg::VoiceLoaded {
                movie,
                generation,
                wav,
            }) => {
                let accept = generation == mvoice.generation
                    && mvoice.movie.as_deref() == Some(movie.as_str());
                info!(
                    "[voice] loaded movie={movie:?} gen={generation} (cur movie={:?} gen={}) accept={accept} wav={wav:?}",
                    mvoice.movie, mvoice.generation
                );
                if accept {
                    mvoice.loading = false;
                    mvoice.available = wav.is_some();
                    mvoice.pending_wav = wav;
                }
            }
            Ok(IsoLoaderMsg::AudioLoaded {
                name,
                generation,
                wav,
            }) => {
                if generation == audio_preview.generation {
                    audio_preview.loading = false;
                    match wav {
                        Some((p, dur)) => {
                            audio_preview.name = name;
                            audio_preview.pending = Some((p, dur));
                        }
                        None => audio_preview.active = false,
                    }
                }
            }
            Ok(IsoLoaderMsg::VoiceLibraryLoaded { clips }) => {
                voice_lib.loading = false;
                // sounds.tbl always has thousands of clips, so an empty result means
                // the read failed (e.g. no game source, or opened only a bare pak).
                // Leave `loaded=false` so re-opening the menu retries instead of
                // latching an empty list forever.
                if clips.is_empty() {
                    voice_lib.loaded = false;
                } else {
                    voice_lib.clips = clips;
                    voice_lib.loaded = true;
                }
            }
            Ok(IsoLoaderMsg::GameDataLoaded(snap)) => {
                game_data.loading = false;
                // An empty snapshot means the decode failed (no game source, or a
                // bare pak) — leave `loaded=false` so re-opening retries.
                let empty = snap.weapons.is_empty() && snap.craft.is_empty() && snap.missions.is_empty();
                if empty {
                    game_data.loaded = false;
                } else {
                    game_data.snapshot = *snap;
                    game_data.loaded = true;
                }
            }
            Ok(IsoLoaderMsg::ShipCatalogLoaded(rows)) => {
                ships.loading = false;
                ships.loaded = !rows.is_empty();
                ships.rows = rows;
            }
            Ok(IsoLoaderMsg::Cancelled) => {
                iso_state.loading = false;
                browser.loading = false;
            }
            Ok(IsoLoaderMsg::Error(msg)) => {
                error!("ISO loader: {}", msg);
                iso_state.loading = false;
                iso_state.error = Some(msg);
                browser.loading = false;
            }
            Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
        }
    }
}

/// Free the current texture preview's GPU image + egui slot and reset it.
#[cfg(not(target_arch = "wasm32"))]
fn free_texture(
    preview: &mut TexturePreview,
    images: &mut Assets<Image>,
    contexts: &mut bevy_egui::EguiContexts<'_, '_>,
) {
    if let Some(ref handle) = preview.handle {
        contexts.remove_image(handle);
        images.remove(handle.id());
    }
    *preview = TexturePreview::default();
}

/// Free the current skybox preview's per-face GPU images + egui slots and reset it.
#[cfg(not(target_arch = "wasm32"))]
fn free_skybox(
    skybox: &mut SkyboxPreview,
    images: &mut Assets<Image>,
    contexts: &mut bevy_egui::EguiContexts<'_, '_>,
) {
    for f in &skybox.faces {
        contexts.remove_image(&f.handle);
        images.remove(f.handle.id());
    }
    *skybox = SkyboxPreview::default();
}

/// Stop and tear down any playing video: kill ffmpeg, drop the audio sink +
/// output stream, release the frame texture, and delete the temp files.
#[cfg(not(target_arch = "wasm32"))]
fn free_video(
    video: &mut VideoPreview,
    engine: &mut VideoEngine,
    images: &mut Assets<Image>,
    contexts: &mut bevy_egui::EguiContexts<'_, '_>,
) {
    if let Some(ref handle) = video.handle {
        contexts.remove_image(handle);
        images.remove(handle.id());
    }
    if let Some(inner) = engine.inner.take() {
        // Drop audio first (stops the device), then kill ffmpeg; dropping the
        // receiver unblocks the reader thread's `send` so it exits.
        drop(inner.sink);
        drop(inner.voice_sink);
        drop(inner._stream);
        let mut child = inner.child;
        let _ = child.kill();
        let _ = child.wait();
        drop(inner.frames);
        let _ = std::fs::remove_file(&inner.temp_video);
        if let Some(w) = &inner.temp_wav {
            let _ = std::fs::remove_file(w);
        }
        if let Some(w) = &inner.voice_wav {
            let _ = std::fs::remove_file(w);
        }
    }
    *video = VideoPreview::default();
}

/// Reduce a sub-model / resource name to its **entity stem** — the leading
/// `<letters><digits>` id that its textures are named after. XBG7 resource names
/// carry LOD / part / pose decoration (`e_rou_e007_Near`, `e007_bdy_01_l`,
/// `f001_bdy_02`) while the matching albedo map is named for the bare entity
/// (`e007_col`, `f001_bdy_01a_col`). Stripping to the stem (`e007`, `f001`) is
/// what lets the two be matched.
fn entity_stem(name: &str) -> String {
    let mut s = name.trim_start_matches('_');
    // Leading single-letter group prefix (`e_`, `g_`, `j_`, `n_`) then `rou_`.
    for p in ["e_", "g_", "j_", "n_"] {
        if let Some(r) = s.strip_prefix(p) {
            s = r;
            break;
        }
    }
    if let Some(r) = s.strip_prefix("rou_") {
        s = r;
    }
    // Take the leading <letters><digits> token (e.g. `e007`, `f001`).
    let b = s.as_bytes();
    let mut i = 0;
    while i < b.len() && b[i].is_ascii_alphabetic() {
        i += 1;
    }
    let mut j = i;
    while j < b.len() && b[j].is_ascii_digit() {
        j += 1;
    }
    if i > 0 && j > i {
        s[..j].to_ascii_lowercase()
    } else {
        s.to_ascii_lowercase()
    }
}

/// Pick the index of the albedo (`_col`) texture for a sub-model, matching its
/// [`entity_stem`] against the container's texture names. Returns the best `_col`
/// candidate (exact `<stem>_col`, else shortest `<stem>…` name, else any name
/// mentioning the stem) or `None` when the entity has no own colour map (it uses
/// a shared atlas we can't resolve — the caller shows a neutral tint instead of a
/// wrong texture). This replaces the old `ends_with("_col") && starts_with(core)`
/// rule, which only matched ~25% of stage sub-models (the rest fell back to flat
/// grey — "models render without colour").
fn pick_albedo_index(model_name: &str, tex_names: &[String]) -> Option<usize> {
    let stem = entity_stem(model_name);
    if stem.is_empty() {
        return None;
    }
    let cols: Vec<(usize, String)> = tex_names
        .iter()
        .enumerate()
        .map(|(i, n)| (i, n.to_ascii_lowercase()))
        .filter(|(_, n)| n.contains("_col"))
        .collect();
    // 1. Exact `<stem>_col`.
    let exact = format!("{stem}_col");
    if let Some((i, _)) = cols.iter().find(|(_, n)| *n == exact) {
        return Some(*i);
    }
    // 2. Names starting with the stem. Prefer the model's PRIMARY body map
    //    (`…_bdy_01a_col`, the 1024² main texture) over the smaller secondary
    //    maps: multi-part ship bodies name several `_col` textures, and the old
    //    "shortest name" rule picked a tiny 128² part map (`f001_bdy_02_col`) for
    //    the whole hull → visibly pixelated. Rank `01a` first, then any `01`,
    //    then fall back to the shortest (closest) name.
    if let Some((i, _)) = cols
        .iter()
        .filter(|(_, n)| n.starts_with(&stem))
        .min_by_key(|(_, n)| {
            let rank = if n.contains("01a") {
                0
            } else if n.contains("01") {
                1
            } else {
                2
            };
            (rank, n.len())
        })
    {
        return Some(*i);
    }
    // 3. Stem mentioned anywhere in a colour map's name.
    cols.iter().find(|(_, n)| n.contains(&stem)).map(|(i, _)| *i)
}

/// Per-vertex smooth normals = normalized sum of incident face normals.
#[cfg(not(target_arch = "wasm32"))]
fn compute_smooth_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
    let mut acc = vec![Vec3::ZERO; positions.len()];
    for tri in indices.chunks_exact(3) {
        let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
        if a >= positions.len() || b >= positions.len() || c >= positions.len() {
            continue;
        }
        let pa = Vec3::from_array(positions[a]);
        let pb = Vec3::from_array(positions[b]);
        let pc = Vec3::from_array(positions[c]);
        let n = (pb - pa).cross(pc - pa);
        acc[a] += n;
        acc[b] += n;
        acc[c] += n;
    }
    acc.into_iter()
        .map(|n| n.normalize_or_zero().to_array())
        .map(|n| if n == [0.0, 0.0, 0.0] { [0.0, 1.0, 0.0] } else { n })
        .collect()
}

/// Converts pending raw bytes into a Bevy `Image`, registers it with egui,
/// and populates `TexturePreview` / `SkyboxPreview` / `FileInfo`.
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::too_many_arguments)]
fn apply_loaded_texture(
    mut pending: ResMut<PendingFileBytes>,
    mut preview: ResMut<TexturePreview>,
    mut text_preview: ResMut<TextPreview>,
    mut pak_view: ResMut<PakView>,
    mut skybox: ResMut<SkyboxPreview>,
    mut video: ResMut<VideoPreview>,
    mut engine: NonSendMut<VideoEngine>,
    mut file_info: ResMut<FileInfo>,
    mut images: ResMut<Assets<Image>>,
    mut contexts: bevy_egui::EguiContexts,
    mut commands: Commands,
    mut model_preview: ResMut<ModelPreview>,
    old_models: Query<Entity, With<PreviewModel>>,
    channels: Res<IsoChannels>,
    mut xpr_load: ResMut<XprLoadState>,
    mut browser: ResMut<FileBrowserState>,
) {
    if !pending.ready {
        return;
    }
    pending.ready = false;

    let bytes = std::mem::take(&mut pending.bytes);
    let path = std::mem::take(&mut pending.path);

    // Any new selection supersedes an in-flight XPR decode: bump the generation
    // (so a late worker result is discarded) and flip the previous cancel flag
    // (so the worker aborts at its next resource boundary). Done for *every* file
    // type, not just XPR, so switching to e.g. a text file also cancels a stage.
    xpr_load.generation = xpr_load.generation.wrapping_add(1);
    if let Some(prev) = xpr_load.cancel.take() {
        prev.store(true, Ordering::Relaxed);
    }

    let fmt = sylpheed_formats::vfs::identify_format(&bytes);

    // Always free the previous previews (GPU memory + egui slots) so exactly one
    // viewer is active per selection.
    free_texture(&mut preview, &mut images, &mut contexts);
    free_skybox(&mut skybox, &mut images, &mut contexts);
    free_video(&mut video, &mut engine, &mut images, &mut contexts);
    *text_preview = TextPreview::default();
    *pak_view = PakView::default();

    // Despawn any previously-previewed 3D model.
    for e in old_models.iter() {
        commands.entity(e).despawn_recursive();
    }
    *model_preview = ModelPreview::default();

    // Populate FileInfo for the status / info panel.
    file_info.name = path
        .split('/')
        .next_back()
        .unwrap_or(&path)
        .to_string();
    file_info.size_bytes = bytes.len();
    file_info.detected_format = Some(fmt);

    if fmt == FileFormat::Text {
        let (mut text, encoding) = sylpheed_formats::vfs::decode_text(&bytes);
        let truncated = text.len() > MAX_TEXT_BYTES;
        if truncated {
            // Cut on a char boundary so we never split a codepoint.
            let end = text
                .char_indices()
                .take_while(|(i, _)| *i < MAX_TEXT_BYTES)
                .last()
                .map(|(i, c)| i + c.len_utf8())
                .unwrap_or(0);
            text.truncate(end);
        }
        text_preview.content = Some(text);
        text_preview.encoding = encoding.to_string();
        text_preview.truncated = truncated;
        return; // FileInfo + TextPreview are sufficient for text files.
    }

    if fmt != FileFormat::Xpr2Texture {
        return; // Not a texture / model — FileInfo is sufficient.
    }

    // XPR2 containers (a 2D texture, a world cubemap, a single model, or a stage
    // with hundreds of sub-models) can take *seconds* to decode + prepare. Doing
    // that on this system froze the whole app (OS "not responding", no spinner).
    // Instead hand the bytes to a worker that does ALL the heavy work — geometry
    // decode, transform baking, texture decode, mesh building — and returns a
    // ready-to-register `PreparedXpr`; `apply_prepared_xpr` only touches `Assets`.
    // The generation was already bumped (and any prior worker cancelled) above.
    let generation = xpr_load.generation;
    let cancel = Arc::new(AtomicBool::new(false));
    xpr_load.cancel = Some(cancel.clone());
    browser.loading = true; // keep the spinner up until the result is applied
    let sender = channels.sender.clone();
    std::thread::spawn(move || {
        let prepared = prepare_xpr(&bytes, &cancel);
        let _ = sender.send(IsoLoaderMsg::XprPrepared {
            generation,
            prepared: Box::new(prepared),
        });
    });
}

/// Decode + fully prepare an `XPR2` container **off the main thread**: geometry
/// decode (abortable), transform baking, per-material merge, texture decode, and
/// Bevy `Mesh`/`Image` construction. Everything here is CPU-only and `Send`; the
/// result is registered into `Assets` by [`apply_prepared_xpr`] on the main
/// thread. `cancel` is polled between stage resources so a new selection aborts.
#[cfg(not(target_arch = "wasm32"))]
fn prepare_xpr(bytes: &[u8], cancel: &Arc<AtomicBool>) -> PreparedXpr {
    use sylpheed_formats::mesh::{count_xbg7, Xbg7Model};
    let should_cancel = || cancel.load(Ordering::Relaxed);

    // ── 3D geometry? Route by XBG7 resource count (see the CLI `decode_models`):
    // >1 → a stage collection (content-anchored, abortable); 1 → a single model
    // via the validated decode, else the strict content-anchor fallback. ──
    let models: Vec<Xbg7Model> = if count_xbg7(bytes) > 1 {
        Xbg7Model::stage_models_cancellable(bytes, &should_cancel)
    } else {
        match Xbg7Model::from_xpr2(bytes) {
            Ok(m) if !m.meshes.is_empty() => vec![m],
            _ => Xbg7Model::anchor_models_cancellable(bytes, 0.85, &should_cancel),
        }
    };
    if should_cancel() {
        return PreparedXpr::Cancelled;
    }
    if !models.is_empty() {
        // A ship file is a base pose + `_mnv*`/`_turn180` animation poses of the
        // SAME entity. Draw ONLY the base pose (not a 5-copy grid of the poses).
        if let Some((base, _poses)) = detect_pose_set(&models) {
            return prepare_models(bytes, std::slice::from_ref(&models[base]));
        }
        return prepare_models(bytes, &models);
    }

    // ── World cubemap (TXCM): decode all six faces. ──
    match sylpheed_formats::texture::X360Texture::cube_faces_from_xpr2(bytes) {
        Ok(Some(cube)) => {
            use sylpheed_formats::texture::{Cubemap, X360Texture};
            let mut faces = Vec::new();
            for (i, face) in cube.faces.iter().enumerate() {
                let face_tex = X360Texture {
                    width: cube.width,
                    height: cube.height,
                    format: cube.format,
                    mip_levels: 1,
                    is_cubemap: false,
                    data: face.clone(),
                };
                if let Ok(img) = crate::asset_loader::x360_texture_to_bevy_image(face_tex) {
                    faces.push((Cubemap::face_label(i), img, cube.width, cube.height));
                }
            }
            let info = format!(
                "Cubemap  {}×{}  {:?}  ·  {} faces",
                cube.width,
                cube.height,
                cube.format,
                faces.len()
            );
            return PreparedXpr::Cubemap { faces, info };
        }
        Ok(None) => {} // ordinary 2D texture
        Err(e) => return PreparedXpr::Info(format!("Cubemap parse failed: {e}")),
    }

    // ── Ordinary 2D texture. ──
    let tex = match sylpheed_formats::X360Texture::from_xpr2(bytes) {
        Ok(t) => t,
        Err(e) => return PreparedXpr::Info(format!("XPR2 parse failed: {e}")),
    };
    let info = format!(
        "{:?}  {}×{}  {} mip(s)",
        tex.format, tex.width, tex.height, tex.mip_levels
    );
    match crate::asset_loader::x360_texture_to_bevy_image(tex) {
        Ok(image) => PreparedXpr::Texture { image, info },
        Err(e) => PreparedXpr::Info(format!("Bevy image conversion failed: {e}")),
    }
}

/// Bake decoded [`Xbg7Model`]s into world-space Bevy meshes merged by material.
/// A single model is centred at the origin; several (a stage) are laid out in a
/// thumbnail grid, each recentred and uniformly scaled to a fixed cell so all are
/// browsable regardless of native size. Runs on the worker thread.
/// Pack a specular (`_spc`) and gloss (`_gls`) map into a single linear
/// metallic-roughness image (Bevy samples G=roughness, B=metallic): metallic ←
/// specular intensity, roughness ← 1 − gloss. Both source maps are the game's
/// single-channel maps read from the red channel. Returns `None` unless at least
/// one source is an UNCOMPRESSED RGBA8 image — the game's maps are usually
/// DXT/BC-compressed (GPU-only), which we can't read on the CPU here, so those
/// materials fall back to the scalar metallic/roughness.
#[cfg(not(target_arch = "wasm32"))]
fn pack_metallic_roughness(spc: Option<&Image>, gls: Option<&Image>) -> Option<Image> {
    use bevy::render::render_asset::RenderAssetUsages;
    use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
    let raw = |img: &&Image| {
        matches!(
            img.texture_descriptor.format,
            TextureFormat::Rgba8Unorm | TextureFormat::Rgba8UnormSrgb
        )
    };
    let spc = spc.filter(raw);
    let gls = gls.filter(raw);
    let base = spc.or(gls)?;
    let w = base.texture_descriptor.size.width.max(1);
    let h = base.texture_descriptor.size.height.max(1);
    // Nearest-sample the red channel of a (possibly differently-sized) source.
    let samp = |img: Option<&Image>, x: u32, y: u32, dflt: u8| -> u8 {
        let Some(img) = img else { return dflt };
        let iw = img.texture_descriptor.size.width.max(1);
        let ih = img.texture_descriptor.size.height.max(1);
        let sx = (x * iw / w).min(iw - 1);
        let sy = (y * ih / h).min(ih - 1);
        img.data.get(((sy * iw + sx) * 4) as usize).copied().unwrap_or(dflt)
    };
    let mut data = vec![0u8; (w * h * 4) as usize];
    for y in 0..h {
        for x in 0..w {
            let i = ((y * w + x) * 4) as usize;
            data[i + 1] = 255 - samp(gls, x, y, 96); // G = roughness (no gloss → mid)
            data[i + 2] = samp(spc, x, y, 0); // B = metallic (no spec → dielectric)
            data[i + 3] = 255;
        }
    }
    Some(Image::new(
        Extent3d { width: w, height: h, depth_or_array_layers: 1 },
        TextureDimension::D2,
        data,
        TextureFormat::Rgba8Unorm, // linear — metallic/roughness are not colour
        RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
    ))
}

#[cfg(not(target_arch = "wasm32"))]
fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr {
    prepare_models_impl(bytes, models, false)
}

/// Assemble several XBG7 resources into ONE whole model in a shared coordinate
/// frame — the capital-ship case, where each part (`e106_bdy_01`, `e106_brg_01`,
/// …) already carries its ship-local position. Unlike the stage path, the parts
/// are NOT laid out in a grid and are recentred by a single shared bbox so their
/// relative placement is preserved. See [`sylpheed_formats::ship`].
#[cfg(not(target_arch = "wasm32"))]
fn prepare_ship(bytes: &[u8], parts: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr {
    prepare_models_impl(bytes, parts, true)
}

#[cfg(not(target_arch = "wasm32"))]
fn prepare_models_impl(
    bytes: &[u8],
    models: &[sylpheed_formats::mesh::Xbg7Model],
    assemble: bool,
) -> PreparedXpr {
    use bevy::render::mesh::{Indices, PrimitiveTopology};
    use sylpheed_formats::texture::X360Texture;

    const CELL: f32 = 10.0;
    const GAP: f32 = 4.0;
    const MAX_EXTENT: f32 = 20_000.0; // drop skybox planes / stray-vertex anchors
    let pitch = CELL + GAP;
    // Grid layout only for a true multi-model stage browse — an assembled ship
    // keeps its parts in one shared space.
    let is_stage = models.len() > 1 && !assemble;

    // Keep models with real, bounded geometry.
    let drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = models
        .iter()
        .filter(|m| {
            let mut lo = Vec3::splat(f32::MAX);
            let mut hi = Vec3::splat(f32::MIN);
            let mut has = false;
            for sub in &m.meshes {
                if sub.positions.is_empty() || sub.indices.is_empty() {
                    continue;
                }
                has = true;
                for p in &sub.positions {
                    lo = lo.min(Vec3::from_array(*p));
                    hi = hi.max(Vec3::from_array(*p));
                }
            }
            has && (hi - lo).max_element() <= MAX_EXTENT
        })
        .collect();
    if drawn.is_empty() {
        return PreparedXpr::Info("No previewable geometry in this container.".to_string());
    }
    let cols = (drawn.len() as f32).sqrt().ceil().max(1.0) as usize;

    // Map each TX2D texture index to a material slot, decoding each used texture
    // exactly once. `materials[0]` is always the neutral tint (slot 0).
    let tex_names = X360Texture::texture_names(bytes);
    let mut materials: Vec<PreparedMat> = vec![PreparedMat::default()]; // slot 0 = neutral tint
    let mut tex_slot: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
    // Decode a TX2D to a Bevy image, by index or by name.
    let load_idx = |ti: usize| -> Option<Image> {
        X360Texture::from_xpr2_index(bytes, ti)
            .ok()
            .and_then(|t| crate::asset_loader::x360_texture_to_bevy_image(t).ok())
    };
    let load_named = |name: &str| -> Option<Image> {
        let ti = tex_names.iter().position(|t| t.eq_ignore_ascii_case(name))?;
        load_idx(ti)
    };
    let slot_for_ti = |ti: usize,
                       materials: &mut Vec<PreparedMat>,
                       tex_slot: &mut std::collections::HashMap<usize, usize>|
     -> usize {
        if let Some(&slot) = tex_slot.get(&ti) {
            return slot;
        }
        let albedo = load_idx(ti);
        // Sibling maps share the base name with a different suffix
        // (`…_col` → `…_spc` / `…_gls` / `…_lum`).
        let base = tex_names
            .get(ti)
            .and_then(|n| n.strip_suffix("_col").or(Some(n.as_str())))
            .unwrap_or("")
            .to_string();
        let spc = (!base.is_empty()).then(|| load_named(&format!("{base}_spc"))).flatten();
        let gls = (!base.is_empty()).then(|| load_named(&format!("{base}_gls"))).flatten();
        let emissive = (!base.is_empty()).then(|| load_named(&format!("{base}_lum"))).flatten();
        let metallic_roughness = pack_metallic_roughness(spc.as_ref(), gls.as_ref());
        let slot = materials.len();
        materials.push(PreparedMat { albedo, metallic_roughness, emissive });
        tex_slot.insert(ti, slot);
        slot
    };

    // Accumulate world-space vertices per material so the main thread spawns only
    // ~one mesh per material instead of hundreds of entities.
    struct Buf {
        pos: Vec<[f32; 3]>,
        nrm: Vec<[f32; 3]>,
        uv: Vec<[f32; 2]>,
        idx: Vec<u32>,
    }
    let mut buffers: Vec<Buf> = vec![Buf {
        pos: vec![],
        nrm: vec![],
        uv: vec![],
        idx: vec![],
    }];
    let ensure = |buffers: &mut Vec<Buf>, n: usize| {
        while buffers.len() <= n {
            buffers.push(Buf {
                pos: vec![],
                nrm: vec![],
                uv: vec![],
                idx: vec![],
            });
        }
    };

    let mut world_lo = Vec3::splat(f32::MAX);
    let mut world_hi = Vec3::splat(f32::MIN);
    let mut total_v = 0usize;
    let mut total_t = 0usize;

    // Assembled ship: one shared recentre across ALL parts so each part keeps its
    // ship-local offset (bridge fore, engines aft). Computed from the raw parts —
    // ship parts carry no node transform, so raw == placed.
    let shared_center = if assemble {
        let mut lo = Vec3::splat(f32::MAX);
        let mut hi = Vec3::splat(f32::MIN);
        for m in &drawn {
            for sub in &m.meshes {
                for p in &sub.positions {
                    lo = lo.min(Vec3::from_array(*p));
                    hi = hi.max(Vec3::from_array(*p));
                }
            }
        }
        (lo.x <= hi.x).then(|| (lo + hi) * 0.5)
    } else {
        None
    };

    for (i, model) in drawn.iter().enumerate() {
        // Per-model bbox → recentre + (stage) uniform scale to the grid cell.
        let mut lo = Vec3::splat(f32::MAX);
        let mut hi = Vec3::splat(f32::MIN);
        for sub in &model.meshes {
            for p in &sub.positions {
                lo = lo.min(Vec3::from_array(*p));
                hi = hi.max(Vec3::from_array(*p));
            }
        }
        if lo.x > hi.x {
            continue;
        }
        // An assembled ship uses the shared centre (preserve relative placement);
        // otherwise recentre each model on its own bbox.
        let center = shared_center.unwrap_or((lo + hi) * 0.5);
        let (scale, cell) = if is_stage {
            let extent = (hi - lo).max_element().max(1e-3);
            let col = i % cols;
            let row = i / cols;
            (
                CELL / extent,
                Vec3::new(col as f32 * pitch, -(row as f32) * pitch, 0.0),
            )
        } else {
            (1.0, Vec3::ZERO)
        };

        // XBG7 scene-graph node placements: the parts are authored around the
        // origin in the rigid vertex buffer and posed by per-node transforms the
        // game applies in the shader (translation to the tail, V-tail cant, and a
        // mirrored copy for each L/R pair). Recover the full affine per drawn
        // instance, keyed on the geometry (sub_index). Stages skip this.
        let placements = if is_stage {
            Vec::new()
        } else {
            sylpheed_formats::mesh::node_transforms(bytes, &model.name)
        };

        for (sub_idx, sub) in model.meshes.iter().enumerate() {
            if sub.positions.is_empty() || sub.indices.is_empty() {
                continue;
            }
            let has_uv = sub.uvs.len() == sub.positions.len();
            let has_nrm = sub.normals.len() == sub.positions.len();

            // Split the sub-mesh into its XBG7 material draw-groups so each slice
            // gets its authored albedo. The Delta Saber body is 9 groups (bdy_01a
            // hull + bdy_01b + bdy_02/03 + daiza stand); the fins are single-group
            // (bdy_04/06/07). Stages keep the cheap per-model stem match; when the
            // graph yields nothing, fall back to one stem-matched albedo.
            let groups = if is_stage {
                Vec::new()
            } else {
                sylpheed_formats::mesh::material_groups(bytes, &model.name, sub.indices.len())
            };
            let slices: Vec<(usize, usize, Option<usize>)> = if groups.is_empty() {
                vec![(0, sub.indices.len(), pick_albedo_index(&model.name, &tex_names))]
            } else {
                groups
                    .iter()
                    .map(|g| {
                        let ti = tex_names.iter().position(|t| t.eq_ignore_ascii_case(&g.albedo));
                        (g.idx_offset, g.idx_count, ti)
                    })
                    .collect()
            };

            // Every scene-graph instance that draws this geometry (a mirrored fin
            // pair, L/R winglets…); `None` = no placement → identity.
            let instances: Vec<Option<&sylpheed_formats::mesh::NodePlacement>> = {
                let v: Vec<_> = placements
                    .iter()
                    .filter(|p| p.sub_index == sub_idx && p.vtx_count == sub.positions.len())
                    .map(Some)
                    .collect();
                if v.is_empty() {
                    vec![None]
                } else {
                    v
                }
            };

            for place in instances {
                let reflect = place.map(|p| p.reflect).unwrap_or(false);
                total_v += sub.positions.len();
                total_t += sub.indices.len() / 3;

                // Positions: apply the node affine, then recentre + (stage) scale.
                let tpos: Vec<[f32; 3]> = sub
                    .positions
                    .iter()
                    .map(|p| {
                        let local = place.map(|pl| pl.apply(*p)).unwrap_or(*p);
                        let wp = (Vec3::from_array(local) - center) * scale + cell;
                        world_lo = world_lo.min(wp);
                        world_hi = world_hi.max(wp);
                        wp.to_array()
                    })
                    .collect();
                // Normals: rotate by the (orthogonal) linear part; a reflected
                // instance's winding is reversed below so faces stay outward.
                let tnrm: Vec<[f32; 3]> = if has_nrm {
                    sub.normals
                        .iter()
                        .map(|n| match place {
                            Some(pl) => Vec3::new(
                                pl.m[0][0] * n[0] + pl.m[0][1] * n[1] + pl.m[0][2] * n[2],
                                pl.m[1][0] * n[0] + pl.m[1][1] * n[1] + pl.m[1][2] * n[2],
                                pl.m[2][0] * n[0] + pl.m[2][1] * n[1] + pl.m[2][2] * n[2],
                            )
                            .normalize_or_zero()
                            .to_array(),
                            None => *n,
                        })
                        .collect()
                } else {
                    compute_smooth_normals(&tpos, &sub.indices)
                };

                for (off, cnt, ti) in &slices {
                    let mat = match ti {
                        Some(ti) => slot_for_ti(*ti, &mut materials, &mut tex_slot),
                        None => 0,
                    };
                    ensure(&mut buffers, mat);
                    let end = (off + cnt).min(sub.indices.len());
                    // Append the slice's triangles, re-emitting vertices per index
                    // (no dedup) so each material buffer stays self-contained. A
                    // reflected instance swaps two corners to keep front faces out.
                    for tri in sub.indices[(*off).min(end)..end].chunks_exact(3) {
                        let corners = if reflect {
                            [tri[0], tri[2], tri[1]]
                        } else {
                            [tri[0], tri[1], tri[2]]
                        };
                        for &vi in &corners {
                            let vi = vi as usize;
                            if vi >= tpos.len() {
                                continue;
                            }
                            let b = &mut buffers[mat];
                            let next = b.pos.len() as u32;
                            b.pos.push(tpos[vi]);
                            b.nrm.push(tnrm[vi]);
                            b.uv.push(if has_uv { sub.uvs[vi] } else { [0.0, 0.0] });
                            b.idx.push(next);
                        }
                    }
                }
            }
        }
    }

    // Build one Bevy mesh per non-empty material buffer.
    let mut meshes: Vec<(Mesh, usize)> = Vec::new();
    for (mat, buf) in buffers.into_iter().enumerate() {
        if buf.pos.is_empty() || buf.idx.is_empty() {
            continue;
        }
        let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::default());
        mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, buf.pos);
        mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, buf.nrm);
        mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, buf.uv);
        mesh.insert_indices(Indices::U32(buf.idx));
        meshes.push((mesh, mat));
    }

    let center = if world_lo.x <= world_hi.x {
        (world_lo + world_hi) * 0.5
    } else {
        Vec3::ZERO
    };
    let diag = if world_lo.x <= world_hi.x {
        (world_hi - world_lo).length().max(CELL)
    } else {
        CELL
    };
    let (name, info) = if assemble {
        (
            "ship".to_string(),
            format!(
                "Assembled ship · {} parts · {} verts · {} tris",
                drawn.len(),
                total_v,
                total_t
            ),
        )
    } else if is_stage {
        (
            "stage".to_string(),
            format!(
                "Stage container · {} sub-models (thumbnail grid) · {} verts · {} tris \
                 (content-anchored; quantized hero bodies skipped)",
                drawn.len(),
                total_v,
                total_t
            ),
        )
    } else {
        (
            drawn[0].name.clone(),
            format!(
                "XBG7 model · {} sub-mesh(es) · {} verts · {} tris",
                drawn[0].meshes.len(),
                total_v,
                total_t
            ),
        )
    };

    PreparedXpr::Model {
        meshes,
        materials,
        focus: center,
        radius: diag * if is_stage { 0.55 } else { 0.7 },
        min_radius: (diag * 0.02).max(0.02),
        max_radius: diag * 3.0,
        name,
        info,
    }
}

/// True when a resource name is a maneuver / turn morph pose (`_rou_f001_mnv01_L`,
/// `_rou_f001_turn180`) rather than the base pose (`f001`).
#[cfg(not(target_arch = "wasm32"))]
fn is_pose_name(name: &str) -> bool {
    let l = name.to_ascii_lowercase();
    l.contains("mnv") || l.contains("turn")
}

/// Recognise a ship container: several XBG7 resources that are one base pose plus
/// `_mnv*`/`_turn180` morph poses of the SAME entity, all sharing topology (so
/// the poses can be blended per-vertex). Returns `(base_index, pose_indices)`,
/// or `None` for a genuine multi-entity stage / a single model.
#[cfg(not(target_arch = "wasm32"))]
fn detect_pose_set(models: &[sylpheed_formats::mesh::Xbg7Model]) -> Option<(usize, Vec<usize>)> {
    if models.len() < 2 {
        return None;
    }
    // All resources must belong to one entity (single stem) — else it's a stage.
    let stem0 = entity_stem(&models[0].name);
    if stem0.is_empty() || !models.iter().all(|m| entity_stem(&m.name) == stem0) {
        return None;
    }
    let bases: Vec<usize> = (0..models.len())
        .filter(|&i| !is_pose_name(&models[i].name))
        .collect();
    if bases.len() != 1 {
        return None; // ambiguous — don't guess
    }
    let base = bases[0];
    let base_v: usize = models[base].meshes.iter().map(|m| m.positions.len()).sum();
    if base_v == 0 {
        return None;
    }
    // Poses must share the base vertex count (same topology → valid morph).
    let poses: Vec<usize> = (0..models.len())
        .filter(|&i| i != base && is_pose_name(&models[i].name))
        .filter(|&i| {
            let v: usize = models[i].meshes.iter().map(|m| m.positions.len()).sum();
            v == base_v
        })
        .collect();
    if poses.is_empty() {
        return None;
    }
    Some((base, poses))
}

/// Register a worker-prepared [`PreparedXpr`] into Bevy `Assets` on the main
/// thread (cheap: no decode, just `Assets::add` + entity spawns). Discards a
/// result whose generation was superseded by a newer selection.
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::too_many_arguments)]
fn apply_prepared_xpr(
    mut pending: ResMut<PendingXpr>,
    xpr_load: Res<XprLoadState>,
    mut browser: ResMut<FileBrowserState>,
    mut preview: ResMut<TexturePreview>,
    mut skybox: ResMut<SkyboxPreview>,
    mut images: ResMut<Assets<Image>>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    mut contexts: bevy_egui::EguiContexts,
    mut commands: Commands,
    mut model_preview: ResMut<ModelPreview>,
    old_models: Query<Entity, With<PreviewModel>>,
    mut orbit: Query<&mut crate::camera::OrbitCamera>,
) {
    if !pending.ready {
        return;
    }
    pending.ready = false;
    let prepared = match pending.prepared.take() {
        Some(p) => p,
        None => return,
    };
    // Drop a stale result: the user has since selected a different file.
    if pending.generation != xpr_load.generation {
        return;
    }
    browser.loading = false;

    match *prepared {
        PreparedXpr::Cancelled => {}
        PreparedXpr::Info(msg) => {
            preview.format_info = msg;
        }
        PreparedXpr::Texture { image, info } => {
            let w = image.width();
            let h = image.height();
            let handle = images.add(image);
            let egui_id = contexts.add_image(handle.clone_weak());
            preview.handle = Some(handle);
            preview.egui_id = Some(egui_id);
            preview.width = w;
            preview.height = h;
            preview.format_info = info;
        }
        PreparedXpr::Cubemap { faces, info } => {
            for (label, image, width, height) in faces {
                let handle = images.add(image);
                let egui_id = contexts.add_image(handle.clone_weak());
                skybox.faces.push(FaceTex {
                    label,
                    handle,
                    egui_id,
                    width,
                    height,
                });
            }
            skybox.info = info;
        }
        PreparedXpr::Model {
            meshes: prepared_meshes,
            materials: prepared_mats,
            focus,
            radius,
            min_radius,
            max_radius,
            name,
            info,
        } => {
            // Despawn any prior model here (rather than at dispatch) so the last
            // preview stays under the spinner until this result actually lands.
            for e in old_models.iter() {
                commands.entity(e).despawn_recursive();
            }

            // One StandardMaterial per prepared material slot — albedo (`_col`),
            // a packed metallic-roughness map (`_spc`/`_gls`, when uncompressed),
            // and emissive (`_lum`, e.g. the fin's glow).
            let mat_handles: Vec<Handle<StandardMaterial>> = prepared_mats
                .into_iter()
                .map(|mat| {
                    let base_color_texture = mat.albedo.map(|i| images.add(i));
                    let metallic_roughness_texture = mat.metallic_roughness.map(|i| images.add(i));
                    let has_emissive = mat.emissive.is_some();
                    let emissive_texture = mat.emissive.map(|i| images.add(i));
                    materials.add(StandardMaterial {
                        base_color: if base_color_texture.is_some() {
                            Color::WHITE
                        } else {
                            Color::srgb(0.72, 0.74, 0.78)
                        },
                        base_color_texture,
                        metallic_roughness_texture,
                        // The game's ship shader (PS 0x01F962B4DCFEB577) lights the
                        // hull mostly with an HDR environment cubemap reflection, so
                        // the metal must be reflective for the viewer's
                        // EnvironmentMapLight to show. Metallic hull, fairly smooth;
                        // the packed map overrides per-pixel where present.
                        perceptual_roughness: 0.4,
                        metallic: 0.7,
                        // The lum mask is boosted ~1.7× in the game (PS c65.z ≈ 1.6–1.8).
                        emissive: if has_emissive {
                            LinearRgba::rgb(1.7, 1.7, 1.7)
                        } else {
                            LinearRgba::BLACK
                        },
                        emissive_texture,
                        cull_mode: None,
                        double_sided: true,
                        ..default()
                    })
                })
                .collect();

            let root = commands
                .spawn((
                    PreviewModel,
                    Transform::default(),
                    Visibility::default(),
                    Name::new(name.clone()),
                ))
                .id();
            for (mesh, mat) in prepared_meshes {
                let material = mat_handles
                    .get(mat)
                    .cloned()
                    .unwrap_or_else(|| mat_handles[0].clone());
                let child = commands
                    .spawn((
                        PreviewModel,
                        Mesh3d(meshes.add(mesh)),
                        MeshMaterial3d(material),
                        Transform::default(),
                    ))
                    .id();
                commands.entity(root).add_child(child);
            }

            if let Ok(mut cam) = orbit.get_single_mut() {
                cam.focus = focus;
                cam.radius = radius;
                cam.min_radius = min_radius;
                cam.max_radius = max_radius;
            }
            model_preview.active = true;
            model_preview.name = name;
            model_preview.info = info;
        }
    }
}


/// Consumes a staged pack, freeing the previous texture/text previews and
/// populating `PakView` for the master-detail browser.
#[cfg(not(target_arch = "wasm32"))]
fn apply_pak(
    mut pending: ResMut<PendingPak>,
    mut pak_view: ResMut<PakView>,
    mut preview: ResMut<TexturePreview>,
    mut text_preview: ResMut<TextPreview>,
    mut skybox: ResMut<SkyboxPreview>,
    mut video: ResMut<VideoPreview>,
    mut engine: NonSendMut<VideoEngine>,
    mut file_info: ResMut<FileInfo>,
    mut images: ResMut<Assets<Image>>,
    mut contexts: bevy_egui::EguiContexts,
) {
    if !pending.ready {
        return;
    }
    pending.ready = false;

    // Free any previous texture/skybox/video + clear the other previews (mirrors
    // the texture path) so exactly one viewer is active.
    free_texture(&mut preview, &mut images, &mut contexts);
    free_skybox(&mut skybox, &mut images, &mut contexts);
    free_video(&mut video, &mut engine, &mut images, &mut contexts);
    *text_preview = TextPreview::default();

    file_info.name = pending.name.clone();
    file_info.size_bytes = 0;
    file_info.detected_format = None;

    *pak_view = PakView {
        loaded: true,
        name: std::mem::take(&mut pending.name),
        rows: std::mem::take(&mut pending.rows),
        selected: None,
        error: pending.error.take(),
        ..Default::default()
    };
}

/// Consumes a prepared video: allocates the frame texture, starts the ffmpeg
/// frame pipe + (best-effort) audio sink, and populates `VideoPreview`.
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::too_many_arguments)]
fn apply_loaded_video(
    mut pending: ResMut<PendingVideo>,
    mut video: ResMut<VideoPreview>,
    mut engine: NonSendMut<VideoEngine>,
    mut preview: ResMut<TexturePreview>,
    mut text_preview: ResMut<TextPreview>,
    mut pak_view: ResMut<PakView>,
    mut skybox: ResMut<SkyboxPreview>,
    mut file_info: ResMut<FileInfo>,
    mut images: ResMut<Assets<Image>>,
    mut contexts: bevy_egui::EguiContexts,
    mut subtitles: ResMut<MovieSubtitles>,
    mut sub_events: EventWriter<RequestSubtitles>,
    mut mvoice: ResMut<MovieVoice>,
    mut voice_events: EventWriter<RequestVoice>,
    mut audio_preview: ResMut<AudioPreview>,
) {
    if !pending.ready {
        return;
    }
    pending.ready = false;
    let Some(prep) = pending.video.take() else {
        return;
    };
    // Opening a video stops any standalone audio player.
    audio_preview.active = false;
    let prep = *prep;

    // Free every other preview + any prior video so exactly one viewer is active.
    free_texture(&mut preview, &mut images, &mut contexts);
    free_skybox(&mut skybox, &mut images, &mut contexts);
    free_video(&mut video, &mut engine, &mut images, &mut contexts);
    *text_preview = TextPreview::default();
    *pak_view = PakView::default();

    file_info.name = prep.name.clone();
    file_info.size_bytes = 0;
    file_info.detected_format = None;

    // One RGBA frame texture (starts black), overwritten in place each tick.
    let image = Image::new_fill(
        Extent3d {
            width: prep.width,
            height: prep.height,
            depth_or_array_layers: 1,
        },
        TextureDimension::D2,
        &[0, 0, 0, 255],
        TextureFormat::Rgba8UnormSrgb,
        RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
    );
    let handle = images.add(image);
    let egui_id = contexts.add_image(handle.clone_weak());

    // Start the video frame pipe (clock is paused; frames flow up to the queue).
    let (child, frames) =
        match spawn_video_decoder(&prep.temp_video, prep.width, prep.height, prep.fps, 0.0) {
            Ok(v) => v,
            Err(e) => {
                error!("video decode: {e}");
                contexts.remove_image(&handle);
                images.remove(handle.id());
                let _ = std::fs::remove_file(&prep.temp_video);
                if let Some(w) = &prep.temp_wav {
                    let _ = std::fs::remove_file(w);
                }
                preview.format_info = e; // shown in the info panel
                return;
            }
        };

    // Audio (best-effort): open the device + a sink over the temp WAV, paused.
    let volume = 0.8_f32;
    let (stream, stream_handle, sink) = match &prep.temp_wav {
        Some(wav) => match rodio::OutputStream::try_default() {
            Ok((stream, handle_out)) => {
                let sink = build_audio_sink(&handle_out, wav, 0.0, volume, false);
                (Some(stream), Some(handle_out), sink)
            }
            Err(e) => {
                warn!("no audio output device: {e}");
                (None, None, None)
            }
        },
        None => (None, None, None),
    };
    let has_audio = sink.is_some();

    // One-shot frame grabber for live timeline scrubbing.
    let (scrub_tx, scrub_req_rx) = mpsc::channel::<f32>();
    let (scrub_frame_tx, scrub_frame_rx) = mpsc::channel::<Vec<u8>>();
    {
        let path = prep.temp_video.clone();
        let (gw, gh) = (prep.width, prep.height);
        std::thread::spawn(move || scrub_worker(scrub_req_rx, scrub_frame_tx, path, gw, gh));
    }

    engine.inner = Some(VideoEngineInner {
        frames,
        child,
        width: prep.width,
        height: prep.height,
        fps: prep.fps,
        temp_video: prep.temp_video,
        temp_wav: prep.temp_wav,
        held: None,
        scrub_tx,
        scrub_frame_rx,
        awaiting_seek_frame: false,
        _stream: stream,
        stream_handle,
        sink,
        applied_volume: volume,
        was_playing: false,
        voice_wav: None,
        voice_sink: None,
        voice_applied_volume: 0.0,
    });

    *video = VideoPreview {
        active: true,
        name: prep.name,
        width: prep.width,
        height: prep.height,
        duration: prep.duration,
        position: 0.0,
        playing: false,
        handle: Some(handle),
        egui_id: Some(egui_id),
        volume,
        has_audio,
        seek_request: None,
        scrub_request: None,
        scrubbing: false,
    };

    // Kick off subtitle loading for this movie in the current language. The
    // movie basename is the file name without its `.wmv` extension.
    let movie = video
        .name
        .rsplit('/')
        .next()
        .unwrap_or(&video.name)
        .strip_suffix(".wmv")
        .unwrap_or(&video.name)
        .to_string();
    subtitles.generation = subtitles.generation.wrapping_add(1);
    subtitles.movie = Some(movie.clone());
    subtitles.cues.clear();
    subtitles.loading = true;
    sub_events.send(RequestSubtitles {
        movie: movie.clone(),
        lang: subtitles.lang,
        generation: subtitles.generation,
    });

    // Kick off voice-track decoding for this movie (clamped to its length).
    mvoice.generation = mvoice.generation.wrapping_add(1);
    mvoice.movie = Some(movie.clone());
    mvoice.available = false;
    mvoice.loading = true;
    mvoice.pending_wav = None;
    voice_events.send(RequestVoice {
        movie,
        lang: mvoice.lang,
        duration: video.duration,
        generation: mvoice.generation,
    });
}

/// Read a `.pak` archive (+ its `.pNN` segments) from the current source,
/// blocking. Mirrors the pak-browser loader but returns a live `PakArchive`.
#[cfg(not(target_arch = "wasm32"))]
fn read_pak_archive_blocking(
    source: &SourceKind,
    pak_path: &str,
) -> Result<sylpheed_formats::PakArchive, String> {
    let base = pak_path
        .strip_suffix(".pak")
        .ok_or_else(|| format!("not a .pak path: {pak_path}"))?
        .to_string();
    match source {
        SourceKind::Iso(iso_path) => {
            let iso_path = iso_path.clone();
            let pak_path = pak_path.to_string();
            futures::executor::block_on(async move {
                let mut reader = sylpheed_formats::xiso::open_iso(&iso_path)
                    .await
                    .map_err(|e| e.to_string())?;
                let index = reader
                    .read_file(&pak_path)
                    .await
                    .map_err(|e| e.to_string())?;
                let mut data = Vec::new();
                for i in 0..100u32 {
                    match reader.read_file(&format!("{base}.p{i:02}")).await {
                        Ok(mut b) => data.append(&mut b),
                        Err(_) => break,
                    }
                }
                sylpheed_formats::PakArchive::from_parts(&index, data).map_err(|e| e.to_string())
            })
        }
        SourceKind::Directory(root) => {
            let assets = sylpheed_formats::vfs::GameAssets::from_directory(root);
            let index = assets.read(pak_path).map_err(|e| e.to_string())?;
            let mut data = Vec::new();
            for i in 0..100u32 {
                match assets.read(&format!("{base}.p{i:02}")) {
                    Ok(mut b) => data.append(&mut b),
                    Err(_) => break,
                }
            }
            sylpheed_formats::PakArchive::from_parts(&index, data).map_err(|e| e.to_string())
        }
        SourceKind::None => Err("no source open".to_string()),
    }
}

/// Handles a [`RequestSubtitles`]: loads the language track + text pack off the
/// main thread and posts the resolved cues back via the loader channel.
#[cfg(not(target_arch = "wasm32"))]
fn handle_subtitle_request(
    mut events: EventReader<RequestSubtitles>,
    iso_state: Res<IsoState>,
    channels: Res<IsoChannels>,
) {
    // Only act on the latest request (rapid language toggles coalesce).
    let Some(req) = events.read().last() else {
        return;
    };
    let source = iso_state.source_kind.clone();
    let sender = channels.sender.clone();
    let (movie, lang, generation) = (req.movie.clone(), req.lang, req.generation);
    std::thread::spawn(move || {
        let cues = (|| -> Result<Vec<sylpheed_formats::SubCue>, String> {
            let lang_pak =
                read_pak_archive_blocking(&source, &format!("dat/movie/{}.pak", lang.pak_code()))?;
            let text_pak = read_pak_archive_blocking(
                &source,
                &format!("dat/GP_MAIN_GAME_{}.pak", lang.game_code()),
            )?;
            Ok(sylpheed_formats::movie_subtitle::load(&movie, &lang_pak, &text_pak))
        })()
        .unwrap_or_default();
        let _ = sender.send(IsoLoaderMsg::SubtitlesLoaded {
            movie,
            lang,
            generation,
            cues,
        });
    });
}

/// Read a whole file from the current source, blocking.
#[cfg(not(target_arch = "wasm32"))]
fn read_source_file(source: &SourceKind, path: &str) -> Result<Vec<u8>, String> {
    match source {
        SourceKind::Iso(iso_path) => {
            let iso_path = iso_path.clone();
            let path = path.to_string();
            futures::executor::block_on(async move {
                let mut reader = sylpheed_formats::xiso::open_iso(&iso_path)
                    .await
                    .map_err(|e| e.to_string())?;
                reader.read_file(&path).await.map_err(|e| e.to_string())
            })
        }
        SourceKind::Directory(root) => {
            std::fs::read(root.join(path)).map_err(|e| e.to_string())
        }
        SourceKind::None => Err("no source open".to_string()),
    }
}

/// Read a single byte range `[off, off+size)` out of a segmented pack
/// (`<base>.p00`, `.p01`, …) without materialising the whole ~1 GB archive.
/// Directory sources seek into just the right segment(s); ISO sources read the
/// segment files up to the one covering the range and slice.
#[cfg(not(target_arch = "wasm32"))]
fn read_segment_range(
    source: &SourceKind,
    base: &str,
    off: u64,
    size: usize,
) -> Result<Vec<u8>, String> {
    use std::io::{Read, Seek, SeekFrom};
    let mut out = Vec::with_capacity(size);
    let mut skip = off;
    let mut need = size;
    for i in 0..100u32 {
        if need == 0 {
            break;
        }
        let seg = format!("{base}.p{i:02}");
        match source {
            SourceKind::Directory(root) => {
                let path = root.join(&seg);
                let Ok(meta) = std::fs::metadata(&path) else { break };
                let seg_len = meta.len();
                if skip >= seg_len {
                    skip -= seg_len;
                    continue;
                }
                let mut f = std::fs::File::open(&path).map_err(|e| e.to_string())?;
                f.seek(SeekFrom::Start(skip)).map_err(|e| e.to_string())?;
                let take = need.min((seg_len - skip) as usize);
                let start = out.len();
                out.resize(start + take, 0);
                f.read_exact(&mut out[start..]).map_err(|e| e.to_string())?;
                need -= take;
                skip = 0;
            }
            SourceKind::Iso(_) => {
                // No partial read on ISO; read the whole segment and slice.
                let Ok(bytes) = read_source_file(source, &seg) else { break };
                let seg_len = bytes.len() as u64;
                if skip >= seg_len {
                    skip -= seg_len;
                    continue;
                }
                let take = need.min((seg_len - skip) as usize);
                out.extend_from_slice(&bytes[skip as usize..skip as usize + take]);
                need -= take;
                skip = 0;
            }
            SourceKind::None => return Err("no source open".to_string()),
        }
    }
    if need != 0 {
        return Err(format!("segment range short by {need} bytes"));
    }
    Ok(out)
}

/// Read one `sound.pak` entry (a `.slb` bank) by name-hash, reading only its
/// byte range from the segments.
#[cfg(not(target_arch = "wasm32"))]
fn read_sound_entry(source: &SourceKind, name_hash: u32) -> Result<Vec<u8>, String> {
    let toc = read_source_file(source, "dat/sound.pak")?;
    let entries = sylpheed_formats::PakArchive::parse_toc(&toc).map_err(|e| e.to_string())?;
    let idx = entries
        .binary_search_by_key(&name_hash, |e| e.name_hash)
        .map_err(|_| "voice not present in sound.pak".to_string())?;
    let e = &entries[idx];
    read_segment_range(source, "dat/sound", e.offset as u64, e.comp_size as usize)
}

/// Decode a `sound.pak` clip (by its `.slb` entry name) to a temp **mono** WAV
/// (left channel — the source is mono, sometimes only in the left channel).
/// `duration` clamps the decode (`INFINITY` = full clip). Returns the WAV path.
#[cfg(not(target_arch = "wasm32"))]
fn decode_sound_clip(
    source: &SourceKind,
    clip_name: &str,
    duration: f32,
) -> Result<PathBuf, String> {
    use sylpheed_formats::{hash::name_hash, slb};
    let key = name_hash(clip_name);
    let bytes = read_sound_entry(source, key)?;
    // A `.slb` is an XACT bank of one or more sub-waves. Decode EVERY sub-wave and
    // concatenate them, then clamp to the movie length. This is robust to both bank
    // shapes we see: segment banks (e.g. VOICE_RT07A = 3 sub-waves 24+14+11s ≈ the
    // 50s movie) yield the full dialogue, while the clamp drops the extra ALTERNATE
    // takes of full-length banks (e.g. VOICE_S00A, whose sub-wave 0 already spans
    // the 94s movie). Playing only sub-wave 0 (the old behaviour) dropped 2/3 of the
    // dialogue for segment banks — the "RT voice wrong" bug. Verified vs real durations.
    let mut riffs = slb::to_xma_riffs(&bytes);
    if riffs.is_empty() {
        // Some banks (data-before-header `\etc\` radio clips) confuse the
        // multi-sub-wave scanner; the robust single-stream decoder handles them,
        // so the standalone browser can still play them.
        riffs = slb::to_xma_riff_best(&bytes).into_iter().collect();
    }
    decode_riffs_to_wav(riffs, &format!("{key:08x}"), duration)
}

/// Decode a set of XMA sub-wave RIFFs into a single mono WAV: concatenate them,
/// downmix to mono (left channel holds the signal for dual-mono sources), and
/// clamp to `duration` seconds. Shared by the per-`.slb` decoder and the
/// continuous movie-voice decoder.
#[cfg(not(target_arch = "wasm32"))]
fn decode_riffs_to_wav(riffs: Vec<Vec<u8>>, tag: &str, duration: f32) -> Result<PathBuf, String> {
    if riffs.is_empty() {
        return Err("no decodable audio".into());
    }
    let dir = std::env::temp_dir();
    let out_path = dir.join(format!("sylph_voice_{tag}.wav"));
    let mut inputs = Vec::with_capacity(riffs.len());
    for (i, riff) in riffs.iter().enumerate() {
        let p = dir.join(format!("sylph_voice_{tag}_{i}.xma.wav"));
        std::fs::write(&p, riff).map_err(|e| e.to_string())?;
        inputs.push(p);
    }

    let dur = if duration.is_finite() && duration > 0.0 {
        duration
    } else {
        600.0
    };
    let filter = format!(
        "{}concat=n={}:v=0:a=1,pan=mono|c0=c0[a]",
        (0..inputs.len()).map(|i| format!("[{i}:a]")).collect::<String>(),
        inputs.len(),
    );
    let mut cmd = Command::new("ffmpeg");
    cmd.args(["-hide_banner", "-y"]);
    for p in &inputs {
        cmd.arg("-i").arg(p);
    }
    cmd.args(["-filter_complex", &filter, "-map", "[a]", "-t"])
        .arg(format!("{dur}"))
        .arg(&out_path);
    let status = cmd.output();
    for p in &inputs {
        let _ = std::fs::remove_file(p);
    }
    match status {
        Ok(o) if o.status.success() && out_path.metadata().map(|m| m.len() > 44).unwrap_or(false) => {
            Ok(out_path)
        }
        Ok(o) => Err(format!(
            "ffmpeg voice decode failed: {}",
            String::from_utf8_lossy(&o.stderr).lines().last().unwrap_or("")
        )),
        Err(e) => Err(format!("spawning ffmpeg: {e}")),
    }
}

/// Voice token for a hokyu (resupply) cutscene the manifest leaves unbound.
///
/// Only 5 of the 18 hokyu movies carry an explicit `VOICETRACK`; the rest reuse
/// those 5 recordings. The selector is the cutscene's **demo id** (from its
/// subtitle track), NOT the ship/source category: `hokyu_LS_s02A` and
/// `hokyu_LS_s11A` are both LS/carrier but use demos 600 vs 601 → `VOICE_D_450`
/// vs `_451` (their lines differ: "Rhino 3 has landed" vs "Rhino Leader has
/// landed"). We derive the demo→token map from the 5 BOUND hokyu (each has both a
/// subtitle demo id and a VOICETRACK), then look up the target movie's demo id.
/// Returns `None` for hokyu with no voice cue (`hokyu_LS_s24A`/`s27A` — correctly
/// silent) and for non-hokyu movies (they resolve via the manifest only).
#[cfg(not(target_arch = "wasm32"))]
fn hokyu_voice_token(
    source: &SourceKind,
    movie: &str,
    lang: sylpheed_formats::slb::VoiceLang,
    manifest: &[u8],
) -> Option<String> {
    use sylpheed_formats::{movie_manifest, movie_subtitle as ms};
    if !movie.starts_with("hokyu_") {
        return None;
    }
    let lang_pak =
        read_pak_archive_blocking(source, &format!("dat/movie/{}.pak", lang.code_pub())).ok()?;
    // The target cutscene's demo id (its subtitle's single voice cue).
    let want = ms::track_voice_cues(&lang_pak, movie).first().map(|&(d, _)| d)?;
    // Match it against a bound hokyu carrying the same demo id → that VOICETRACK.
    movie_manifest::parse(manifest).into_iter().find_map(|e| {
        let tok = e.voice_token.filter(|_| e.movie.starts_with("hokyu_"))?;
        ms::track_voice_cues(&lang_pak, &e.movie)
            .iter()
            .any(|&(d, _)| d == want)
            .then_some(tok)
    })
}

/// Resolve a movie's cutscene voice to a **continuous byte region** of the
/// `sound.pNN` voice stream, in `[start, end)` global offsets.
///
/// The movie voices are one continuous XMA stream chunked into `VOICE_*.slb` TOC
/// entries whose boundaries do NOT match the cutscene cues (a cue routinely spans
/// two `.slb` chunks — so a `.slb` need not hold the track its name claims). Each
/// cue ends at an inline `(sound_id, 0x11, …)` trailer; cue N = the bytes between
/// trailer N-1 and trailer N. Chain: movie → cue name (manifest VOICETRACK) →
/// sound-id (master registry) → region (scan the stream for the two trailers).
/// Returns `None` for movies whose voice is not a `\Movie\` bank (e.g. hokyu
/// `\etc\` clips), which the caller then resolves the legacy per-clip way.
#[cfg(not(target_arch = "wasm32"))]
fn resolve_movie_voice_region(
    source: &SourceKind,
    movie: &str,
    lang: sylpheed_formats::slb::VoiceLang,
) -> Option<(u64, u64)> {
    use sylpheed_formats::{hash::name_hash, movie_manifest, movie_voice, PakArchive};
    let code = lang.code_pub();
    let tpak = read_pak_archive_blocking(source, "dat/tables.pak").ok()?;
    // movie → cue token (e.g. "VOICE_RT01A")
    let manifest = tpak
        .entries()
        .iter()
        .find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))?;
    let token = movie_manifest::voice_token(&manifest, movie)
        .or_else(|| hokyu_voice_token(source, movie, lang, &manifest))?;
    // token → sound-id via the master registry (the large per-language IDXD entry
    // carrying the `<lang>\Movie\VOICE_*.slb` paths).
    let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
    let registry = tpak.entries().iter().find_map(|e| {
        tpak.read(e)
            .ok()
            .filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
    })?;
    let id = *movie_voice::registry_voice_ids(&registry).get(&token)?;
    // physical anchor: the TOC offset of this token's own `.slb` chunk — a start
    // point near the cue's trailers (the cue itself may sit before/after it). The
    // token's subdir varies: `Movie` (ADV/RT/S cutscenes) or `etc` (hokyu VOICE_D).
    let stoc = read_source_file(source, "dat/sound.pak").ok()?;
    let entries = PakArchive::parse_toc(&stoc).ok()?;
    let anchor = ["Movie", "etc", "Voice"].iter().find_map(|dir| {
        let h = name_hash(&format!("{code}\\{dir}\\{token}.slb"));
        entries
            .binary_search_by_key(&h, |e| e.name_hash)
            .ok()
            .map(|i| entries[i].offset as u64)
    })?;
    // Scan a window spanning both directions from the anchor for this cue's trailer
    // and its predecessor. The window must cover the largest bank (ADV ≈ 3.6 MB).
    let win_start = anchor.saturating_sub(2 * 1024 * 1024) & !3;
    let window = read_segment_range(source, "dat/sound", win_start, 8 * 1024 * 1024).ok()?;
    let end_local = movie_voice::find_descriptor(&window, id)?;
    let end = win_start + end_local as u64;
    // Cue start = its predecessor trailer. Prefer the exact `id-1` trailer; if the
    // id sequence has a gap (e.g. VOICE_D_453→454), fall back to the nearest trailer
    // below this one — but only if it's within one bank (~1.5 MB), else this is the
    // first cue in its block and the audio starts at the bank anchor itself.
    let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
        .or_else(|| movie_voice::find_descriptor_before(&window, end_local))
        .map(|o| win_start + o as u64)
        .filter(|&s| s < end && end - s < 1_500_000)
        .unwrap_or(anchor);
    Some((start, end))
}

/// Decode a continuous movie-voice byte region (from [`resolve_movie_voice_region`])
/// into a mono WAV, clamped to `duration`.
#[cfg(not(target_arch = "wasm32"))]
fn decode_voice_region(
    source: &SourceKind,
    start: u64,
    end: u64,
    duration: f32,
) -> Result<PathBuf, String> {
    use sylpheed_formats::slb;
    let bytes = read_segment_range(source, "dat/sound", start, (end - start) as usize)?;
    let mut riffs = slb::to_xma_riffs(&bytes);
    if riffs.is_empty() {
        riffs = slb::to_xma_riff_best(&bytes).into_iter().collect();
    }
    decode_riffs_to_wav(riffs, &format!("mv_{start:x}"), duration)
}

/// Resolve a movie's `sound.pak` voice-entry name via the **movie manifest**
/// (`tables.pak`), which authoritatively binds each movie to its voice bank —
/// several `hokyu_*` resupply movies point at in-mission `VOICE_D_*` clips in
/// `<lang>\etc\`, and many bind to nothing at all. Returns `None` when the movie
/// has no voice-over (so the caller plays silence instead of guessing a bank).
#[cfg(not(target_arch = "wasm32"))]
fn resolve_movie_voice_clip(
    source: &SourceKind,
    movie: &str,
    lang: sylpheed_formats::slb::VoiceLang,
) -> Option<String> {
    use sylpheed_formats::movie_manifest;
    let pak = read_pak_archive_blocking(source, "dat/tables.pak").ok()?;
    // The manifest has no stable name, so find it by shape among the entries.
    let manifest = pak
        .entries()
        .iter()
        .find_map(|e| pak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))?;
    let sounds = pak
        .read_by_name(&format!("{}\\sounds.tbl", lang.code_pub()))?
        .ok()?;
    // Only the manifest's DIRECT bindings are trusted. Extending to unbound
    // resupply movies by shared demo line was verified WRONG (played the wrong
    // recording), so unbound movies stay unvoiced rather than play a guess.
    movie_manifest::resolve_voice_entry(&manifest, &sounds, movie, lang)
}

/// Handles a [`RequestVoice`]: decodes the cutscene voice off-thread and posts
/// the temp WAV path back via the loader channel. The voice bank is resolved
/// from the movie manifest, so movies the game leaves unvoiced (e.g. most
/// `hokyu_*` resupply cutscenes) correctly post no audio rather than a wrong clip.
#[cfg(not(target_arch = "wasm32"))]
fn handle_voice_request(
    mut events: EventReader<RequestVoice>,
    iso_state: Res<IsoState>,
    channels: Res<IsoChannels>,
) {
    let Some(req) = events.read().last() else {
        return;
    };
    let source = iso_state.source_kind.clone();
    let sender = channels.sender.clone();
    let (movie, lang, duration, generation) =
        (req.movie.clone(), req.lang, req.duration, req.generation);
    std::thread::spawn(move || {
        // The cutscene voices live in one continuous XMA stream, addressed by cue
        // byte-region (movie → sound-id → [trailer(N-1)..trailer(N)]); this gives
        // the CORRECT, complete voice for ADV / S / RT movies, including the many
        // cues that span two `.slb` chunks. Movies whose voice is not a `\Movie\`
        // bank (hokyu resupply → `\etc\` demo clips) resolve to no region and fall
        // through to the legacy per-clip decoder. A `\Movie\` token that failed
        // region resolution must NOT play its raw `.slb` — that off-by-one chunk is
        // the wrong track — so it stays silent rather than wrong.
        let wav = resolve_movie_voice_region(&source, &movie, lang)
            .and_then(|(s, e)| decode_voice_region(&source, s, e, duration).ok())
            .or_else(|| {
                let clip = resolve_movie_voice_clip(&source, &movie, lang)?;
                if clip.contains("\\Movie\\") {
                    return None;
                }
                decode_sound_clip(&source, &clip, duration).ok()
            });
        info!("[voice] movie={movie:?} gen={generation} -> wav={wav:?}");
        let _ = sender.send(IsoLoaderMsg::VoiceLoaded {
            movie,
            generation,
            wav,
        });
    });
}

/// A simple exhaust-plume cone: base ring at the frame origin opening toward
/// **−Z** (the jet frames face aft), apex trailing behind. Stands in for the
/// game's engine-exhaust FX so assembled ships visually read as powered.
#[cfg(not(target_arch = "wasm32"))]
fn exhaust_cone_mesh() -> sylpheed_formats::mesh::GameMesh {
    const SEG: usize = 12;
    const R: f32 = 22.0;
    const LEN: f32 = 140.0;
    let mut positions = Vec::with_capacity(SEG + 2);
    let mut normals = Vec::with_capacity(SEG + 2);
    positions.push([0.0, 0.0, LEN]); // apex — the jet frame flips Z, sending it aft
    normals.push([0.0, 0.0, -1.0]);
    for s in 0..SEG {
        let a = s as f32 / SEG as f32 * std::f32::consts::TAU;
        positions.push([a.cos() * R, a.sin() * R, 0.0]);
        normals.push([a.cos(), a.sin(), 0.3]);
    }
    positions.push([0.0, 0.0, 0.0]); // base centre (cap)
    normals.push([0.0, 0.0, 1.0]);
    let mut indices = Vec::new();
    for s in 0..SEG as u32 {
        let (a, b) = (1 + s, 1 + (s + 1) % SEG as u32);
        indices.extend_from_slice(&[0, b, a]); // side
        indices.extend_from_slice(&[(SEG + 1) as u32, a, b]); // cap
    }
    sylpheed_formats::mesh::GameMesh {
        positions,
        normals,
        uvs: Vec::new(),
        indices,
        name: Some("exhaust".to_string()),
    }
}

/// Duration (seconds) of a PCM WAV from its `fmt`/`data` chunks.
#[cfg(not(target_arch = "wasm32"))]
fn wav_duration(path: &Path) -> Option<f32> {
    let b = std::fs::read(path).ok()?;
    let find = |tag: &[u8]| b.windows(4).position(|w| w == tag);
    let f = find(b"fmt ")?;
    let rate = u32::from_le_bytes([b[f + 12], b[f + 13], b[f + 14], b[f + 15]]);
    let byte_rate = u32::from_le_bytes([b[f + 16], b[f + 17], b[f + 18], b[f + 19]]);
    let d = find(b"data")?;
    let data_sz = u32::from_le_bytes([b[d + 4], b[d + 5], b[d + 6], b[d + 7]]);
    let br = if byte_rate > 0 { byte_rate } else { rate * 2 };
    (br > 0).then(|| data_sz as f32 / br as f32)
}

/// Handles a [`RequestAudio`]: decodes a movie's voice full-length (no clamp) for
/// standalone playback, posting the WAV + duration back via the loader channel.
#[cfg(not(target_arch = "wasm32"))]
fn handle_audio_request(
    mut events: EventReader<RequestAudio>,
    iso_state: Res<IsoState>,
    channels: Res<IsoChannels>,
) {
    let Some(req) = events.read().last() else {
        return;
    };
    let source = iso_state.source_kind.clone();
    let sender = channels.sender.clone();
    let (clip, display, movie, generation) = (
        req.clip.clone(),
        req.display.clone(),
        req.movie.clone(),
        req.generation,
    );
    std::thread::spawn(move || {
        // For a movie, decode its full-length cutscene voice via the continuous
        // stream (same resolution as playback), falling back to a non-`\Movie\`
        // clip (hokyu `\etc\`); for a named library clip, decode it directly.
        let wav = match movie {
            Some((m, lang)) => resolve_movie_voice_region(&source, &m, lang)
                .and_then(|(s, e)| decode_voice_region(&source, s, e, f32::INFINITY).ok())
                .or_else(|| {
                    let c = resolve_movie_voice_clip(&source, &m, lang)?;
                    if c.contains("\\Movie\\") {
                        return None;
                    }
                    decode_sound_clip(&source, &c, f32::INFINITY).ok()
                }),
            None => decode_sound_clip(&source, &clip, f32::INFINITY).ok(),
        }
        .and_then(|p| wav_duration(&p).map(|d| (p, d)));
        let _ = sender.send(IsoLoaderMsg::AudioLoaded {
            name: display,
            generation,
            wav,
        });
    });
}

/// Handles a [`RequestGameData`]: decodes the combat / mission / cast tables
/// off-thread and posts the snapshot back for the browser.
#[cfg(not(target_arch = "wasm32"))]
fn handle_game_data_request(
    mut events: EventReader<RequestGameData>,
    iso_state: Res<IsoState>,
    channels: Res<IsoChannels>,
) {
    if events.read().next().is_none() {
        return;
    }
    let source = iso_state.source_kind.clone();
    let sender = channels.sender.clone();
    std::thread::spawn(move || {
        let snap = build_game_snapshot(&source).unwrap_or_default();
        let _ = sender.send(IsoLoaderMsg::GameDataLoaded(Box::new(snap)));
    });
}

/// Decode the game-data tables into a browser snapshot (English pak + hangar pak).
#[cfg(not(target_arch = "wasm32"))]
fn build_game_snapshot(source: &SourceKind) -> Option<GameSnapshot> {
    use sylpheed_formats::{game_data as gd, localization::TextIndex};
    let main = read_pak_archive_blocking(source, "dat/GP_MAIN_GAME_E.pak").ok()?;
    let text = TextIndex::build(&main);

    let mut weapons = gd::load_weapons(&main);
    weapons.sort_by(|a, b| a.id.cmp(&b.id));
    let mut craft = gd::load_units(&main);
    craft.sort_by(|a, b| a.id.cmp(&b.id));
    let mut vessels = gd::load_vessels(&main);
    vessels.sort_by(|a, b| a.id.cmp(&b.id));

    let mut characters: Vec<CharRow> = gd::load_characters(&main)
        .into_iter()
        .filter_map(|c| {
            let id = c.id?;
            let short = id.trim_start_matches("Character").to_string();
            let name = text.character_name(&short).unwrap_or(&short).to_string();
            Some(CharRow { name, faction: c.faction.unwrap_or_default(), faces: c.faces.len() })
        })
        .collect();
    characters.sort_by(|a, b| (a.faction.clone(), a.name.clone()).cmp(&(b.faction.clone(), b.name.clone())));

    // Combat rosters, keyed by stage where the table self-identifies.
    let rosters = gd::load_unit_rosters(&main);
    let stat_hp = |id: &str| -> Option<f32> {
        craft.iter().find(|u| u.id.as_deref() == Some(id)).and_then(|u| u.hp)
            .or_else(|| vessels.iter().find(|v| v.id.as_deref() == Some(id)).and_then(|v| v.hp))
    };
    let mut missions: Vec<MissionRow> = Vec::new();
    for s in gd::load_stages(&main) {
        // main + extra story stages only (skip the Test stage)
        if s.id.len() != 3 || !s.id.starts_with('S') || s.id[1..].parse::<u32>().is_err() {
            continue;
        }
        let objectives: Vec<String> =
            (1..=s.phases).flat_map(|p| text.objectives(&s.id, p)).map(str::to_string).collect();
        let lose: Vec<String> =
            (1..=s.phases).flat_map(|p| text.lose_conditions(&s.id, p)).map(str::to_string).collect();
        let enemies: Vec<String> = rosters
            .iter()
            .find(|r| r.stage.as_deref() == Some(s.id.as_str()))
            .map(|r| {
                r.units.iter().filter(|u| u.contains("ADAN")).map(|u| {
                    let name = u.trim_start_matches("UN_").splitn(3, '_').nth(2).unwrap_or(u).replace('_', " ");
                    match stat_hp(u) {
                        Some(h) => format!("{name}  ({h:.0} HP)"),
                        None => name,
                    }
                }).collect()
            })
            .unwrap_or_default();
        missions.push(MissionRow {
            id: s.id.clone(),
            location: s.location.unwrap_or_default().replace('_', " "),
            phases: s.phases,
            objectives,
            lose,
            enemies,
        });
    }
    missions.sort_by(|a, b| a.id.cmp(&b.id));

    // Arsenal + flights from the hangar pak (optional).
    let (arsenal, flights) = read_pak_archive_blocking(source, "dat/GP_HANGAR_ARSENAL.pak")
        .map(|h| {
            let arsenal = gd::load_arsenal(&h);
            let mut seen = std::collections::BTreeSet::new();
            let mut flights = Vec::new();
            for r in gd::load_pilot_rosters(&h) {
                let key: String = r.pilots.iter().map(|(c, p)| format!("{c}:{p}")).collect::<Vec<_>>().join(",");
                if seen.insert(key) {
                    flights.push(r.pilots);
                }
            }
            (arsenal, flights)
        })
        .unwrap_or_default();

    Some(GameSnapshot { weapons, craft, vessels, characters, missions, arsenal, flights })
}

/// Handles a [`RequestShipCatalog`]: scans the stage containers and reconstructs
/// the assemblable ship catalog off-thread.
#[cfg(not(target_arch = "wasm32"))]
fn handle_ship_catalog_request(
    mut events: EventReader<RequestShipCatalog>,
    iso_state: Res<IsoState>,
    channels: Res<IsoChannels>,
    mut ships: ResMut<ShipBrowser>,
) {
    if events.read().next().is_none() {
        return;
    }
    if ships.loaded || ships.loading {
        return; // already built / building
    }
    ships.loading = true;
    let source = iso_state.source_kind.clone();
    let sender = channels.sender.clone();
    std::thread::spawn(move || {
        let rows = build_ship_catalog(&source);
        let _ = sender.send(IsoLoaderMsg::ShipCatalogLoaded(rows));
    });
}

/// Scan every `Stage_SNN.xpr` container, group XBG7 resources into whole ships,
/// and join each to its [`sylpheed_formats::game_data::Vessel`] stats.
#[cfg(not(target_arch = "wasm32"))]
fn build_ship_catalog(source: &SourceKind) -> Vec<ShipRow> {
    use sylpheed_formats::game_data::{self as gd, Vessel};
    use sylpheed_formats::ship::ships_in_container;
    use std::collections::BTreeMap;

    // Vessel stats keyed by family id (`rou_e105` → `e105`).
    let vessels: Vec<Vessel> = read_pak_archive_blocking(source, "dat/GP_MAIN_GAME_E.pak")
        .map(|p| gd::load_vessels(&p))
        .unwrap_or_default();
    let vmap: BTreeMap<String, &Vessel> = vessels
        .iter()
        .filter_map(|v| {
            let id = v.model.as_deref()?.trim_start_matches("rou_").to_string();
            Some((id, v))
        })
        .collect();

    // A capital-ship-ish display name from the Vessel id (`UN_e105_ADAN_Cruiser`
    // → `Cruiser`), else the family id.
    let pretty = |id: &str, v: Option<&&Vessel>| -> String {
        if let Some(v) = v {
            if let Some(vid) = &v.id {
                // UN_<id>_<FACTION>_<Class…>
                if let Some(rest) = vid.strip_prefix("UN_") {
                    let parts: Vec<&str> = rest.splitn(3, '_').collect();
                    if parts.len() == 3 {
                        return parts[2].replace('_', " ");
                    }
                }
            }
        }
        id.to_string()
    };

    let mut agg: BTreeMap<String, ShipRow> = BTreeMap::new();
    for n in 1..=30u32 {
        let file = format!("hidden/resource3d/Stage_S{n:02}.xpr");
        let Ok(bytes) = read_source_file(source, &file) else {
            continue;
        };
        let label = format!("S{n:02}");
        for sm in ships_in_container(&bytes) {
            // Skip single-piece props / debris — not "whole ships" — and any
            // family with no composite scene graph (fighter morph sets, shared
            // turret parts): those have no placement data and would render as a
            // pile at the origin with stray far-away pieces.
            if sm.parts.len() < 2 || !sm.has_composite {
                continue;
            }
            let v = vmap.get(&sm.id);
            let entry = agg.entry(sm.id.clone()).or_insert_with(|| ShipRow {
                id: sm.id.clone(),
                faction: sm.faction.label().to_string(),
                name: pretty(&sm.id, v),
                stage_file: file.clone(),
                stages: Vec::new(),
                parts: sm.parts.clone(),
                has_vessel: v.is_some(),
                hp: v.and_then(|v| v.hp),
                size: v.and_then(|v| Some((v.size_x?, v.size_y?, v.size_z?))),
                turrets: v.and_then(|v| v.turret_count),
                bridges: v.and_then(|v| v.bridge_count),
                shield_gens: v.and_then(|v| v.shield_generator_count),
            });
            if !entry.stages.contains(&label) {
                entry.stages.push(label.clone());
            }
        }
    }

    let mut rows: Vec<ShipRow> = agg.into_values().collect();
    // Real capital ships (Vessel-matched) first, then by faction, then id.
    rows.sort_by(|a, b| {
        b.has_vessel
            .cmp(&a.has_vessel)
            .then(a.faction.cmp(&b.faction))
            .then(a.id.cmp(&b.id))
    });
    rows
}

/// Handles a [`RequestShipRender`]: assembles the ship's part family into one
/// world-space model off-thread and posts it through the shared XPR render path.
#[cfg(not(target_arch = "wasm32"))]
fn handle_ship_render_request(
    mut events: EventReader<RequestShipRender>,
    iso_state: Res<IsoState>,
    channels: Res<IsoChannels>,
    mut xpr_load: ResMut<XprLoadState>,
    mut browser: ResMut<FileBrowserState>,
) {
    let Some(req) = events.read().last() else {
        return;
    };
    let (file, id, external, label) =
        (req.file.clone(), req.id.clone(), req.external, req.label.clone());

    // Supersede any in-flight XPR/stage decode, exactly like a file selection.
    xpr_load.generation = xpr_load.generation.wrapping_add(1);
    if let Some(prev) = xpr_load.cancel.take() {
        prev.store(true, Ordering::Relaxed);
    }
    let cancel = Arc::new(AtomicBool::new(false));
    xpr_load.cancel = Some(cancel.clone());
    let generation = xpr_load.generation;
    browser.loading = true;

    let source = iso_state.source_kind.clone();
    let sender = channels.sender.clone();
    std::thread::spawn(move || {
        let prepared = build_ship_model(&source, &file, &id, external, &label, &cancel);
        let _ = sender.send(IsoLoaderMsg::XprPrepared {
            generation,
            prepared: Box::new(prepared),
        });
    });
}

/// Read a stage container, resolve the ship's scene-graph placement, decode only
/// the referenced parts, transform each into its ship-local pose, and assemble
/// them into a single world-space model (reusing the XPR model prepare path).
#[cfg(not(target_arch = "wasm32"))]
fn build_ship_model(
    source: &SourceKind,
    file: &str,
    id: &str,
    external: bool,
    label: &str,
    cancel: &Arc<AtomicBool>,
) -> PreparedXpr {
    use sylpheed_formats::mesh::Xbg7Model;
    let bytes = match read_source_file(source, file) {
        Ok(b) => b,
        Err(e) => return PreparedXpr::Info(format!("Could not read {file}: {e}")),
    };
    let should_cancel = || cancel.load(Ordering::Relaxed);

    // Placement: fully static scene-graph assembly — exact, validated
    // part-for-part (translations + rotations + instances + mirror) against the
    // e106 runtime capture (`ship::tests::static_assembly_matches_runtime_capture`).
    // The captured table in ship_capture stays as the verification oracle only.
    let placed = sylpheed_formats::ship::assemble_ship(&bytes, id, external);
    if placed.is_empty() {
        return PreparedXpr::Info(format!("No parts found for {label}."));
    }
    // Decode each distinct referenced resource once.
    let wanted: std::collections::HashSet<String> =
        placed.iter().map(|p| p.resource.clone()).collect();
    let base = Xbg7Model::models_named(&bytes, &wanted, &should_cancel);
    if should_cancel() {
        return PreparedXpr::Cancelled;
    }
    if base.is_empty() {
        return PreparedXpr::Info(format!("No geometry decoded for {label}."));
    }

    // One transformed model per placement: bake the part's world pose into its
    // vertices (positions by the full affine, normals by the rotation) so the
    // shared prepare path can draw it in place. A resource placed at several
    // hardpoints yields several posed copies. The name is kept so `material_groups`
    // still finds the part's textures.
    let rot = |m: &[[f32; 3]; 3], n: &[f32; 3]| {
        [
            m[0][0] * n[0] + m[0][1] * n[1] + m[0][2] * n[2],
            m[1][0] * n[0] + m[1][1] * n[1] + m[1][2] * n[2],
            m[2][0] * n[0] + m[2][1] * n[1] + m[2][2] * n[2],
        ]
    };
    let mut models: Vec<Xbg7Model> = Vec::with_capacity(placed.len());
    for p in &placed {
        let Some(src) = base.iter().find(|m| m.name == p.resource) else {
            continue;
        };
        // A captured mirror placement (det < 0 — the engine X-reflects the
        // second of a port/starboard pair) flips triangle winding; reverse each
        // triangle's index order so front faces stay outward.
        let det = p.m[0][0] * (p.m[1][1] * p.m[2][2] - p.m[1][2] * p.m[2][1])
            - p.m[0][1] * (p.m[1][0] * p.m[2][2] - p.m[1][2] * p.m[2][0])
            + p.m[0][2] * (p.m[1][0] * p.m[2][1] - p.m[1][1] * p.m[2][0]);
        let mut m = src.clone();
        for sub in &mut m.meshes {
            for v in &mut sub.positions {
                *v = p.apply(*v);
            }
            for nrm in &mut sub.normals {
                *nrm = rot(&p.m, nrm);
            }
            if det < 0.0 {
                for tri in sub.indices.chunks_exact_mut(3) {
                    tri.swap(1, 2);
                }
            }
        }
        models.push(m);
    }
    if models.is_empty() {
        return PreparedXpr::Info(format!("No geometry decoded for {label}."));
    }

    // Exhaust markers: the game's visible "thrusters" are FX drawn at the
    // GN_Jet/GN_SJet frames (the engine GEOMETRY is recessed in the hull —
    // capture-verified). Draw a simple cone at each frame so the assembled ship
    // reads correctly.
    if external {
        for (i, f) in sylpheed_formats::ship::exhaust_frames(&bytes, id).iter().enumerate() {
            let mut cone = exhaust_cone_mesh();
            for v in &mut cone.positions {
                *v = f.apply(*v);
            }
            for nrm in &mut cone.normals {
                *nrm = rot(&f.m, nrm);
            }
            models.push(Xbg7Model {
                name: format!("__exhaust_{i}"),
                meshes: vec![cone],
            });
        }
    }

    // Assemble, then relabel with the ship's display name.
    match prepare_ship(&bytes, &models) {
        PreparedXpr::Model {
            meshes,
            materials,
            focus,
            radius,
            min_radius,
            max_radius,
            info,
            ..
        } => PreparedXpr::Model {
            meshes,
            materials,
            focus,
            radius,
            min_radius,
            max_radius,
            name: label.to_string(),
            info,
        },
        other => other,
    }
}

/// Handles a [`RequestVoiceLibrary`]: reads `tables.pak`'s `sounds.tbl` and
/// enumerates the voice clips off-thread.
#[cfg(not(target_arch = "wasm32"))]
fn handle_voice_library_request(
    mut events: EventReader<RequestVoiceLibrary>,
    iso_state: Res<IsoState>,
    channels: Res<IsoChannels>,
) {
    if events.read().next().is_none() {
        return;
    }
    let source = iso_state.source_kind.clone();
    let sender = channels.sender.clone();
    std::thread::spawn(move || {
        let clips = (|| -> Result<Vec<sylpheed_formats::slb::VoiceClip>, String> {
            let pak = read_pak_archive_blocking(&source, "dat/tables.pak")?;
            let tbl = pak
                .read_by_name("eng\\sounds.tbl")
                .ok_or("sounds.tbl not found")?
                .map_err(|e| e.to_string())?;
            Ok(sylpheed_formats::slb::list_voice_clips(
                &tbl,
                sylpheed_formats::slb::VoiceLang::English,
            ))
        })()
        .unwrap_or_default();
        let _ = sender.send(IsoLoaderMsg::VoiceLibraryLoaded { clips });
    });
}

/// Drives the standalone audio player: builds/rebuilds its sink, mirrors
/// play/pause + volume + seeks, advances the clock, and stops at the end.
#[cfg(not(target_arch = "wasm32"))]
fn advance_audio_playback(
    mut audio: ResMut<AudioPreview>,
    mut engine: NonSendMut<AudioEngine>,
    time: Res<Time>,
) {
    // Build the sink for a freshly-decoded track.
    if let Some((wav, dur)) = audio.pending.take() {
        if engine.handle.is_none() {
            if let Ok((s, h)) = rodio::OutputStream::try_default() {
                engine._stream = Some(s);
                engine.handle = Some(h);
            }
        }
        if let Some(h) = &engine.handle {
            engine.sink = build_audio_sink(h, &wav, 0.0, audio.volume, true);
            engine.applied_volume = audio.volume;
            engine.was_playing = true;
            engine.wav = Some(wav);
        }
        audio.active = true;
        audio.playing = true;
        audio.position = 0.0;
        audio.duration = dur;
        audio.loading = false;
    }

    // When deactivated (a video opened / closed), stop the sink and clean up.
    if !audio.active {
        if engine.sink.take().is_some() {
            engine._stream = None;
            engine.handle = None;
            if let Some(w) = engine.wav.take() {
                let _ = std::fs::remove_file(w);
            }
        }
        return;
    }

    if let Some(t) = audio.seek_request.take() {
        let t = t.clamp(0.0, audio.duration);
        if let (Some(h), Some(wav)) = (&engine.handle, &engine.wav) {
            engine.sink = build_audio_sink(h, wav, t, audio.volume, audio.playing);
            engine.applied_volume = audio.volume;
            engine.was_playing = audio.playing;
        }
        audio.position = t;
    }
    if let Some(s) = &engine.sink {
        if (audio.volume - engine.applied_volume).abs() > 0.001 {
            s.set_volume(audio.volume);
            engine.applied_volume = audio.volume;
        }
    }
    if audio.playing != engine.was_playing {
        if let Some(s) = &engine.sink {
            if audio.playing {
                s.play();
            } else {
                s.pause();
            }
        }
        engine.was_playing = audio.playing;
    }
    if audio.playing {
        audio.position += time.delta_secs();
        if audio.duration > 0.0 && audio.position >= audio.duration {
            audio.position = audio.duration;
            audio.playing = false;
            if let Some(s) = &engine.sink {
                s.pause();
            }
            engine.was_playing = false;
        }
    }
}

/// Drives the playing video each frame: honours seek requests, mirrors
/// play/pause + volume to the audio sink, advances the wall-clock position, and
/// uploads the frame due at that position into the shared texture.
#[cfg(not(target_arch = "wasm32"))]
fn advance_video_playback(
    mut video: ResMut<VideoPreview>,
    mut engine: NonSendMut<VideoEngine>,
    mut voice: ResMut<MovieVoice>,
    time: Res<Time>,
    mut images: ResMut<Assets<Image>>,
) {
    if !video.active {
        return;
    }
    let Some(inner) = engine.inner.as_mut() else {
        return;
    };

    // ── Apply a freshly-decoded voice track: build its sink on the shared output
    // stream, layered over the movie's own music+SFX. ──
    if let Some(wav) = voice.pending_wav.take() {
        if let Some(handle) = &inner.stream_handle {
            let vol = if voice.enabled { video.volume } else { 0.0 };
            info!("[voice] APPLY sink movie={:?} wav={wav:?} pos={}", voice.movie, video.position);
            inner.voice_sink =
                build_audio_sink(handle, &wav, video.position, vol, video.playing && !video.scrubbing);
            inner.voice_applied_volume = vol;
            inner.voice_wav = Some(wav);
        }
    }

    // ── Commit seek: restart the decoder (and audio) at the requested position.
    // The last grabbed scrub frame stays on screen (via `awaiting_seek_frame`)
    // until the restarted stream produces its first frame — no black flash. ──
    if let Some(target) = video.seek_request.take() {
        let target = target.clamp(0.0, video.duration);
        match spawn_video_decoder(&inner.temp_video, inner.width, inner.height, inner.fps, target) {
            Ok((new_child, new_rx)) => {
                let _ = inner.child.kill();
                let _ = inner.child.wait();
                inner.child = new_child;
                inner.frames = new_rx; // old receiver dropped → old reader exits
                inner.held = None;
                inner.awaiting_seek_frame = true;
                if let (Some(handle), Some(wav)) = (&inner.stream_handle, &inner.temp_wav) {
                    // Rebuild the sink at the new offset (old sink dropped = silenced).
                    inner.sink = build_audio_sink(handle, wav, target, video.volume, video.playing);
                }
                // Rebuild the voice sink at the same offset so it stays in sync.
                if let (Some(handle), Some(wav)) = (&inner.stream_handle, &inner.voice_wav) {
                    let vol = if voice.enabled { video.volume } else { 0.0 };
                    inner.voice_sink = build_audio_sink(handle, wav, target, vol, video.playing);
                    inner.voice_applied_volume = vol;
                }
                inner.applied_volume = video.volume;
                inner.was_playing = video.playing;
                video.position = target;
            }
            Err(e) => error!("seek failed: {e}"),
        }
    }

    // ── Live scrub: ask the grabber for the frame under the knob. ──
    if let Some(target) = video.scrub_request.take() {
        let _ = inner.scrub_tx.send(target.clamp(0.0, video.duration));
    }

    // Audio + clock are suspended while scrubbing so they don't fight the knob.
    let effective_playing = video.playing && !video.scrubbing;

    // ── Mirror volume + play/pause to the sink. ──
    if let Some(sink) = &inner.sink {
        if (video.volume - inner.applied_volume).abs() > 0.001 {
            sink.set_volume(video.volume);
            inner.applied_volume = video.volume;
        }
    }
    // Voice sink volume follows the master, gated by the enable toggle (0 = off).
    // Keeping it playing-but-silent preserves sync so toggling is instant.
    if let Some(vs) = &inner.voice_sink {
        let target_vol = if voice.enabled { video.volume } else { 0.0 };
        if (target_vol - inner.voice_applied_volume).abs() > 0.001 {
            vs.set_volume(target_vol);
            inner.voice_applied_volume = target_vol;
        }
    }
    if effective_playing != inner.was_playing {
        if let Some(sink) = &inner.sink {
            if effective_playing {
                sink.play();
            } else {
                sink.pause();
            }
        }
        if let Some(vs) = &inner.voice_sink {
            if effective_playing {
                vs.play();
            } else {
                vs.pause();
            }
        }
        inner.was_playing = effective_playing;
    }

    // ── Advance the wall-clock position while playing (not while scrubbing). ──
    if effective_playing {
        video.position += time.delta_secs();
        if video.duration > 0.0 && video.position >= video.duration {
            video.position = video.duration;
            video.playing = false;
            if let Some(sink) = &inner.sink {
                sink.pause();
            }
            if let Some(vs) = &inner.voice_sink {
                vs.pause();
            }
            inner.was_playing = false;
        }
    }

    // ── Frame display. ──
    // Always drain the grabber, keeping only the latest frame it produced.
    let mut scrub_frame: Option<Vec<u8>> = None;
    while let Ok(f) = inner.scrub_frame_rx.try_recv() {
        scrub_frame = Some(f);
    }

    // While dragging, the grabbed frame under the knob is what we show.
    if video.scrubbing {
        if let (Some(handle), Some(frame)) = (&video.handle, scrub_frame) {
            if let Some(img) = images.get_mut(handle) {
                img.data = frame;
            }
        }
        return;
    }

    // Otherwise show the newest streamed frame due at `position` (never drop or
    // reorder). If the stream hasn't caught up after a seek, bridge with the
    // last grabbed frame so the target position is visible immediately.
    let pos = video.position;
    let mut current: Option<Vec<u8>> = None;
    loop {
        if let Some((pts, _)) = &inner.held {
            if *pts > pos {
                break; // still in the future — keep holding it
            }
            current = inner.held.take().map(|(_, buf)| buf);
            continue;
        }
        match inner.frames.try_recv() {
            Ok((pts, buf)) => {
                if pts > pos {
                    inner.held = Some((pts, buf));
                    break;
                }
                current = Some(buf);
            }
            Err(_) => break, // empty (or decoder finished)
        }
    }
    let frame = if let Some(f) = current {
        inner.awaiting_seek_frame = false; // stream caught up
        Some(f)
    } else if inner.awaiting_seek_frame {
        scrub_frame // bridge with the grabbed target frame
    } else {
        None
    };
    if let (Some(handle), Some(frame)) = (&video.handle, frame) {
        if let Some(img) = images.get_mut(handle) {
            img.data = frame;
        }
    }
}

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

    fn cue(start: f32, end: Option<f32>, text: &str) -> sylpheed_formats::SubCue {
        sylpheed_formats::SubCue { start, end, text: text.into() }
    }

    #[test]
    fn reference_cue_hides_after_reading_time() {
        // Single start-only cue (a resupply line): shown from its start for an
        // estimated reading span, then hidden — NOT for the whole video.
        let subs = MovieSubtitles {
            cues: vec![cue(0.0, None, "Resupply complete. You are cleared for take-off!")],
            ..Default::default()
        };
        assert!(subs.active_at(0.0).is_some());
        assert!(subs.active_at(3.0).is_some());
        assert!(subs.active_at(30.0).is_none(), "must not linger the whole video");
    }

    #[test]
    fn start_only_cues_have_gaps_and_dont_overlap() {
        // Two far-apart radio lines: each visible near its own start, with a gap
        // in between (the first doesn't stretch to the second).
        let subs = MovieSubtitles {
            cues: vec![cue(0.5, None, "We did it!"), cue(19.3, None, "Yeah, but Brandon...")],
            ..Default::default()
        };
        assert_eq!(subs.active_at(1.0).map(|c| c.text.as_str()), Some("We did it!"));
        assert!(subs.active_at(12.0).is_none(), "gap between the two lines");
        assert_eq!(subs.active_at(20.0).map(|c| c.text.as_str()), Some("Yeah, but Brandon..."));
    }

    #[test]
    fn ranged_cue_uses_exact_end() {
        // Inline narration with an explicit start-end range is honored exactly.
        let subs = MovieSubtitles {
            cues: vec![cue(2.2, Some(8.2), "It's the 27th century.")],
            ..Default::default()
        };
        assert!(subs.active_at(5.0).is_some());
        assert!(subs.active_at(9.0).is_none());
    }

    #[test]
    fn disabled_shows_nothing() {
        let subs = MovieSubtitles {
            enabled: false,
            cues: vec![cue(0.0, Some(100.0), "x")],
            ..Default::default()
        };
        assert!(subs.active_at(1.0).is_none());
    }

    #[test]
    fn entity_stem_strips_decoration() {
        assert_eq!(entity_stem("e_rou_e007_Near"), "e007");
        assert_eq!(entity_stem("e007_bdy_01_l"), "e007");
        assert_eq!(entity_stem("f001_bdy_02"), "f001");
        assert_eq!(entity_stem("f001"), "f001");
        assert_eq!(entity_stem("g001"), "g001");
        assert_eq!(entity_stem("_rou_f001_mnv01_L"), "f001");
    }

    #[test]
    fn albedo_matches_entity_col_map() {
        let texs: Vec<String> = ["e007_col", "e007_lum", "e007_spc", "e010_col"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        // Sub-models decorated with LOD / part suffixes still map to `e007_col`,
        // where the old `starts_with(full_name)` rule fell back to grey.
        assert_eq!(pick_albedo_index("e_rou_e007_Near", &texs), Some(0));
        assert_eq!(pick_albedo_index("e007_bdy_01_l", &texs), Some(0));
        assert_eq!(pick_albedo_index("e010_bdy", &texs), Some(3));
        // A hangar-suffixed col map is still recognised as a colour map.
        let hangar = vec!["f001_wep_00_col_hangar".to_string()];
        assert_eq!(pick_albedo_index("f001_wep_00_hangar", &hangar), Some(0));
        // No matching entity map ⇒ None (caller shows a neutral tint).
        assert_eq!(pick_albedo_index("z999", &texs), None);
    }

    #[test]
    fn albedo_prefers_primary_body_map() {
        // A multi-part ship body names several `_col` maps. The whole hull must
        // pick the primary 1024² `…_bdy_01a_col`, NOT the shortest-named tiny
        // part map (`f001_bdy_02_col`, 128²) the old rule chose → the pixelation
        // fix. Directory order deliberately puts a small map first.
        let texs: Vec<String> = [
            "f001_bdy_02_col",
            "f001_bdy_03_col",
            "f001_bdy_01a_col",
            "f001_bdy_01b_col",
            "f001_bdy_daiza_col",
        ]
        .iter()
        .map(|s| s.to_string())
        .collect();
        assert_eq!(pick_albedo_index("f001", &texs), Some(2)); // …_01a_col
    }
}

#[cfg(all(test, not(target_arch = "wasm32")))]
mod pose_set_tests {
    use super::*;
    use sylpheed_formats::mesh::{GameMesh, Xbg7Model};

    fn model(name: &str, verts: usize) -> Xbg7Model {
        let sub = GameMesh {
            positions: vec![[0.0, 0.0, 0.0]; verts],
            indices: vec![0, 1, 2],
            ..Default::default()
        };
        Xbg7Model {
            name: name.to_string(),
            meshes: vec![sub],
        }
    }

    #[test]
    fn detects_ship_pose_set() {
        // DeltaSaber_T layout: base `f001` + 4 `_mnv*`/`_turn180` poses, same
        // vertex count → recognised as a single ship with 4 morph targets.
        let models = vec![
            model("f001", 100),
            model("_rou_f001_mnv01_L", 100),
            model("_rou_f001_mnv01_R", 100),
            model("_rou_f001_mnv02", 100),
            model("_rou_f001_turn180", 100),
        ];
        let got = detect_pose_set(&models);
        assert_eq!(got, Some((0, vec![1, 2, 3, 4])));
    }

    #[test]
    fn rejects_multi_entity_stage() {
        // Different stems = a real stage collection, not a pose set.
        let models = vec![model("e007", 50), model("e010", 60), model("e011", 70)];
        assert_eq!(detect_pose_set(&models), None);
    }

    #[test]
    fn rejects_mismatched_topology() {
        // A pose whose vertex count differs can't be blended → excluded; if none
        // remain, it's not treated as a ship.
        let models = vec![model("f001", 100), model("_rou_f001_mnv01_L", 99)];
        assert_eq!(detect_pose_set(&models), None);
    }
}

#[cfg(all(test, not(target_arch = "wasm32")))]
mod font_sample_tests {
    use super::*;
    use std::path::Path;

    /// The real English movie font must rasterize a sample off-thread without
    /// panicking (regression for the egui `set_fonts` crash).
    #[test]
    fn real_font_rasterizes() {
        let pak = Path::new("/tmp/sylph_extract/dat/movie/eng.pak");
        if !pak.exists() {
            eprintln!("SKIP: extract not present");
            return;
        }
        let arc = sylpheed_formats::PakArchive::open(pak).unwrap();
        let bytes = arc.read_by_hash(0x5cd0_fca6).unwrap().unwrap();
        assert!(sylpheed_formats::font::is_font(&bytes));
        let img = render_font_sample(&bytes, "The quick brown fox 0123", 34.0)
            .expect("real TrueType font should rasterize a sample");
        assert!(img.width > 0 && img.height > 0);
        assert_eq!(img.rgba.len(), (img.width * img.height * 4) as usize);
        assert!(img.rgba.iter().skip(3).step_by(4).any(|&a| a > 0), "some ink");
    }
}
