//! IXUD localized-string / subtitle table reader.
//!
//! `IXUD` entries in the language packs (`dat/movie/<lang>.pak`, the
//! `GP_MAIN_GAME_<lang>` tables, …) hold localized UTF-16**BE** strings. The
//! movie language packs use them as **subtitle cue lists**: a `SUBTITLE` header
//! followed by alternating `(text, timecode)` tokens, e.g.
//!
//! ```text
//! SUBTITLE
//! "Calm down!"                        00:09.40-00:11.00
//! "Why are you taking Margras away?"  00:18.50-00:19.70
//! ```
//!
//! Reference-style tracks store a message key (`MSG_DEMO_240`) plus a single
//! start timecode instead of inline text; the actual string then lives in a
//! separate global table (not resolved here).
//!
//! ## Binary layout (as far as needed)
//! ```text
//! 0x00  4   Magic "IXUD"
//! 0x04  4   version (1)
//! 0x08  4   schema/type hash (constant 0x6CC83E70)
//! ..    ..  record directory { key_hash u32, offset u32, len u32 } × n
//! ..    ..  UTF-16BE string pool, NUL-terminated entries
//! ```
//! We don't need the directory to *present* the track — decoding the pool and
//! pairing tokens after the `SUBTITLE` header is enough and robust.

/// Magic at the start of every IXUD entry.
pub const IXUD_MAGIC: [u8; 4] = *b"IXUD";

/// One subtitle cue: a start time (seconds), optional end time, and the text
/// (or a `MSG_*` reference key for reference-style tracks).
#[derive(Debug, Clone, PartialEq)]
pub struct Cue {
    pub start: f32,
    pub end: Option<f32>,
    pub text: String,
}

/// A decoded subtitle track.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Subtitle {
    pub cues: Vec<Cue>,
}

impl Subtitle {
    /// True when the track carries no inline text — every cue is a `MSG_*`
    /// reference whose string lives in a separate global table.
    pub fn is_reference_only(&self) -> bool {
        !self.cues.is_empty() && self.cues.iter().all(|c| c.text.starts_with("MSG_"))
    }
}

/// Whether `bytes` is an IXUD entry.
pub fn is_ixud(bytes: &[u8]) -> bool {
    bytes.len() >= 4 && bytes[0..4] == IXUD_MAGIC
}

/// Parse an IXUD entry as a subtitle track. Returns `None` if not IXUD.
/// Malformed pools yield as many well-formed cues as can be recovered.
pub fn parse(bytes: &[u8]) -> Option<Subtitle> {
    if !is_ixud(bytes) {
        return None;
    }

    // Decode the whole payload as UTF-16BE and split into NUL-terminated,
    // printable runs. The binary header/directory decodes to control/CJK-range
    // junk that we skip by seeking to the `SUBTITLE` marker.
    let tokens = utf16be_tokens(bytes);
    let start = tokens
        .iter()
        .position(|t| t.ends_with("SUBTITLE"))
        .map(|i| i + 1)
        .unwrap_or(0);
    let rest = &tokens[start..];

    // Pair (text, timecode). A token that parses as a timecode closes the cue
    // opened by the preceding text token; unpaired tokens are skipped so a
    // single glitch doesn't desync the rest.
    let mut cues = Vec::new();
    let mut i = 0;
    while i + 1 < rest.len() {
        if let Some((s, e)) = parse_timing(&rest[i + 1]) {
            cues.push(Cue {
                start: s,
                end: e,
                text: rest[i].clone(),
            });
            i += 2;
        } else {
            i += 1;
        }
    }
    Some(Subtitle { cues })
}

/// Split the payload (interpreted as UTF-16BE) into printable, NUL-delimited
/// tokens. Control chars terminate the current token.
fn utf16be_tokens(bytes: &[u8]) -> Vec<String> {
    let mut tokens = Vec::new();
    let mut cur = String::new();
    for pair in bytes.chunks_exact(2) {
        let u = u16::from_be_bytes([pair[0], pair[1]]);
        match char::from_u32(u as u32) {
            Some(c) if !c.is_control() => cur.push(c),
            _ => {
                if !cur.is_empty() {
                    tokens.push(std::mem::take(&mut cur));
                }
            }
        }
    }
    if !cur.is_empty() {
        tokens.push(cur);
    }
    tokens
}

/// Parse `MM:SS.ss` (`" 9:04.40"` style, minutes may be blank/space-padded) or
/// a `start-end` range into seconds.
fn parse_timing(s: &str) -> Option<(f32, Option<f32>)> {
    let one = |p: &str| -> Option<f32> {
        let (mm, ss) = p.trim().split_once(':')?;
        let m: f32 = if mm.trim().is_empty() {
            0.0
        } else {
            mm.trim().parse().ok()?
        };
        let sec: f32 = ss.trim().parse().ok()?;
        Some(m * 60.0 + sec)
    };
    match s.split_once('-') {
        Some((a, b)) => Some((one(a)?, Some(one(b)?))),
        None => Some((one(s)?, None)),
    }
}

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

    /// Build a synthetic IXUD payload: an 8-byte header stand-in + UTF-16BE
    /// NUL-terminated tokens.
    fn synth(tokens: &[&str]) -> Vec<u8> {
        let mut b = IXUD_MAGIC.to_vec();
        b.extend_from_slice(&[0, 0, 0, 1, 0x6C, 0xC8, 0x3E, 0x70]); // version + schema
        for t in tokens {
            for u in t.encode_utf16() {
                b.extend_from_slice(&u.to_be_bytes());
            }
            b.extend_from_slice(&[0, 0]); // NUL
        }
        b
    }

    #[test]
    fn inline_subtitle_track() {
        let b = synth(&[
            "SUBTITLE",
            "Calm down!",
            "00:09.40-00:11.00",
            "Let go of me!",
            "00:13.10-00:14.50",
        ]);
        let sub = parse(&b).unwrap();
        assert_eq!(sub.cues.len(), 2);
        assert_eq!(sub.cues[0].text, "Calm down!");
        assert_eq!(sub.cues[0].start, 9.4);
        assert_eq!(sub.cues[0].end, Some(11.0));
        assert!(!sub.is_reference_only());
    }

    #[test]
    fn reference_track() {
        let b = synth(&["SUBTITLE", "MSG_DEMO_240", "00:01.00", "MSG_DEMO_241", "00:03.20"]);
        let sub = parse(&b).unwrap();
        assert_eq!(sub.cues.len(), 2);
        assert_eq!(sub.cues[0].end, None);
        assert!(sub.is_reference_only());
    }

    #[test]
    fn rejects_non_ixud() {
        assert!(parse(b"IDXD\0\0\0\0").is_none());
    }
}
