//! Typed loaders for Project Sylpheed's combat data tables.
//!
//! The game keeps its balance data in reflective [`crate::idxd`] tables — one
//! blob per entity, `token[0]` naming the table, fields laid out value-before-key.
//! This module maps the four combat schemas to plain, cloneable structs: the
//! common stats as named `Option<…>` fields, and **every** explicitly-set field in
//! a [`fields`](Weapon::fields) map so nothing is lost.
//!
//! Reverse-engineered 2026-07-23 from `GP_MAIN_GAME_E.pak` (English; the D/F/I/J/S
//! paks are localized duplicates). Fields left at their default value omit their
//! value on disc — those live in title code, not here — so a `None` means "not set
//! on disc", never a guess.
//!
//! ```no_run
//! use sylpheed_formats::{game_data, PakArchive};
//! let pak = PakArchive::open("dat/GP_MAIN_GAME_E.pak").unwrap();
//! for w in game_data::load_weapons(&pak) {
//!     println!("{} — power {:?}, range {:?}", w.id.unwrap_or_default(), w.power, w.max_range);
//! }
//! ```

use crate::idxd::IdxdObject;
use crate::pak::PakArchive;
use std::collections::BTreeMap;

/// IDXD schema ids of the combat tables (the `schema_hash` field of each blob).
pub mod schema {
    /// Player craft config: physics, cameras, scoring, difficulty, render pipeline.
    pub const PLAYER: u32 = 0x0426_e81d;
    /// Weapon definitions (guns, missiles, beams, bombs).
    pub const WEAPON: u32 = 0x6ab4_825a;
    /// Craft / units — fighters, bombers, turrets, with their AI flight model.
    pub const UNIT: u32 = 0x43fa_a517;
    /// Vessels — capital ships, with structural component counts.
    pub const VESSEL: u32 = 0x3c5b_0549;
    /// Characters — pilots / crew / comms, with faction and portrait set.
    pub const CHARACTER: u32 = 0xbd86_d41c;
    /// Stage resource manifest — per mission: location, phases, resource tables.
    pub const STAGE: u32 = 0x3c9a_e32e;
    /// Message / demo dialogue — per line: speaker, portrait, voice clip, pages.
    pub const MESSAGE: u32 = 0xb412_e6d8;
}

/// Parse a numeric field value, tolerating a trailing `f`/`F` (e.g. `"3.0f"`).
fn as_f32(v: Option<&String>) -> Option<f32> {
    let v = v?;
    let v = v.strip_suffix(['f', 'F']).unwrap_or(v);
    v.parse().ok()
}

fn as_i64(v: Option<&String>) -> Option<i64> {
    v?.parse().ok()
}

/// Collect every explicitly-set field of an IDXD blob into a `key → value` map,
/// plus the identity strings. Identifier-valued fields (`Model`, `ShotType`, …)
/// that carry a bare name rather than a value are not in the map — read those from
/// [`IdxdObject::get_raw`] if needed.
fn field_map(o: &IdxdObject) -> BTreeMap<String, String> {
    o.resolved_fields()
        .into_iter()
        .map(|(k, v)| (k.to_string(), v.to_string()))
        .collect()
}

/// Read every record of `schema` from a pak, mapping each with `build`.
fn load_table<T>(pak: &PakArchive, schema: u32, build: impl Fn(&IdxdObject) -> Option<T>) -> Vec<T> {
    pak.entries()
        .iter()
        .filter_map(|e| pak.read(e).ok())
        .filter_map(|b| IdxdObject::parse(&b).ok())
        .filter(|o| o.schema_hash == schema)
        .filter_map(|o| build(&o))
        .collect()
}

// ── Weapon ────────────────────────────────────────────────────────────────────

/// A weapon definition (schema [`schema::WEAPON`]).
#[derive(Debug, Clone)]
pub struct Weapon {
    pub id: Option<String>,
    pub name: Option<String>,
    /// Target mask, e.g. `"Vessel,Craft,Structure"`.
    pub target_type: Option<String>,
    pub power: Option<f32>,
    pub velocity: Option<f32>,
    pub min_velocity: Option<f32>,
    pub max_velocity: Option<f32>,
    pub min_range: Option<f32>,
    pub max_range: Option<f32>,
    /// Ammo / reload budget (`LoadingCount`).
    pub loading_count: Option<i64>,
    /// Seconds between shots.
    pub interval: Option<f32>,
    /// Shots per trigger pull (volley / lock count).
    pub trigger_shot_count: Option<i64>,
    pub mass: Option<f32>,
    pub heating: Option<f32>,
    pub cooling: Option<f32>,
    pub life_time: Option<f32>,
    /// All explicitly-set fields (a superset of the named ones above).
    pub fields: BTreeMap<String, String>,
}

impl Weapon {
    fn from_idxd(o: &IdxdObject) -> Option<Self> {
        let f = field_map(o);
        Some(Weapon {
            id: o.get_raw("ID").map(str::to_string),
            name: o.get_raw("Name").map(str::to_string),
            target_type: o.get_raw("TargetType").map(str::to_string),
            power: as_f32(f.get("Power")),
            velocity: as_f32(f.get("Velocity")),
            min_velocity: as_f32(f.get("MinimumVelocity")),
            max_velocity: as_f32(f.get("MaximumVelocity")),
            min_range: as_f32(f.get("MinimumRange")),
            max_range: as_f32(f.get("MaximumRange")),
            loading_count: as_i64(f.get("LoadingCount")),
            interval: as_f32(f.get("Interval")),
            trigger_shot_count: as_i64(f.get("TriggerShotCount")),
            mass: as_f32(f.get("Mass")),
            heating: as_f32(f.get("Heating")),
            cooling: as_f32(f.get("Cooling")),
            life_time: as_f32(f.get("LifeTime")),
            fields: f,
        })
    }
    /// Any explicit field parsed as `f32` (for the long-tail stats not named above).
    pub fn field_f32(&self, key: &str) -> Option<f32> {
        as_f32(self.fields.get(key))
    }
}

/// Load every weapon from a pak. Catches all `Weapon`-shaped records, not just the
/// main [`schema::WEAPON`] — player special/missile weapons live in variant schemas
/// (e.g. `Weapon_DSaber_P_wep_*` in `GP_HANGAR_ARSENAL.pak`). Deduplicated by id.
pub fn load_weapons(pak: &PakArchive) -> Vec<Weapon> {
    let mut seen = std::collections::BTreeSet::new();
    let mut out = Vec::new();
    for e in pak.entries() {
        let Ok(b) = pak.read(e) else { continue };
        let Ok(o) = IdxdObject::parse(&b) else { continue };
        // `Weapon`/`QWeapon`/… but not the `EnumWeapon` name lists.
        let t0 = o.tokens().first().map(String::as_str).unwrap_or("");
        if !t0.ends_with("Weapon") || t0.contains("Enum") {
            continue;
        }
        if let Some(w) = Weapon::from_idxd(&o) {
            if w.id.as_deref().is_some_and(|id| seen.insert(id.to_string())) {
                out.push(w);
            }
        }
    }
    out
}

// ── Craft / unit ────────────────────────────────────────────────────────────────

/// A craft / unit — fighter, bomber, turret (schema [`schema::UNIT`]). Carries the
/// AI flight model in [`fields`](CraftUnit::fields) (`AV_Pitch*`, `BarrelRoll_*`,
/// `TurnAttack_*`, …).
#[derive(Debug, Clone)]
pub struct CraftUnit {
    pub id: Option<String>,
    pub name: Option<String>,
    pub hp: Option<f32>,
    pub is_destructible: Option<bool>,
    pub size_x: Option<f32>,
    pub size_y: Option<f32>,
    pub size_z: Option<f32>,
    pub size_radius: Option<f32>,
    pub radar_range: Option<f32>,
    pub fcs_range: Option<f32>,
    pub cruising_velocity: Option<f32>,
    pub maximum_velocity: Option<f32>,
    pub acceleration: Option<f32>,
    pub deceleration: Option<f32>,
    pub shield_ratio: Option<f32>,
    pub turret_count: Option<i64>,
    pub score_point: Option<i64>,
    pub fields: BTreeMap<String, String>,
}

impl CraftUnit {
    fn from_idxd(o: &IdxdObject) -> Option<Self> {
        let f = field_map(o);
        Some(CraftUnit {
            id: o.get_raw("ID").map(str::to_string),
            name: o.get_raw("Name").map(str::to_string),
            hp: as_f32(f.get("HP")),
            is_destructible: o.get_bool("IsDestructible"),
            size_x: as_f32(f.get("Size_X")),
            size_y: as_f32(f.get("Size_Y")),
            size_z: as_f32(f.get("Size_Z")),
            size_radius: as_f32(f.get("Size_Radius")),
            radar_range: as_f32(f.get("RadarRange")),
            fcs_range: as_f32(f.get("FCSRange")),
            cruising_velocity: as_f32(f.get("CruisingVelocity")),
            maximum_velocity: as_f32(f.get("MaximumVelocity")),
            acceleration: as_f32(f.get("Acceleration")),
            deceleration: as_f32(f.get("Deceleration")),
            shield_ratio: as_f32(f.get("ShieldRatio")),
            turret_count: as_i64(f.get("TurretCount")),
            score_point: as_i64(f.get("ScorePoint")),
            fields: f,
        })
    }
    pub fn field_f32(&self, key: &str) -> Option<f32> {
        as_f32(self.fields.get(key))
    }
}

/// Load every craft / unit from a pak.
pub fn load_units(pak: &PakArchive) -> Vec<CraftUnit> {
    load_table(pak, schema::UNIT, CraftUnit::from_idxd)
}

// ── Vessel / capital ship ───────────────────────────────────────────────────────

/// A capital ship (schema [`schema::VESSEL`]). Component counts drive the
/// destructible hardpoints.
#[derive(Debug, Clone)]
pub struct Vessel {
    pub id: Option<String>,
    pub name: Option<String>,
    /// The 3D hull resource stem, e.g. `rou_e105` — the `eNNN`/`fNNN` id links to
    /// the XBG7 part family (`e105_bdy_*`, `e105_brg_*`, …) inside a `Stage_SNN.xpr`.
    pub model: Option<String>,
    pub hp: Option<f32>,
    pub size_x: Option<f32>,
    pub size_y: Option<f32>,
    pub size_z: Option<f32>,
    pub radar_range: Option<f32>,
    pub fcs_range: Option<f32>,
    pub maximum_velocity: Option<f32>,
    pub shield_ratio: Option<f32>,
    pub score_point: Option<i64>,
    pub turret_count: Option<i64>,
    pub bridge_count: Option<i64>,
    pub hatch_count: Option<i64>,
    pub shield_generator_count: Option<i64>,
    pub thruster_count: Option<i64>,
    pub fields: BTreeMap<String, String>,
}

impl Vessel {
    fn from_idxd(o: &IdxdObject) -> Option<Self> {
        let f = field_map(o);
        Some(Vessel {
            id: o.get_raw("ID").map(str::to_string),
            name: o.get_raw("Name").map(str::to_string),
            model: o.get_raw("Model").map(str::to_string),
            hp: as_f32(f.get("HP")),
            size_x: as_f32(f.get("Size_X")),
            size_y: as_f32(f.get("Size_Y")),
            size_z: as_f32(f.get("Size_Z")),
            radar_range: as_f32(f.get("RadarRange")),
            fcs_range: as_f32(f.get("FCSRange")),
            maximum_velocity: as_f32(f.get("MaximumVelocity")),
            shield_ratio: as_f32(f.get("ShieldRatio")),
            score_point: as_i64(f.get("ScorePoint")),
            turret_count: as_i64(f.get("TurretCount")),
            bridge_count: as_i64(f.get("BridgeCount")),
            hatch_count: as_i64(f.get("HatchCount")),
            shield_generator_count: as_i64(f.get("ShieldGeneratorCount")),
            thruster_count: as_i64(f.get("ThrusterCount")),
            fields: f,
        })
    }
    pub fn field_f32(&self, key: &str) -> Option<f32> {
        as_f32(self.fields.get(key))
    }
}

/// Load every capital ship from a pak.
pub fn load_vessels(pak: &PakArchive) -> Vec<Vessel> {
    load_table(pak, schema::VESSEL, Vessel::from_idxd)
}

// ── Player config ───────────────────────────────────────────────────────────────

/// Player craft config (schema [`schema::PLAYER`]) — one per mission. Physics,
/// projectile caps and scoring here; cameras, difficulty and the render pipeline
/// live in [`fields`](PlayerConfig::fields).
#[derive(Debug, Clone)]
pub struct PlayerConfig {
    pub air_drag_factor: Option<f32>,
    pub gravity_factor: Option<f32>,
    pub bullet_limit: Option<i64>,
    pub laser_limit: Option<i64>,
    pub homing_limit: Option<i64>,
    pub space_size: Option<f32>,
    pub supply_range: Option<f32>,
    pub main_mission_bonus: Option<i64>,
    pub rank_score_s: Option<i64>,
    pub rank_score_a: Option<i64>,
    pub rank_score_b: Option<i64>,
    pub rank_score_c: Option<i64>,
    pub rank_score_d: Option<i64>,
    pub fields: BTreeMap<String, String>,
}

impl PlayerConfig {
    fn from_idxd(o: &IdxdObject) -> Option<Self> {
        let f = field_map(o);
        Some(PlayerConfig {
            air_drag_factor: as_f32(f.get("AirDragFactor")),
            gravity_factor: as_f32(f.get("GravityFactor")),
            bullet_limit: as_i64(f.get("BulletLimit")),
            laser_limit: as_i64(f.get("LaserLimit")),
            homing_limit: as_i64(f.get("HomingLimit")),
            space_size: as_f32(f.get("SpaceSize")),
            supply_range: as_f32(f.get("SupplyRange")),
            main_mission_bonus: as_i64(f.get("MainMissionBonus")),
            rank_score_s: as_i64(f.get("RankScore_S")),
            rank_score_a: as_i64(f.get("RankScore_A")),
            rank_score_b: as_i64(f.get("RankScore_B")),
            rank_score_c: as_i64(f.get("RankScore_C")),
            rank_score_d: as_i64(f.get("RankScore_D")),
            fields: f,
        })
    }
    pub fn field_f32(&self, key: &str) -> Option<f32> {
        as_f32(self.fields.get(key))
    }
}

/// Load every player config (one per mission) from a pak.
pub fn load_player_configs(pak: &PakArchive) -> Vec<PlayerConfig> {
    load_table(pak, schema::PLAYER, PlayerConfig::from_idxd)
}

// ── Character ───────────────────────────────────────────────────────────────────

/// A pilot / crew portrait: an emotion id (`FaceRAYMOND_07`) and its texture
/// (`pjf003_C02.t32`, a T8aD tile inside a RATC bundle).
#[derive(Debug, Clone)]
pub struct Face {
    pub id: String,
    pub texture: String,
}

/// A character (schema [`CHARACTER`](schema::CHARACTER)) — pilots, crew, comms.
#[derive(Debug, Clone)]
pub struct Character {
    pub id: Option<String>,
    /// Localized display-name key (resolve against the game's string tables).
    pub name_key: Option<String>,
    /// Faction / side, e.g. `TCAF` (player) or `ADAN` (enemy).
    pub faction: Option<String>,
    pub unique: Option<bool>,
    /// Portrait set: each emotion face and the texture that draws it.
    pub faces: Vec<Face>,
    pub fields: BTreeMap<String, String>,
}

impl Character {
    fn from_idxd(o: &IdxdObject) -> Option<Self> {
        let t = o.tokens();
        // Faces are `(texture, FaceID)` pairs after the `Faces` marker (value-
        // before-key: the FaceID names the emotion, the `.t32` before it is its art).
        let mut faces = Vec::new();
        if let Some(start) = t.iter().position(|s| s == "Faces") {
            for i in (start + 1)..t.len() {
                if t[i].starts_with("Face") && i > 0 {
                    faces.push(Face {
                        id: t[i].clone(),
                        texture: t[i - 1].clone(),
                    });
                }
            }
        }
        Some(Character {
            id: o.get_raw("ID").map(str::to_string),
            name_key: o.get_raw("Name").map(str::to_string),
            faction: o.get_raw("SideID").map(str::to_string),
            unique: o.get_bool("Unique"),
            faces,
            fields: field_map(o),
        })
    }
}

/// Load every character from a pak.
pub fn load_characters(pak: &PakArchive) -> Vec<Character> {
    load_table(pak, schema::CHARACTER, Character::from_idxd)
}

// ── Stage / mission ─────────────────────────────────────────────────────────────

/// The raw token immediately before `key` in `tokens` (value-before-key), for
/// identifier-valued fields the generic resolver skips (filenames, locations).
fn raw_before<'a>(tokens: &'a [String], key: &str) -> Option<&'a str> {
    let i = tokens.iter().position(|t| t == key)?;
    (i > 0).then(|| tokens[i - 1].as_str())
}

/// A mission / stage (schema [`STAGE`](schema::STAGE)). The manifest that wires a
/// mission together: its location, phase count, and the per-stage tables that
/// populate it (unit roster, squadrons, formations, flight routes, AI, objectives).
/// Resolve the referenced `*_S<NN>.tbl` tables with [`load_records`] for the
/// spawn/formation detail.
#[derive(Debug, Clone)]
pub struct Stage {
    /// Stage tag derived from the unit-table name, e.g. `"S01"`.
    pub id: String,
    /// In-world location (`BackGroundID`), e.g. `Lebendorf`, `Acheron`, `Earth`.
    pub location: Option<String>,
    /// Number of `Phase_N` sections.
    pub phases: u32,
    pub unit_table: Option<String>,
    pub character_table: Option<String>,
    pub squadron_table: Option<String>,
    pub formation_table: Option<String>,
    pub route_table: Option<String>,
    pub ai_params_table: Option<String>,
    pub subobjective_table: Option<String>,
    pub message_table: Option<String>,
    /// Every `resource-kind → table` reference in the manifest.
    pub resources: BTreeMap<String, String>,
}

impl Stage {
    fn from_idxd(o: &IdxdObject) -> Option<Self> {
        let t = o.tokens();
        // Each resource is `table-name → Enumerate<Kind>` (value-before-key).
        let mut resources = BTreeMap::new();
        for i in 1..t.len() {
            let key = t[i].as_str();
            if key.starts_with("Enumerate") || key == "MessageSet" || key == "NamePlate" {
                resources.insert(key.to_string(), t[i - 1].clone());
            }
        }
        let unit_table = raw_before(t, "EnumerateUnit").map(str::to_string);
        let id = unit_table
            .as_deref()
            .and_then(|s| s.strip_prefix("EnumUnit_"))
            .map(|s| s.trim_end_matches(".tbl").to_string())
            .unwrap_or_default();
        Some(Stage {
            id,
            location: o.get_raw("BackGroundID").map(str::to_string),
            phases: t.iter().filter(|s| s.starts_with("Phase_")).count() as u32,
            unit_table,
            character_table: raw_before(t, "EnumerateCharacter").map(str::to_string),
            squadron_table: raw_before(t, "EnumerateSquadron").map(str::to_string),
            formation_table: raw_before(t, "EnumerateFormation").map(str::to_string),
            route_table: raw_before(t, "EnumerateNullFrame").map(str::to_string),
            ai_params_table: raw_before(t, "EnumerateAIParams").map(str::to_string),
            subobjective_table: raw_before(t, "EnumerateSubobjective").map(str::to_string),
            message_table: raw_before(t, "MessageSet").map(str::to_string),
            resources,
        })
    }
}

/// Load every stage / mission manifest from a pak.
pub fn load_stages(pak: &PakArchive) -> Vec<Stage> {
    load_table(pak, schema::STAGE, Stage::from_idxd)
}

// ── Squadron / flight group ─────────────────────────────────────────────────────

/// A squadron — a flight group that spawns together: its formation shape, AI
/// behaviour, side, and member craft (with their pilots). Parsed from the
/// per-stage `Enumerate_Squadrons` tables (`UnitGroup_S<NN>.tbl`). The player's
/// Rhino flight, for instance, is a 2-craft TCAF squadron flying `Formation_2_Rhino`.
#[derive(Debug, Clone)]
pub struct Squadron {
    /// Squadron tag (`TCN001`, `ADT101`, …), matched positionally to its
    /// definition; `None` if the id list and definitions don't line up.
    pub id: Option<String>,
    pub formation_id: Option<String>,
    pub ai_id: Option<String>,
    /// Faction — `TCAF` (allied) or `ADAN` (enemy).
    pub side: Option<String>,
    pub count: Option<i64>,
    /// Member craft (`UN_*` unit ids).
    pub members: Vec<String>,
}

/// Load every squadron from a pak. Scans all `Enumerate_Squadrons` tables (their
/// schema hash varies per stage, but the table name is stable).
pub fn load_squadrons(pak: &PakArchive) -> Vec<Squadron> {
    let mut out = Vec::new();
    for e in pak.entries() {
        let Ok(b) = pak.read(e) else { continue };
        let Ok(o) = IdxdObject::parse(&b) else { continue };
        let t = o.tokens();
        if !t.first().is_some_and(|s| s.contains("Enumerate_Squadron")) {
            continue;
        }
        // Definitions are delimited by the `FormationID` key (its value, the
        // formation, sits just before it). The id list precedes the first one.
        let fpos: Vec<usize> = t
            .iter()
            .enumerate()
            .filter(|(_, s)| *s == "FormationID")
            .map(|(i, _)| i)
            .collect();
        let Some(&first) = fpos.first() else { continue };
        // The squadron id list: tokens after the header up to the first formation.
        let ids: Vec<&String> = t[1..first.saturating_sub(1)].iter().collect();
        for (k, &fi) in fpos.iter().enumerate() {
            let start = fi.saturating_sub(1);
            let end = fpos.get(k + 1).map(|&n| n.saturating_sub(1)).unwrap_or(t.len());
            let span = &t[start..end];
            let before = |key: &str| -> Option<String> {
                span.iter().position(|s| s == key).and_then(|i| (i > 0).then(|| span[i - 1].clone()))
            };
            let count = before("Count").and_then(|v| v.parse::<i64>().ok());
            // Members are the first `count` `UN_` craft after the header block
            // (which ends at DisableInterval, else Count); anything past them
            // belongs to the next squadron or the stage roster.
            let member_start = span
                .iter()
                .position(|s| s == "DisableInterval")
                .or_else(|| span.iter().position(|s| s == "Count"))
                .map(|i| i + 1)
                .unwrap_or(0);
            let take = count.unwrap_or(0).max(0) as usize;
            out.push(Squadron {
                id: (ids.len() == fpos.len()).then(|| ids[k].clone()),
                formation_id: Some(t[fi - 1].clone()),
                ai_id: before("AIID"),
                side: before("SideID"),
                count,
                members: span[member_start..]
                    .iter()
                    .filter(|s| s.starts_with("UN_"))
                    .take(take)
                    .cloned()
                    .collect(),
            });
        }
    }
    out
}

// ── Demo message / dialogue line ────────────────────────────────────────────────

/// One dialogue line (schema [`MESSAGE`](schema::MESSAGE)): who speaks, with which
/// portrait and voice clip, and the text-key(s) of its caption pages. Resolve the
/// page keys against a [`crate::localization::TextIndex`] for the words, and the
/// voice clip against the movie-voice banks for the audio.
#[derive(Debug, Clone)]
pub struct DemoMessage {
    /// Line id, e.g. `MSG_VOICE_A_007`.
    pub id: String,
    /// Speaker (`CharacterRAYMOND`) when the line names one; many system /
    /// continuation lines are unattributed (`None`).
    pub character: Option<String>,
    /// Portrait / emotion (`FaceRAYMOND_07`).
    pub face: Option<String>,
    /// Voice clip token (`VOICE_A_007`).
    pub voice_clip: Option<String>,
    /// Caption page text keys (`MSG_VOICE_A_007_000_00`, …) — resolve for the text.
    pub page_keys: Vec<String>,
}

/// Load every dialogue line from a pak. Each `Message_NNN` sub-record within the
/// message blobs becomes one [`DemoMessage`]; fields are found by prefix, so the
/// positional/defaulted layout doesn't matter.
pub fn load_demo_messages(pak: &PakArchive) -> Vec<DemoMessage> {
    let is_marker = |s: &str| {
        s.strip_prefix("Message_").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
    };
    let mut out = Vec::new();
    for e in pak.entries() {
        let Ok(b) = pak.read(e) else { continue };
        let Ok(o) = IdxdObject::parse(&b) else { continue };
        if o.schema_hash != schema::MESSAGE {
            continue;
        }
        let t = o.tokens();
        let marks: Vec<usize> = t.iter().enumerate().filter(|(_, s)| is_marker(s)).map(|(i, _)| i).collect();
        for (k, &m) in marks.iter().enumerate() {
            let end = marks.get(k + 1).copied().unwrap_or(t.len());
            let slice = &t[m + 1..end];
            let Some(id) = slice.first().cloned() else { continue };
            out.push(DemoMessage {
                character: slice.iter().find(|s| s.starts_with("Character")).cloned(),
                face: slice.iter().find(|s| s.starts_with("Face")).cloned(),
                voice_clip: slice.iter().find(|s| s.starts_with("VOICE_")).cloned(),
                page_keys: slice
                    .iter()
                    .filter(|s| s.len() > id.len() && s.starts_with(&id) && s[id.len()..].starts_with('_'))
                    .cloned()
                    .collect(),
                id,
            });
        }
    }
    out
}

// ── Unit roster (per-mission combatants) ────────────────────────────────────────

/// Schema of the per-stage `EnumUnit` roster tables.
const ENUM_UNIT_SCHEMA: u32 = 0x35b8_dc67;

/// A mission's unit roster — the craft, vessels and props that can appear in one
/// battle (an `EnumUnit_S<NN>` table). Join the ids against [`load_units`] /
/// [`load_vessels`] for stats. The table isn't tagged with its stage on disc, so
/// [`stage`](UnitRoster::stage) is inferred from any `S<NN>_`-prefixed prop id in
/// the roster (asteroid collision meshes etc.) and is `None` when none is present.
#[derive(Debug, Clone)]
pub struct UnitRoster {
    pub stage: Option<String>,
    /// Distinct combatant unit ids (`UN_*`), excluding stage props (asteroids,
    /// collision meshes).
    pub units: Vec<String>,
}

/// Load every mission unit roster from a pak.
pub fn load_unit_rosters(pak: &PakArchive) -> Vec<UnitRoster> {
    let mut out = Vec::new();
    for e in pak.entries() {
        let Ok(b) = pak.read(e) else { continue };
        let Ok(o) = IdxdObject::parse(&b) else { continue };
        if o.schema_hash != ENUM_UNIT_SCHEMA {
            continue;
        }
        let ids: Vec<&str> = o.tokens().iter().filter(|s| s.starts_with("UN_")).map(String::as_str).collect();
        // Stage tag from an `UN_S<NN>_…` prop id (e.g. UN_S01_Asteroid_cmesh_…).
        let stage = ids.iter().find_map(|s| {
            let r = s.trim_start_matches("UN_");
            let bytes = r.as_bytes();
            (r.len() >= 3 && bytes[0] == b'S' && bytes[1].is_ascii_digit() && bytes[2].is_ascii_digit())
                .then(|| r[..3].to_string())
        });
        // Combatants: drop the stage props, dedup preserving order.
        let mut units = Vec::new();
        for id in ids {
            let prop = id.contains("Asteroid") || id.contains("cmesh") || id.contains("_Box");
            if !prop && !units.iter().any(|u| u == id) {
                units.push(id.to_string());
            }
        }
        if !units.is_empty() {
            out.push(UnitRoster { stage, units });
        }
    }
    out
}

// ── Pilot roster (player squadron assignments) ──────────────────────────────────

/// The player squadron's flight assignments for a mission — which pilot flies each
/// callsign (`Rhino1` → `Katana`, `Bird1` → `Sandra`, …). Parsed from the
/// `UNITS`/`ZUNITS` player-unit tables (in `GP_HANGAR_ARSENAL.pak`); assignments
/// vary per mission (different missions field different wingmen).
#[derive(Debug, Clone)]
pub struct PilotRoster {
    /// `(callsign, pilot)` pairs in flight order, e.g. `("Rhino1", "Katana")`.
    pub pilots: Vec<(String, String)>,
    /// The player craft unit id (`UN_f002_TCAF_DeltaSaber…`).
    pub player_unit: Option<String>,
}

/// True for a `Callsign<N>-Pilot` token (a single hyphen, digit-suffixed callsign,
/// alphabetic pilot) — the flight-assignment shape.
fn is_assignment(s: &str) -> bool {
    let Some((cs, pilot)) = s.split_once('-') else { return false };
    !pilot.is_empty()
        && pilot.chars().all(|c| c.is_ascii_alphabetic())
        && cs.len() >= 2
        && cs.as_bytes()[cs.len() - 1].is_ascii_digit()
        && cs.chars().all(|c| c.is_ascii_alphanumeric())
}

/// Load every player pilot roster from a pak (scan `GP_HANGAR_ARSENAL.pak`). One
/// per player-unit config; rosters with no assignment tokens are skipped.
pub fn load_pilot_rosters(pak: &PakArchive) -> Vec<PilotRoster> {
    let mut out = Vec::new();
    for e in pak.entries() {
        let Ok(b) = pak.read(e) else { continue };
        let Ok(o) = IdxdObject::parse(&b) else { continue };
        let t = o.tokens();
        let pilots: Vec<(String, String)> = t
            .iter()
            .filter(|s| is_assignment(s))
            .filter_map(|s| s.split_once('-').map(|(c, p)| (c.to_string(), p.to_string())))
            .collect();
        if pilots.len() >= 2 {
            out.push(PilotRoster {
                pilots,
                player_unit: t.iter().find(|s| s.starts_with("UN_")).cloned(),
            });
        }
    }
    out
}

// ── Arsenal (player weapon options per hardpoint) ───────────────────────────────

/// The player's selectable weapons, by hardpoint (from the `UNITS` player-unit
/// tables in `GP_HANGAR_ARSENAL.pak`). These are the arsenal display ids — the
/// Delta Saber's nose guns (`Stiletto_BG1`, `Broad_Sword_SG1`, …) and arm missiles
/// (`Falcon_9AM`, `White_Shark_T53R`, …) the player equips in the hangar.
#[derive(Debug, Clone, Default)]
pub struct Arsenal {
    /// Nose slot — the fixed forward gun options.
    pub nose: Vec<String>,
    /// The three arm hardpoints — missile / bomb / special options.
    pub arm1: Vec<String>,
    pub arm2: Vec<String>,
    pub arm3: Vec<String>,
}

/// The weapon ids listed under a `STANDARD_<hp>` header (skipping its `Type` key),
/// up to the next `STANDARD_`/`PlayerSET_` header.
fn hardpoint_options(tokens: &[String], header: &str) -> Vec<String> {
    let Some(start) = tokens.iter().position(|s| s == header) else { return Vec::new() };
    tokens[start + 1..]
        .iter()
        .skip_while(|s| *s == "Type")
        .take_while(|s| !s.starts_with("STANDARD_") && !s.starts_with("PlayerSET"))
        .filter(|s| *s != "Type")
        .cloned()
        .collect()
}

/// Load the player arsenal from a pak (`GP_HANGAR_ARSENAL.pak`). Unions the weapon
/// options across every player-unit config, deduped in first-seen order.
pub fn load_arsenal(pak: &PakArchive) -> Arsenal {
    let mut a = Arsenal::default();
    let mut seen = [(); 4].map(|_| std::collections::BTreeSet::new());
    let slots: [(&str, usize); 4] = [
        ("STANDARD_NOSE", 0),
        ("STANDARD_ARM1", 1),
        ("STANDARD_ARM2", 2),
        ("STANDARD_ARM3", 3),
    ];
    for e in pak.entries() {
        let Ok(b) = pak.read(e) else { continue };
        let Ok(o) = IdxdObject::parse(&b) else { continue };
        if !o.tokens().first().is_some_and(|s| s.ends_with("UNITS")) {
            continue;
        }
        for (header, idx) in slots {
            let dst = match idx {
                0 => &mut a.nose,
                1 => &mut a.arm1,
                2 => &mut a.arm2,
                _ => &mut a.arm3,
            };
            for w in hardpoint_options(o.tokens(), header) {
                if seen[idx].insert(w.clone()) {
                    dst.push(w);
                }
            }
        }
    }
    a
}

// ── Generic record access (any of the ~105 schemas) ─────────────────────────────

/// A generically-decoded IDXD record: its table name, schema id, identity, and
/// every explicitly-set field. Use this to read the ~99 schemas without a bespoke
/// struct (missions, UI layouts, effects, enums, …).
#[derive(Debug, Clone)]
pub struct Record {
    /// The table name — `token[0]` (e.g. `Weapon`, `StageResource`, `Sperkers`).
    /// May carry a stray leading byte from the string-pool boundary on some
    /// records; group by [`schema`](Self::schema), not this, for reliability.
    pub table: String,
    pub schema: u32,
    pub id: Option<String>,
    pub name: Option<String>,
    /// Every explicitly-set field (`key → value`).
    pub fields: BTreeMap<String, String>,
}

impl Record {
    fn from_idxd(o: &IdxdObject) -> Option<Self> {
        Some(Record {
            table: o.tokens().first().cloned().unwrap_or_default(),
            schema: o.schema_hash,
            id: o.get_raw("ID").map(str::to_string),
            name: o.get_raw("Name").map(str::to_string),
            fields: field_map(o),
        })
    }
    pub fn f32(&self, key: &str) -> Option<f32> {
        as_f32(self.fields.get(key))
    }
    pub fn i64(&self, key: &str) -> Option<i64> {
        as_i64(self.fields.get(key))
    }
}

/// Load every record of an arbitrary `schema` from a pak, generically. Pair with
/// the [`schema`] constants or a hash from the IDXD census.
pub fn load_records(pak: &PakArchive, schema: u32) -> Vec<Record> {
    load_table(pak, schema, Record::from_idxd)
}

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

    #[test]
    fn as_f32_tolerates_trailing_f() {
        assert_eq!(as_f32(Some(&"3.0f".to_string())), Some(3.0));
        assert_eq!(as_f32(Some(&"-0.5".to_string())), Some(-0.5));
        assert_eq!(as_f32(Some(&"Vessel,Craft".to_string())), None);
    }

    // Integration tests against a real disc — skipped unless SYLPHEED_DISC is set.
    fn pak() -> Option<PakArchive> {
        let disc = std::env::var("SYLPHEED_DISC").ok()?;
        PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).ok()
    }

    #[test]
    fn loads_weapons() {
        let Some(pak) = pak() else { return };
        let ws = load_weapons(&pak);
        assert!(ws.len() >= 100, "expected ≥100 weapons, got {}", ws.len());
        let rocket = ws
            .iter()
            .find(|w| w.id.as_deref().is_some_and(|s| s.contains("DeltaSaber_Rocket")))
            .expect("Delta Saber rocket present");
        assert_eq!(rocket.velocity, Some(3000.0));
        assert_eq!(rocket.max_range, Some(6000.0));
        assert_eq!(rocket.loading_count, Some(1000));
        assert_eq!(rocket.target_type.as_deref(), Some("Vessel,Craft,Structure"));
    }

    #[test]
    fn loads_units_and_vessels() {
        let Some(pak) = pak() else { return };
        let units = load_units(&pak);
        assert!(units.len() >= 80);
        // Player craft: HP 1000, radar 500000.
        let player = units
            .iter()
            .find(|u| u.id.as_deref().is_some_and(|s| s.contains("DeltaSaber_T")))
            .expect("player craft present");
        assert_eq!(player.hp, Some(1000.0));
        assert_eq!(player.radar_range, Some(500000.0));

        let vessels = load_vessels(&pak);
        let flagship = vessels
            .iter()
            .find(|v| v.id.as_deref().is_some_and(|s| s.contains("SDBattleship")))
            .expect("SD-Battleship present");
        assert_eq!(flagship.hp, Some(100000.0));
        assert_eq!(flagship.turret_count, Some(25));
    }

    #[test]
    fn loads_player_configs() {
        let Some(pak) = pak() else { return };
        let cfgs = load_player_configs(&pak);
        assert!(!cfgs.is_empty());
        let c = &cfgs[0];
        assert_eq!(c.bullet_limit, Some(512));
        assert_eq!(c.laser_limit, Some(32));
        assert_eq!(c.rank_score_s, Some(10000));
    }

    #[test]
    fn loads_characters() {
        let Some(pak) = pak() else { return };
        let chars = load_characters(&pak);
        assert!(chars.len() >= 20, "expected many characters, got {}", chars.len());
        let raymond = chars
            .iter()
            .find(|c| c.id.as_deref() == Some("CharacterRAYMOND"))
            .expect("Raymond present");
        assert_eq!(raymond.faction.as_deref(), Some("TCAF"));
        assert!(!raymond.faces.is_empty());
        assert!(raymond.faces.iter().all(|f| f.texture.ends_with(".t32")));
    }

    #[test]
    fn loads_stages() {
        let Some(pak) = pak() else { return };
        let stages = load_stages(&pak);
        let s01 = stages.iter().find(|s| s.id == "S01").expect("stage S01 present");
        assert_eq!(s01.location.as_deref(), Some("Lebendorf"));
        assert_eq!(s01.phases, 3);
        assert_eq!(s01.unit_table.as_deref(), Some("EnumUnit_S01.tbl"));
        assert_eq!(s01.squadron_table.as_deref(), Some("UnitGroup_S01.tbl"));
        // 16-mission main campaign is present.
        for n in 1..=16 {
            assert!(stages.iter().any(|s| s.id == format!("S{n:02}")), "missing S{n:02}");
        }
    }

    #[test]
    fn loads_squadrons() {
        let Some(pak) = pak() else { return };
        let sq = load_squadrons(&pak);
        assert!(sq.len() >= 20, "expected many squadrons, got {}", sq.len());
        // A TCAF Rhino flight of Delta Sabers, membership matching its count.
        let rhino = sq
            .iter()
            .find(|s| {
                s.side.as_deref() == Some("TCAF")
                    && s.formation_id.as_deref().is_some_and(|f| f.contains("Rhino"))
                    && s.members.iter().any(|m| m.contains("DeltaSaber"))
            })
            .expect("a TCAF Rhino Delta Saber squadron");
        assert_eq!(rhino.members.len() as i64, rhino.count.unwrap_or(-1));
        assert!(rhino.members.iter().all(|m| m.contains("DeltaSaber")));
    }

    #[test]
    fn loads_demo_messages() {
        let Some(pak) = pak() else { return };
        let msgs = load_demo_messages(&pak);
        assert!(msgs.len() > 200, "expected many dialogue lines, got {}", msgs.len());
        // Most lines are `MSG_*` ids with a speaker; page keys extend the id.
        let msg_ids = msgs.iter().filter(|m| m.id.starts_with("MSG_")).count();
        assert!(msg_ids > msgs.len() * 9 / 10, "{msg_ids}/{}", msgs.len());
        // Attributed dialogue (an explicit speaker) is a minority — most lines are
        // unattributed system/continuation messages — but there are hundreds.
        let attributed = msgs.iter().filter(|m| m.character.is_some()).count();
        assert!(attributed > 500, "attributed lines: {attributed}");
        let m = msgs.iter().find(|m| m.character.is_some() && !m.page_keys.is_empty()).unwrap();
        assert!(m.page_keys.iter().all(|k| k.starts_with(&m.id)));
    }

    #[test]
    fn loads_unit_rosters() {
        let Some(pak) = pak() else { return };
        let rosters = load_unit_rosters(&pak);
        assert!(rosters.len() > 20, "expected many rosters, got {}", rosters.len());
        // At least a few tag their stage; all rosters carry combatants, no props.
        assert!(rosters.iter().filter(|r| r.stage.is_some()).count() >= 4);
        assert!(rosters.iter().all(|r| {
            !r.units.is_empty() && r.units.iter().all(|u| !u.contains("Asteroid"))
        }));
        // A roster tagged S01 exists and names the player's Delta Saber.
        let s01 = rosters.iter().find(|r| r.stage.as_deref() == Some("S01"));
        assert!(s01.is_some_and(|r| r.units.iter().any(|u| u.contains("DeltaSaber"))));
    }

    #[test]
    fn loads_pilot_rosters() {
        let Ok(disc) = std::env::var("SYLPHEED_DISC") else { return };
        let Ok(pak) = PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")) else { return };
        let rosters = load_pilot_rosters(&pak);
        assert!(!rosters.is_empty(), "expected player pilot rosters");
        // Some config assigns Katana to a Rhino callsign (she leads Rhino flight).
        assert!(rosters.iter().any(|r| {
            r.pilots.iter().any(|(cs, p)| cs.starts_with("Rhino") && p == "Katana")
        }));
        // Bird flight exists too.
        assert!(rosters.iter().any(|r| r.pilots.iter().any(|(cs, _)| cs.starts_with("Bird"))));
    }

    #[test]
    fn loads_arsenal() {
        let Ok(disc) = std::env::var("SYLPHEED_DISC") else { return };
        let Ok(pak) = PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")) else { return };
        let a = load_arsenal(&pak);
        assert!(a.nose.len() >= 5 && a.arm1.len() >= 5, "nose {} arm1 {}", a.nose.len(), a.arm1.len());
        assert!(a.nose.iter().any(|w| w.starts_with("Stiletto")));
        assert!(a.arm1.iter().any(|w| w.starts_with("Falcon")));
        // Headers never leak into the option lists.
        assert!([&a.nose, &a.arm1, &a.arm2, &a.arm3]
            .iter()
            .all(|v| v.iter().all(|w| !w.starts_with("STANDARD_") && *w != "Type")));
    }

    #[test]
    fn generic_records_read_any_schema() {
        let Some(pak) = pak() else { return };
        // Stage-resource table via the generic reader (no bespoke struct).
        let stages = load_records(&pak, 0x3c9a_e32e);
        assert!(!stages.is_empty());
        assert!(stages.iter().all(|r| r.table.ends_with("StageResource")));
    }
}
