//! `.slb` XACT sound banks → a decodable XMA1 `RIFF`.
//!
//! `dat/sound.pak` (9519 entries across `sound.p00..p04`) is the game's audio
//! bank. Each entry is an XACT `.slb` wrapping **XMA1** (`fmt ` tag `0x0165`,
//! 48 kHz). Files are named `<lang>\Voice\…`, `<lang>\Movie\VOICE_<movie>.slb`,
//! `BGM_###.slb`, etc. (see `docs/re/structures/sound-slb.md`); look them up by
//! [`crate::hash::name_hash`]. Movie cutscene voice = one continuous
//! `<eng|jpn>\Movie\VOICE_<movie>.slb` track meant to play from the video start.
//!
//! Two on-disc layouts, both reversed statically:
//! - **RIFF present** — a standard `RIFF/WAVE` sits inside the bank; its 32-byte
//!   `fmt ` is the real `XMAWAVEFORMAT` and the XMA packets are everything after
//!   that RIFF's `data` chunk header (the declared `data` size is unreliable, so
//!   we take to end and let the caller clamp to the known media length).
//! - **Headerless** — no RIFF at all; a fixed **1392-byte** header precedes raw
//!   XMA1 packets (48 kHz, 2 channels).
//!
//! [`to_xma_riff`] rebuilds a standalone `RIFF/WAVE` (XMA1) for either layout,
//! ready to hand to an XMA decoder (e.g. FFmpeg's `xma1`). The content is always
//! mono (some clips put it in the left channel only, others duplicate L=R), so
//! the decode step should downmix to mono (take the left channel).

/// Fixed offset of the raw XMA1 stream in a headerless `.slb` (no `RIFF`).
pub const HEADERLESS_DATA_OFFSET: usize = 1392;

/// Voice language for cutscene audio. Only English and Japanese voice exist on
/// the disc (subtitles cover more languages, voice does not).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VoiceLang {
    English,
    Japanese,
}

impl VoiceLang {
    fn code(self) -> &'static str {
        match self {
            VoiceLang::English => "eng",
            VoiceLang::Japanese => "jpn",
        }
    }

    /// `eng` / `jpn` — the `sound.pak` path prefix for this voice language.
    pub fn code_pub(self) -> &'static str {
        self.code()
    }
}

/// The `sound.pak` entry name for a movie's continuous voice track, e.g.
/// `eng\Movie\VOICE_RT07A.slb`. Hash it with [`crate::hash::name_hash`] to get
/// the `sound.pak` TOC key.
pub fn movie_voice_name(movie_basename: &str, lang: VoiceLang) -> String {
    format!("{}\\Movie\\VOICE_{}.slb", lang.code(), movie_basename)
}

/// One playable voice clip discovered in `sounds.tbl`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VoiceClip {
    /// Full `sound.pak` entry name, e.g. `eng\Voice\VOICE_ADAN_010.slb`.
    pub name: String,
    /// Speaker/category code, e.g. `ADAN`, `TCAF`, `A`, `RT07A`.
    pub speaker: String,
    /// Short UI label, e.g. `ADAN 010`.
    pub display: String,
}

/// Enumerate the voice/dialog clips named in a decompressed `sounds.tbl` (the
/// IDXD in `tables.pak`). Extracts every `<lang>\{Voice,etc,Movie,Briefing}\…`
/// path ending in `.slb` for `lang`, parsed into `(name, speaker, display)`.
pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> {
    let prefix = format!("{}\\", lang.code());
    let mut seen = std::collections::BTreeSet::new();
    let mut out = Vec::new();
    // Scan for printable-ASCII runs; keep those that look like a voice path.
    let mut i = 0;
    while i < sounds_tbl.len() {
        let start = i;
        while i < sounds_tbl.len() && (0x20..=0x7e).contains(&sounds_tbl[i]) {
            i += 1;
        }
        if i - start >= 6 {
            if let Ok(s) = std::str::from_utf8(&sounds_tbl[start..i]) {
                // Every spoken-line category, so the standalone player covers them
                // all: in-mission radio (`\Voice\`, `\etc\`) and bound movie voices
                // (`\Movie\`) all carry `VOICE_`; mission-briefing lines live in
                // `\Briefing\` as `BR<NN>_<MM>.slb` (no `VOICE` in the name).
                let is_voice = s.contains("VOICE") || s.contains("\\Briefing\\");
                if s.starts_with(&prefix) && s.ends_with(".slb") && is_voice {
                    if seen.insert(s.to_string()) {
                        out.push(parse_voice_clip(s));
                    }
                }
            }
        }
        i += 1;
    }
    out
}

fn parse_voice_clip(name: &str) -> VoiceClip {
    // `<lang>\<cat>\VOICE_<SPK>_<NNN>.slb` or `..\VOICE_<movie>.slb`.
    let stem = name
        .rsplit('\\')
        .next()
        .unwrap_or(name)
        .strip_suffix(".slb")
        .unwrap_or(name);
    let body = stem.strip_prefix("VOICE_").unwrap_or(stem);
    let (speaker, display) = match body.rsplit_once('_') {
        Some((spk, num)) if num.chars().all(|c| c.is_ascii_digit()) => {
            (spk.to_string(), format!("{spk} {num}"))
        }
        _ => (body.to_string(), body.to_string()),
    };
    VoiceClip {
        name: name.to_string(),
        speaker,
        display,
    }
}

/// Build one standalone XMA1 `RIFF/WAVE` **per sub-wave** in a `.slb` bank, in
/// on-disc order. A `.slb` is an XACT bank of one or more sub-waves; the sub-waves
/// are EITHER alternate takes (the first is the whole track — e.g. `VOICE_S01A`,
/// `VOICE_ADV`: sub0 ≈ the movie length) OR sequential segments that must be
/// concatenated (e.g. `VOICE_RT07A`: 24s + 14s + 11s ≈ the 50s movie). The caller
/// decodes each to PCM, concatenates in order, and clamps to the movie length —
/// that yields the full track for segment banks while the clamp drops the
/// duplicate takes for alternate-take banks. (Dynamic RE via Canary file-I/O
/// tracing confirmed the movie→voice binding; this fixes the *decode* of `RT*`.)
pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
    let mut out = Vec::new();
    if find(slb, b"RIFF", 0).is_none() {
        // Headerless single-stream bank.
        if let Some(data) = slb.get(HEADERLESS_DATA_OFFSET..) {
            if !data.is_empty() {
                out.push(build_riff(&synth_xma1_fmt(2, 2, 48000), data));
            }
        }
        return out;
    }
    let mut pos = 0usize;
    while let Some(ri) = find(slb, b"RIFF", pos) {
        // Parse this sub-wave's fmt + data (declared size is honest per sub-wave).
        let Some(fi) = find(slb, b"fmt ", ri) else { break };
        let Some(fsz) = le32(slb, fi + 4) else { break };
        let Some(fmt_end) = fi.checked_add(8).and_then(|v| v.checked_add(fsz as usize)) else {
            break;
        };
        if fmt_end > slb.len() {
            break;
        }
        let Some(di) = find(slb, b"data", fi) else { break };
        let Some(dsz) = le32(slb, di + 4) else { break };
        let Some(ds) = di.checked_add(8) else { break };
        let de = ds
            .checked_add(dsz as usize)
            .unwrap_or(slb.len())
            .min(slb.len());
        if let Some(data) = slb.get(ds..de) {
            if !data.is_empty() {
                out.push(build_riff(&slb[fi..fmt_end], data));
            }
        }
        // Advance past this sub-wave's data to find the next RIFF.
        pos = de.max(ri + 4);
    }
    out
}

/// Build a standalone XMA1 `RIFF/WAVE` from a `.slb` bank, decodable by FFmpeg's
/// `xma1`. Returns `None` if the bank is too small / malformed. This is the
/// FIRST sub-wave only; prefer [`to_xma_riffs`] for correct multi-segment banks.
pub fn to_xma_riff(slb: &[u8]) -> Option<Vec<u8>> {
    if let Some(ri) = find(slb, b"RIFF", 0) {
        // RIFF layout: a `.slb` is an XACT bank of one or more sub-waves, each
        // `[seek][RIFF: fmt + Dmmy pad + data][declared_size XMA bytes]`. Take the
        // FIRST sub-wave, bounded by its **declared `data` size** (which is
        // honest per sub-wave). Decoding to end-of-file instead would append the
        // later sub-waves — for multi-take story movies those are ALTERNATE takes,
        // which is what made S10–S16 play the wrong audio.
        let fi = find(slb, b"fmt ", ri)?;
        let fsz = le32(slb, fi + 4)? as usize;
        let fmt_end = fi.checked_add(8)?.checked_add(fsz)?;
        if fmt_end > slb.len() {
            return None;
        }
        let fmt_chunk = &slb[fi..fmt_end];
        let di = find(slb, b"data", fi)?;
        let dsz = le32(slb, di + 4)? as usize;
        let end = di.checked_add(8)?.checked_add(dsz)?.min(slb.len());
        let data = slb.get(di + 8..end)?;
        Some(build_riff(fmt_chunk, data))
    } else {
        // Headerless: 1392-byte header, then raw XMA1 (48 kHz, 2 channels).
        let data = slb.get(HEADERLESS_DATA_OFFSET..)?;
        if data.is_empty() {
            return None;
        }
        Some(build_riff(&synth_xma1_fmt(2, 2, 48000), data))
    }
}

/// Rebuild a standalone XMA1 `RIFF/WAVE` from a single-stream `.slb`, robust to
/// the layout variants seen in `<lang>\etc\` radio clips. Unlike [`to_xma_riff`]
/// (which assumes `RIFF → fmt → data` in order), this picks the **largest `data`
/// chunk anywhere** in the bank — some radio banks store the audio *before* the
/// trailing `RIFF`/`fmt` metadata (an empty post-`RIFF` `data` chunk), which the
/// ordered scan misses. Pairs it with the first `fmt ` chunk; falls back to the
/// headerless layout. Returns `None` only when no usable audio can be found.
pub fn to_xma_riff_best(slb: &[u8]) -> Option<Vec<u8>> {
    // Largest usable `data` chunk (bounded by its declared size and the buffer).
    let mut best: Option<(usize, usize)> = None; // (data offset, usable payload len)
    let mut i = 0;
    while let Some(di) = find(slb, b"data", i) {
        let declared = le32(slb, di + 4).unwrap_or(0) as usize;
        let usable = declared.min(slb.len().saturating_sub(di + 8));
        if best.map_or(true, |(_, b)| usable > b) {
            best = Some((di, usable));
        }
        i = di + 4;
    }
    if let (Some(fi), Some((di, sz))) = (find(slb, b"fmt ", 0), best) {
        if sz > 512 {
            let fsz = le32(slb, fi + 4)? as usize;
            let fmt_end = (fi + 8 + fsz).min(slb.len());
            let data = slb.get(di + 8..di + 8 + sz)?;
            return Some(build_riff(slb.get(fi..fmt_end)?, data));
        }
    }
    // Headerless fallback: fixed data offset, synthesized XMA1 stereo/48k fmt.
    let data = slb.get(HEADERLESS_DATA_OFFSET..)?;
    (!data.is_empty()).then(|| build_riff(&synth_xma1_fmt(2, 2, 48000), data))
}

/// A minimal `fmt ` chunk carrying an XMA1 `XMAWAVEFORMAT` (one stream).
fn synth_xma1_fmt(channels: u8, channel_mask: u16, rate: u32) -> Vec<u8> {
    let mut fmt = Vec::with_capacity(40);
    fmt.extend_from_slice(b"fmt ");
    fmt.extend_from_slice(&32u32.to_le_bytes());
    // XMAWAVEFORMAT header
    fmt.extend_from_slice(&0x0165u16.to_le_bytes()); // wFormatTag = XMA1
    fmt.extend_from_slice(&16u16.to_le_bytes()); // BitsPerSample
    fmt.extend_from_slice(&0u16.to_le_bytes()); // EncodeOptions
    fmt.extend_from_slice(&0u16.to_le_bytes()); // LargestSkip
    fmt.extend_from_slice(&1u16.to_le_bytes()); // NumStreams
    fmt.push(0); // LoopCount
    fmt.push(3); // Version
    // XMASTREAMFORMAT[0]
    fmt.extend_from_slice(&(rate * channels as u32 * 2).to_le_bytes()); // PsuedoBytesPerSec
    fmt.extend_from_slice(&rate.to_le_bytes()); // SampleRate
    fmt.extend_from_slice(&0u32.to_le_bytes()); // LoopStart
    fmt.extend_from_slice(&0u32.to_le_bytes()); // LoopEnd
    fmt.push(4); // SubframeData
    fmt.push(channels); // Channels
    fmt.extend_from_slice(&channel_mask.to_le_bytes()); // ChannelMask
    fmt
}

fn build_riff(fmt_chunk: &[u8], data: &[u8]) -> Vec<u8> {
    let mut body = Vec::with_capacity(4 + fmt_chunk.len() + 8 + data.len());
    body.extend_from_slice(b"WAVE");
    body.extend_from_slice(fmt_chunk);
    body.extend_from_slice(b"data");
    body.extend_from_slice(&(data.len() as u32).to_le_bytes());
    body.extend_from_slice(data);
    let mut out = Vec::with_capacity(8 + body.len());
    out.extend_from_slice(b"RIFF");
    out.extend_from_slice(&(body.len() as u32).to_le_bytes());
    out.extend_from_slice(&body);
    out
}

fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
    if from >= hay.len() {
        return None;
    }
    hay[from..]
        .windows(needle.len())
        .position(|w| w == needle)
        .map(|p| p + from)
}

fn le32(b: &[u8], o: usize) -> Option<u32> {
    let s = b.get(o..o + 4)?;
    Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}

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

    #[test]
    fn movie_voice_names_and_hashes() {
        assert_eq!(
            movie_voice_name("RT07A", VoiceLang::English),
            "eng\\Movie\\VOICE_RT07A.slb"
        );
        // Verified present in dat/sound.pak (VOICE_S00A == TOC entry 0x2A4F97D3).
        assert_eq!(name_hash("eng\\Movie\\VOICE_RT07A.slb"), 0xA44A_EA1C);
        assert_eq!(name_hash("eng\\Movie\\VOICE_S00A.slb"), 0x2A4F_97D3);
    }

    #[test]
    fn takes_only_first_subwave() {
        // Bank of TWO sub-waves; only the FIRST must be extracted (bounded by its
        // declared `data` size), not everything to end-of-file.
        let fmt = synth_xma1_fmt(2, 2, 48000);
        let mut slb = vec![0xAB; 16];
        slb.extend_from_slice(b"RIFF");
        slb.extend_from_slice(&999u32.to_le_bytes()); // riff size (ignored)
        slb.extend_from_slice(b"WAVE");
        slb.extend_from_slice(&fmt);
        slb.extend_from_slice(b"data");
        slb.extend_from_slice(&4u32.to_le_bytes()); // declared: 4 bytes
        slb.extend_from_slice(&[1, 2, 3, 4]); // sub-wave 1 XMA
        slb.extend_from_slice(&[9, 9, 9, 9]); // a second sub-wave's bytes — excluded
        let riff = to_xma_riff(&slb).unwrap();
        assert_eq!(&riff[0..4], b"RIFF");
        let dpos = find(&riff, b"data", 0).unwrap();
        assert_eq!(le32(&riff, dpos + 4), Some(4));
        assert_eq!(&riff[dpos + 8..], &[1, 2, 3, 4]);
    }

    #[test]
    fn list_voice_clips_covers_movie_radio_and_briefing() {
        // The standalone player must enumerate every spoken-line category: bound
        // movie voices, in-mission radio (\etc\ + \Voice\), and briefing (\Briefing\,
        // whose BR<NN>_<MM> names lack "VOICE"). Music (BGM_*) must stay excluded.
        let mut tbl = Vec::new();
        for s in [
            "eng\\Movie\\VOICE_S13A.slb",
            "eng\\etc\\VOICE_D_450.slb",
            "eng\\Voice\\VOICE_ADAN_010.slb",
            "eng\\Briefing\\BR01_01.slb",
            "eng\\bgm\\BGM_001.slb",
        ] {
            tbl.extend_from_slice(s.as_bytes());
            tbl.push(0);
        }
        let names: Vec<String> = list_voice_clips(&tbl, VoiceLang::English)
            .into_iter()
            .map(|c| c.name)
            .collect();
        for want in [
            "eng\\Movie\\VOICE_S13A.slb",
            "eng\\etc\\VOICE_D_450.slb",
            "eng\\Voice\\VOICE_ADAN_010.slb",
            "eng\\Briefing\\BR01_01.slb",
        ] {
            assert!(names.iter().any(|n| n == want), "missing {want}");
        }
        assert!(
            !names.iter().any(|n| n.contains("BGM_")),
            "music must not be listed as a voice clip"
        );
    }

    #[test]
    fn rebuilds_riff_from_headerless() {
        let mut slb = vec![0u8; HEADERLESS_DATA_OFFSET];
        slb.extend_from_slice(&[9, 8, 7, 6]);
        let riff = to_xma_riff(&slb).unwrap();
        let dpos = find(&riff, b"data", 0).unwrap();
        assert_eq!(&riff[dpos + 8..], &[9, 8, 7, 6]);
        // synthetic fmt advertises XMA1.
        let fpos = find(&riff, b"fmt ", 0).unwrap();
        assert_eq!(le32(&riff, fpos + 8).map(|v| v as u16), Some(0x0165));
    }
}
