//! Localization — resolve the game's text keys to display strings.
//!
//! Project Sylpheed stores its UI/story text in `IXUD` entries as **UTF-16LE**,
//! interleaved `text, key, text, key, …`: a prose string immediately followed by
//! the identifier that names it. Objectives (`S01_P1_Objective_00`), hints, lose
//! conditions, character names (`CharacterName_CharacterRAYMOND`) and cutscene
//! dialogue (`MSG_*`) all resolve this way. One pak per language — pass
//! `GP_MAIN_GAME_E.pak` for English, `GP_MAIN_GAME_J.pak` for Japanese, etc.
//!
//! ```no_run
//! use sylpheed_formats::{localization::TextIndex, PakArchive};
//! let pak = PakArchive::open("dat/GP_MAIN_GAME_E.pak").unwrap();
//! let text = TextIndex::build(&pak);
//! assert_eq!(text.character_name("CharacterRAYMOND"), Some("Raymond"));
//! for line in text.objectives("S01", 1) { println!("• {line}"); }
//! ```

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

/// A UTF-16 unit that belongs to a printable text run (ASCII + Latin-1 + Latin
/// Extended-A). Control codes and the CJK planes split tokens.
fn is_text_unit(u: u16) -> bool {
    matches!(u, 0x20..=0x7e | 0xa0..=0xff | 0x100..=0x17f)
}

/// Split a UTF-16LE blob into printable tokens (mirrors the subtitle tokenizer:
/// step 2 inside a run, 1 on a break to resync odd alignment).
fn utf16_tokens(bytes: &[u8]) -> Vec<String> {
    let mut out = Vec::new();
    let mut cur = String::new();
    let mut i = 0;
    while i + 1 < bytes.len() {
        let u = u16::from_le_bytes([bytes[i], bytes[i + 1]]);
        if is_text_unit(u) {
            if let Some(c) = char::from_u32(u as u32) {
                cur.push(c);
            }
            i += 2;
        } else {
            if cur.chars().count() >= 2 {
                out.push(std::mem::take(&mut cur));
            } else {
                cur.clear();
            }
            i += 1;
        }
    }
    if cur.chars().count() >= 2 {
        out.push(cur);
    }
    out
}

/// An identifier token — the key half of a `text, key` pair.
fn is_key(s: &str) -> bool {
    s.len() >= 5
        && s.contains('_')
        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
        && s.chars().any(|c| c.is_ascii_uppercase())
}

/// A display-text token — anything with a lowercase letter that isn't itself a key.
fn is_prose(s: &str) -> bool {
    !is_key(s) && s.chars().any(|c| c.is_ascii_lowercase())
}

/// Text-key → display-string index for one language, built from a `GP_MAIN_GAME_*`
/// pak.
#[derive(Debug, Clone, Default)]
pub struct TextIndex {
    entries: BTreeMap<String, String>,
}

impl TextIndex {
    /// Scan a language pak's `IXUD` entries and index every `text → key` pair.
    pub fn build(pak: &PakArchive) -> Self {
        let mut entries = BTreeMap::new();
        for e in pak.entries() {
            let Ok(b) = pak.read(e) else { continue };
            if b.len() < 4 || &b[0..4] != b"IXUD" {
                continue;
            }
            let toks = utf16_tokens(&b);
            for w in toks.windows(2) {
                if is_key(&w[1]) && is_prose(&w[0]) {
                    entries.entry(w[1].clone()).or_insert_with(|| w[0].clone());
                }
            }
        }
        TextIndex { entries }
    }

    /// The display string for a text key, if present.
    pub fn get(&self, key: &str) -> Option<&str> {
        self.entries.get(key).map(String::as_str)
    }

    /// Number of indexed keys.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// The objective lines for a stage/phase, e.g. `("S01", 1)`. Empty if none
    /// resolve (unused objective slots are simply absent on disc).
    pub fn objectives(&self, stage: &str, phase: u32) -> Vec<&str> {
        self.numbered(&format!("{stage}_P{phase}_Objective"))
    }

    /// The lose-condition lines for a stage/phase.
    pub fn lose_conditions(&self, stage: &str, phase: u32) -> Vec<&str> {
        self.numbered(&format!("{stage}_P{phase}_Lose"))
    }

    /// The hint lines for a stage/phase.
    pub fn hints(&self, stage: &str, phase: u32) -> Vec<&str> {
        self.numbered(&format!("{stage}_P{phase}_Hint"))
    }

    /// A character's display name from its id (`CharacterRAYMOND` → `Raymond`).
    pub fn character_name(&self, character_id: &str) -> Option<&str> {
        self.get(&format!("CharacterName_{character_id}"))
    }

    /// Collect a `<base>_NN` run of lines while they resolve.
    fn numbered(&self, base: &str) -> Vec<&str> {
        (0..16)
            .filter_map(|i| self.get(&format!("{base}_{i:02}")))
            .collect()
    }
}

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

    #[test]
    fn tokenizes_mixed_runs() {
        // "Hi" NUL "K_1" as UTF-16LE → two tokens.
        let mut b = Vec::new();
        for s in ["Hi", "\0", "K_1x"] {
            for c in s.chars() {
                b.extend_from_slice(&(c as u16).to_le_bytes());
            }
        }
        let t = utf16_tokens(&b);
        assert!(t.contains(&"Hi".to_string()));
        assert!(t.contains(&"K_1x".to_string()));
    }

    #[test]
    fn key_and_prose_classify() {
        assert!(is_key("S01_P1_Objective_00"));
        assert!(is_key("CharacterName_CharacterRAYMOND"));
        assert!(!is_key("Repel the attack."));
        assert!(is_prose("Repel the attack."));
        assert!(is_prose("Raymond"));
        assert!(!is_prose("S01_P1_Objective_00"));
    }

    // Disc-backed — skipped unless SYLPHEED_DISC is set.
    #[test]
    fn resolves_real_text() {
        let Ok(disc) = std::env::var("SYLPHEED_DISC") else { return };
        let Ok(pak) = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")) else { return };
        let t = TextIndex::build(&pak);
        assert!(t.len() > 5000, "expected a large index, got {}", t.len());
        assert_eq!(t.character_name("CharacterRAYMOND"), Some("Raymond"));
        assert_eq!(
            t.get("S01_P1_Objective_00"),
            Some("Repel the enemy's surprise attack.")
        );
        assert!(!t.objectives("S01", 1).is_empty());
    }
}
