//! The movie manifest: the game's authoritative **mission → phase → cutscene**
//! table (statically reversed). Beyond the movie→voice binding, it defines the
//! whole cutscene *structure*: which video (+ subtitle, + on-screen text overlay,
//! + voice) plays at each mission phase, and of what kind (story intro, in-mission
//! phase cutscene, resupply, …).
//!
//! ## On-disc structure (`dat/tables.pak`, entry hash `0x5b983a08`)
//!
//! One `IDXD` table (`Z1`+zlib block), schema `0x067025b9`. Unlike a flat IDXD
//! object, this one is an **array of records**, and its string pool is laid out
//! as **two consecutive arrays**:
//!   1. the **slot KEYS** — `LOGO1`, `ADVERTISE_MOVIE`, `STAFF_ROLL`, then one
//!      per cutscene: `MS<NN><X>`, `STAGE<NN>_PHASE0<N>`, `STAGE<NN>_PHASE_END…`,
//!      `S<NN>_SUPPLY_<ACROPOLIS|TANKER>`, in play order;
//!   2. the **value RECORDS**, in the same order — each a run led by `<movie>.wmv`
//!      and (optionally) a `<pak>+…​.prt` overlay (field `TELOP`), a
//!      `<pak>+SUBTITLE_<movie>.tbl` (field `SUBTITLE`), and a `VOICE_<token>`
//!      (field `VOICETRACK`). A few slot keys are **valueless** (e.g. stages 5 and
//!      12 have no resupply video) — those slots have no record and are skipped.
//!
//! The **slot key is the semantic role** — this is how the game separates a
//! cutscene voice from in-mission radio chatter (structurally, by which slot/bank,
//! not one boolean): `MS*` = story intro (`VOICE_S*`), `STAGE*_PHASE*` = radio-box
//! cutscene (`VOICE_RT*`), `S*_SUPPLY_*` = resupply (`VOICE_D_45x` or none).
//! Resupply videos are **reused across stages** (`S04_SUPPLY_ACROPOLIS` →
//! `hokyu_DS_s02A`), so the mapping is genuinely a table, not derivable from names.
//!
//! The voice token is likewise **not** always `VOICE_<movie>`: most movies use
//! `VOICE_<movie>` (in `<lang>\Movie\`), but the resupply cutscenes bind to
//! in-mission radio clips like `VOICE_D_450` (in `<lang>\etc\`), and some have no
//! voice at all. This manifest is the ground truth.
//!
//! ## Alignment
//!
//! Slot keys and value records would be a 1:1 positional zip but for the valueless
//! slots. We recover the gaps without decoding the binary node/index region by
//! using the fully-derivable **`MS<NN><X>` → `S<NN><X>` anchors**: a non-`MS` slot
//! whose record would be an `MS` target actually belongs to the upcoming `MS`
//! slot, so the non-`MS` slot is a gap. (Decoding the node region would make this
//! exact and also unlock the defaulted numeric fields in other IDXD tables.)

use crate::slb::VoiceLang;

/// The role a cutscene slot plays in the mission flow, from its slot key.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MovieKind {
    /// `LOGO*`, `ADVERTISE_MOVIE`, `STAFF_ROLL` — boot/system movies.
    System,
    /// `MS<NN><X>` — story intro movie (camera on the speaker; stereo `VOICE_S*`).
    Intro,
    /// `STAGE<NN>_PHASE0<N>` — in-mission phase cutscene (radio box; `VOICE_RT*`).
    Phase,
    /// `STAGE<NN>_PHASE_END[_0N]` — phase-end cutscene.
    PhaseEnd,
    /// `S<NN>_SUPPLY_<ACROPOLIS|TANKER>` — resupply (hokyu) cutscene.
    Supply,
}

/// One cutscene slot's manifest row: its semantic slot plus the bound assets.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MovieEntry {
    /// The slot key, e.g. `STAGE01_PHASE01`, `MS00A`, `S02_SUPPLY_ACROPOLIS`.
    /// Empty when parsed from a blob without the slot-key array (fallback).
    pub slot: String,
    /// The slot's role in the mission flow (intro vs. phase cutscene vs. supply …).
    pub kind: MovieKind,
    /// Mission/stage number (`STAGE<NN>` / `MS<NN>` / `S<NN>_SUPPLY`), if any.
    pub mission: Option<u8>,
    /// Phase number for [`MovieKind::Phase`]/[`MovieKind::PhaseEnd`], if any.
    pub phase: Option<u8>,
    /// Movie basename without extension, e.g. `S13A`, `RT01A`, `hokyu_DS_s13A`.
    pub movie: String,
    /// Voice bank basename (`VOICE_S13A`, `VOICE_D_450`), or `None` for no voice.
    pub voice_token: Option<String>,
    /// Subtitle table basename (`SUBTITLE_S13A.tbl`), pak prefix stripped, if any.
    pub subtitle: Option<String>,
    /// On-screen text-overlay (`TELOP`) basename (`pwrt01.prt`), if any.
    pub telop: Option<String>,
}

/// Does this blob look like the movie manifest? (`IDXD` whose string pool
/// carries movie + subtitle + voice references.) Used to locate it in
/// `tables.pak` without hardcoding a hash.
pub fn is_manifest(bytes: &[u8]) -> bool {
    bytes.len() >= 4
        && &bytes[0..4] == b"IDXD"
        && contains(bytes, b".wmv")
        && contains(bytes, b"VOICE_")
        && contains(bytes, b"SUBTITLE_")
}

/// The field-type keys that appear once in the value region as a schema template.
const FIELD_KEYS: [&str; 4] = ["MOVIE", "VOICETRACK", "SUBTITLE", "TELOP"];

/// Parse the manifest into per-slot cutscene rows, in play order.
///
/// Each returned [`MovieEntry`] carries a movie; valueless slots (e.g. stage 5's
/// absent resupply) are omitted. When the two-array slot/record layout is present
/// (the real manifest) every row is tagged with its authoritative slot/kind/
/// mission/phase; otherwise (a blob without the key array) the kind is inferred
/// from the movie name and `slot` is empty.
pub fn parse(bytes: &[u8]) -> Vec<MovieEntry> {
    let toks = ascii_runs(bytes, 3);

    // Locate the slot-key array (`LOGO1` … first `<movie>.wmv`) and the value
    // region (from the first `.wmv` onward). Fall back to a keyless scrape.
    let first_wmv = toks.iter().position(|t| t.ends_with(".wmv"));
    let key_start = toks.iter().position(|t| t == "LOGO1");
    let (slot_keys, value_toks): (Vec<&str>, &[String]) = match (key_start, first_wmv) {
        (Some(k), Some(w)) if k < w => (
            toks[k..w].iter().map(String::as_str).collect(),
            &toks[w..],
        ),
        _ => (Vec::new(), toks.as_slice()),
    };

    // Build the value records (drop the schema-template field keys).
    let mut records: Vec<MovieEntry> = Vec::new();
    for t in value_toks.iter().filter(|t| !FIELD_KEYS.contains(&t.as_str())) {
        if let Some(name) = t.strip_suffix(".wmv") {
            records.push(MovieEntry {
                slot: String::new(),
                kind: kind_from_movie(name),
                mission: None,
                phase: None,
                movie: name.to_string(),
                voice_token: None,
                subtitle: None,
                telop: None,
            });
        } else if let Some(rec) = records.last_mut() {
            if let Some(v) = t.strip_prefix("VOICE_") {
                if rec.voice_token.is_none() {
                    rec.voice_token = Some(format!("VOICE_{v}"));
                }
            } else if t.ends_with(".tbl") {
                rec.subtitle.get_or_insert_with(|| after_plus(t));
            } else if t.ends_with(".prt") {
                rec.telop.get_or_insert_with(|| after_plus(t));
            }
        }
    }

    // Attach slot keys to records, recovering valueless-slot gaps via MS anchors.
    if !slot_keys.is_empty() {
        let ms_targets: std::collections::HashSet<String> = slot_keys
            .iter()
            .filter_map(|k| ms_target(k))
            .collect();
        let mut j = 0usize;
        for slot in &slot_keys {
            if j >= records.len() {
                break;
            }
            // A gap: this slot's "record" is actually the next MS slot's movie.
            let would_be = &records[j].movie;
            let is_gap = match ms_target(slot) {
                // MS slot: only binds if its record is the expected `S<NN><X>`.
                Some(expected) => *would_be != expected,
                // Non-MS slot: a gap iff the record belongs to an upcoming MS slot.
                None => ms_targets.contains(would_be),
            };
            if is_gap {
                continue;
            }
            let (kind, mission, phase) = classify_slot(slot);
            let rec = &mut records[j];
            rec.slot = (*slot).to_string();
            rec.kind = kind;
            rec.mission = mission;
            rec.phase = phase;
            j += 1;
        }
    }

    records
}

/// If `slot` is an `MS<NN><X>` intro slot, the movie it must bind to (`S<NN><X>`).
fn ms_target(slot: &str) -> Option<String> {
    let rest = slot.strip_prefix("MS")?;
    // `<NN><X>`: two digits then a letter suffix.
    if rest.len() >= 3 && rest[..2].bytes().all(|b| b.is_ascii_digit()) {
        Some(format!("S{rest}"))
    } else {
        None
    }
}

/// Semantic role + mission/phase from a slot key.
fn classify_slot(slot: &str) -> (MovieKind, Option<u8>, Option<u8>) {
    if let Some(rest) = slot.strip_prefix("MS") {
        if rest.len() >= 2 {
            return (MovieKind::Intro, rest[..2].parse().ok(), None);
        }
    }
    if let Some(rest) = slot.strip_prefix("STAGE") {
        let nn: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
        if let Ok(m) = nn.parse::<u8>() {
            if slot.contains("PHASE_END") {
                // trailing `_NN` if present (`STAGE12_PHASE_END_02`)
                let phase = slot.rsplit('_').next().and_then(|x| x.parse().ok());
                return (MovieKind::PhaseEnd, Some(m), phase);
            }
            // `STAGE<NN>_PHASE0<N>`
            let phase = rest[nn.len()..]
                .strip_prefix("_PHASE")
                .and_then(|p| p.parse().ok());
            return (MovieKind::Phase, Some(m), phase);
        }
    }
    if slot.contains("_SUPPLY") {
        if let Some(rest) = slot.strip_prefix('S') {
            let nn: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
            return (MovieKind::Supply, nn.parse().ok(), None);
        }
    }
    (MovieKind::System, None, None)
}

/// Fallback kind from a movie basename (used when no slot-key array is present).
fn kind_from_movie(movie: &str) -> MovieKind {
    if movie.starts_with("hokyu_") {
        MovieKind::Supply
    } else if movie.starts_with("RT") {
        MovieKind::Phase
    } else if movie.len() >= 3
        && movie.starts_with('S')
        && movie[1..3].bytes().all(|b| b.is_ascii_digit())
    {
        MovieKind::Intro
    } else {
        MovieKind::System
    }
}

/// The part of a `<pak>+<name>` reference after the `+` (or the whole string).
fn after_plus(s: &str) -> String {
    s.rsplit('+').next().unwrap_or(s).to_string()
}

/// The voice bank basename bound to `movie`, if the manifest lists one.
///
/// Returns `None` both when the movie is absent and when it is present with no
/// voice — callers that need to distinguish should use [`parse`].
pub fn voice_token(bytes: &[u8], movie: &str) -> Option<String> {
    parse(bytes)
        .into_iter()
        .find(|e| e.movie == movie)
        .and_then(|e| e.voice_token)
}

/// Resolve a movie to its full `sound.pak` voice-entry name for `lang`, using the
/// manifest for the *token* and `sounds.tbl` for the token's *directory* (Movie /
/// etc / Voice differ per token). `None` = the movie has no voice-over.
pub fn resolve_voice_entry(
    manifest: &[u8],
    sounds_tbl: &[u8],
    movie: &str,
    lang: VoiceLang,
) -> Option<String> {
    let token = voice_token(manifest, movie)?;
    resolve_token_path(sounds_tbl, &token, lang)
}

/// Find the full `<lang>\…\<token>.slb` path for a voice `token` in a decompressed
/// `sounds.tbl` (the token's subdir — `Movie`, `etc`, `Voice` — is not fixed).
pub fn resolve_token_path(sounds_tbl: &[u8], token: &str, lang: VoiceLang) -> Option<String> {
    let prefix = format!("{}\\", lang.code_pub());
    let suffix = format!("\\{token}.slb");
    ascii_runs(sounds_tbl, 6)
        .into_iter()
        .find(|r| r.starts_with(&prefix) && r.ends_with(&suffix))
}

fn contains(hay: &[u8], needle: &[u8]) -> bool {
    hay.windows(needle.len()).any(|w| w == needle)
}

/// Collect printable-ASCII runs of at least `min` chars.
fn ascii_runs(bytes: &[u8], min: usize) -> Vec<String> {
    let mut out = Vec::new();
    let mut cur = String::new();
    for &b in bytes {
        if (0x20..=0x7e).contains(&b) {
            cur.push(b as char);
        } else {
            if cur.len() >= min {
                out.push(std::mem::take(&mut cur));
            } else {
                cur.clear();
            }
        }
    }
    if cur.len() >= min {
        out.push(cur);
    }
    out
}

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

    fn synth() -> Vec<u8> {
        // Minimal IDXD string pool: three movies — a standard one, a hokyu bound
        // to a VOICE_D radio clip, and a hokyu with no voice.
        let mut b = b"IDXD".to_vec();
        b.extend_from_slice(&[0, 0, 0, 0x69, 0, 0]); // binary header (as on disc)
        for s in [
            "S13A.wmv",
            "eng.pak+SUBTITLE_S13A.tbl",
            "VOICE_S13A",
            "hokyu_LS_s02A.wmv",
            "eng.pak+SUBTITLE_hokyu_LS_s02A.tbl",
            "VOICE_D_450",
            "hokyu_DS_s13A.wmv",
            "eng.pak+SUBTITLE_hokyu_DS_s13A.tbl",
        ] {
            b.extend_from_slice(s.as_bytes());
            b.push(0);
        }
        b
    }

    #[test]
    fn groups_movies_and_binds_voice() {
        // Keyless fallback path: movie + voice + subtitle + inferred kind.
        let m = parse(&synth());
        assert_eq!(m.len(), 3);
        assert_eq!(m[0].movie, "S13A");
        assert_eq!(m[0].voice_token.as_deref(), Some("VOICE_S13A"));
        assert_eq!(m[0].kind, MovieKind::Intro);
        assert_eq!(m[0].subtitle.as_deref(), Some("SUBTITLE_S13A.tbl"));
        assert_eq!(m[1].voice_token.as_deref(), Some("VOICE_D_450"));
        assert_eq!(m[1].kind, MovieKind::Supply);
        assert_eq!(m[2].voice_token, None, "hokyu_DS_s13A has no voice-over");
        assert_eq!(m[2].kind, MovieKind::Supply);
    }

    /// Build a two-array (keys-then-records) manifest like the real one.
    fn synth_two_array(keys: &[&str], records: &[&[&str]]) -> Vec<u8> {
        let mut b = b"IDXD".to_vec();
        b.extend_from_slice(&[0, 0, 0, 0x05, 0, 0]);
        for k in keys {
            b.extend_from_slice(k.as_bytes());
            b.push(0);
        }
        for rec in records {
            for s in *rec {
                b.extend_from_slice(s.as_bytes());
                b.push(0);
            }
        }
        b
    }

    #[test]
    fn two_array_slots_kind_mission_phase() {
        let blob = synth_two_array(
            &["LOGO1", "MS01A", "STAGE01_PHASE01", "STAGE01_PHASE_END_02"],
            &[
                &["logo1.wmv", "MOVIE"],
                &["S01A.wmv", "eng.pak+SUBTITLE_S01A.tbl", "VOICE_S01A"],
                &["RT01A.wmv", "eng.pak+pwrt01.prt", "VOICE_RT01A", "VOICETRACK"],
                &["RT01C_2.wmv", "VOICE_RT01C_2"],
            ],
        );
        let m = parse(&blob);
        assert_eq!(m.len(), 4);
        assert_eq!((m[0].slot.as_str(), m[0].kind), ("LOGO1", MovieKind::System));
        assert_eq!(
            (m[1].slot.as_str(), m[1].kind, m[1].mission),
            ("MS01A", MovieKind::Intro, Some(1))
        );
        assert_eq!(
            (m[2].slot.as_str(), m[2].kind, m[2].mission, m[2].phase),
            ("STAGE01_PHASE01", MovieKind::Phase, Some(1), Some(1))
        );
        assert_eq!(m[2].telop.as_deref(), Some("pwrt01.prt"));
        assert_eq!(
            (m[3].slot.as_str(), m[3].kind, m[3].phase),
            ("STAGE01_PHASE_END_02", MovieKind::PhaseEnd, Some(2))
        );
    }

    #[test]
    fn valueless_supply_slot_is_skipped_via_ms_anchor() {
        // S05_SUPPLY has no record; its "record" (S06A) belongs to the next MS slot.
        let blob = synth_two_array(
            &["LOGO1", "MS05A", "S05_SUPPLY_ACROPOLIS", "MS06A"],
            &[
                &["logo1.wmv"],
                &["S05A.wmv", "VOICE_S05A"],
                &["S06A.wmv", "VOICE_S06A"],
            ],
        );
        let m = parse(&blob);
        assert_eq!(m.len(), 3, "the valueless supply slot yields no entry");
        assert_eq!((m[1].slot.as_str(), m[1].movie.as_str()), ("MS05A", "S05A"));
        // S06A binds to MS06A, NOT to the intervening valueless supply slot.
        assert_eq!((m[2].slot.as_str(), m[2].movie.as_str()), ("MS06A", "S06A"));
        assert!(m.iter().all(|e| e.slot != "S05_SUPPLY_ACROPOLIS"));
    }

    #[test]
    fn resolves_token_directory_from_sounds_tbl() {
        // sounds.tbl places the two tokens in different subdirs.
        let mut tbl = Vec::new();
        for s in ["eng\\Movie\\VOICE_S13A.slb", "eng\\etc\\VOICE_D_450.slb"] {
            tbl.extend_from_slice(s.as_bytes());
            tbl.push(0);
        }
        let man = synth();
        assert_eq!(
            resolve_voice_entry(&man, &tbl, "S13A", VoiceLang::English).as_deref(),
            Some("eng\\Movie\\VOICE_S13A.slb")
        );
        assert_eq!(
            resolve_voice_entry(&man, &tbl, "hokyu_LS_s02A", VoiceLang::English).as_deref(),
            Some("eng\\etc\\VOICE_D_450.slb")
        );
        assert_eq!(
            resolve_voice_entry(&man, &tbl, "hokyu_DS_s13A", VoiceLang::English),
            None
        );
    }
}
