//! Virtual file system abstraction.
//!
//! `GameAssets` provides a unified interface for accessing game files
//! regardless of whether they're in an extracted directory (the normal
//! development workflow) or read directly from an XISO (less common).
//!
//! ## Recommended workflow
//! 1. Run `xdvdfs unpack your_game.iso ./extracted/` once
//! 2. Point `GameAssets` at `./extracted/`
//! 3. Iterate fast — no ISO re-parsing on every launch

use std::path::{Path, PathBuf};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum VfsError {
    #[error("File not found: {0}")]
    NotFound(String),
    #[error("IO error reading {path}: {source}")]
    Io {
        path: String,
        #[source]
        source: std::io::Error,
    },
}

/// Provides access to extracted game files.
///
/// All paths are relative to the extraction root and use `/` as separator.
/// The implementation handles OS-specific path separators internally.
pub struct GameAssets {
    root: PathBuf,
}

impl GameAssets {
    /// Create a new `GameAssets` pointing at a directory of extracted files.
    ///
    /// Call `xdvdfs unpack game.iso ./extracted/` first.
    pub fn from_directory(root: impl Into<PathBuf>) -> Self {
        Self { root: root.into() }
    }

    /// Read a file's bytes by its in-game path.
    ///
    /// # Example
    /// ```no_run
    /// # use sylpheed_formats::vfs::GameAssets;
    /// let assets = GameAssets::from_directory("./extracted");
    /// let bytes = assets.read("DEFAULT.XEX").unwrap();
    /// ```
    pub fn read(&self, game_path: &str) -> Result<Vec<u8>, VfsError> {
        let disk_path = self.resolve(game_path);
        std::fs::read(&disk_path).map_err(|e| VfsError::Io {
            path: game_path.to_string(),
            source: e,
        })
    }

    /// Check whether a file exists.
    pub fn exists(&self, game_path: &str) -> bool {
        self.resolve(game_path).exists()
    }

    /// List all files under a subdirectory, recursively.
    ///
    /// Returns paths relative to the extraction root, with `/` separators.
    pub fn list(&self, subdir: &str) -> Result<Vec<String>, VfsError> {
        let dir = self.resolve(subdir);
        let mut result = Vec::new();
        self.walk_dir(&dir, &dir, &mut result).map_err(|e| VfsError::Io {
            path: subdir.to_string(),
            source: e,
        })?;
        Ok(result)
    }

    /// Resolve a game-relative path to an OS filesystem path.
    pub fn resolve(&self, game_path: &str) -> PathBuf {
        // Normalize separators and join to root
        let native = game_path.replace('/', std::path::MAIN_SEPARATOR_STR);
        self.root.join(native)
    }

    fn walk_dir(
        &self,
        dir: &Path,
        root: &Path,
        out: &mut Vec<String>,
    ) -> std::io::Result<()> {
        for entry in std::fs::read_dir(dir)? {
            let entry = entry?;
            let path = entry.path();
            if path.is_dir() {
                self.walk_dir(&path, root, out)?;
            } else {
                // Make path relative and use forward slashes
                let rel = path.strip_prefix(root).unwrap_or(&path);
                out.push(rel.to_string_lossy().replace('\\', "/"));
            }
        }
        Ok(())
    }
}

// ── Helpers for common file type detection ────────────────────────────────────

/// Sniff a file's format from its first bytes (magic number).
/// Use this to identify unknown file types during reverse engineering.
pub fn identify_format(bytes: &[u8]) -> FileFormat {
    if bytes.len() < 4 {
        // Very short files can still be text (e.g. a one-line config).
        return if is_probably_text(bytes) {
            FileFormat::Text
        } else {
            FileFormat::Unknown
        };
    }
    match &bytes[..4] {
        b"XPR2" => FileFormat::Xpr2Texture,
        b"RIFF" => FileFormat::Riff,      // could be WAV or XWB
        b"XWB\0" => FileFormat::XwbAudio, // Xbox Wave Bank
        [0x89, b'P', b'N', b'G'] => FileFormat::Png,
        b"DDS " => FileFormat::Dds,
        // No binary magic matched — fall back to a content heuristic so text
        // configs (`config.ini`, etc.) are recognised even though they have no
        // signature.
        _ if is_probably_text(bytes) => FileFormat::Text,
        _ => FileFormat::Unknown,
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileFormat {
    Xpr2Texture,
    Riff,
    XwbAudio,
    Png,
    Dds,
    /// Plain text (ASCII / UTF-8 / UTF-16) — `.ini` and similar.
    Text,
    Unknown,
}

impl FileFormat {
    pub fn extension_hint(self) -> &'static str {
        match self {
            Self::Xpr2Texture => "xpr",
            Self::Riff => "riff",
            Self::XwbAudio => "xwb",
            Self::Png => "png",
            Self::Dds => "dds",
            Self::Text => "txt",
            Self::Unknown => "bin",
        }
    }
}

// ── Text detection & decoding (pure std, WASM-safe) ───────────────────────────

/// True if `b` is a byte we'd expect inside a text file: common whitespace,
/// printable ASCII, or any high byte (UTF-8 continuation / Latin-1). Mirrors the
/// printable convention used by the IDXD string-pool extractor.
#[inline]
fn is_text_byte(b: u8) -> bool {
    matches!(b, b'\t' | b'\n' | b'\r') || (0x20..0x7f).contains(&b) || b >= 0x80
}

/// Heuristic sniff for whether `bytes` is human-readable text.
///
/// Recognises UTF-8 / UTF-16 BOMs outright, BOM-less UTF-16LE by its
/// characteristic odd-position NUL bytes, and otherwise requires a byte sample
/// to be almost entirely text bytes with no embedded NUL (NUL is the strongest
/// binary signal). Decoding is left to [`decode_text`].
pub fn is_probably_text(bytes: &[u8]) -> bool {
    if bytes.is_empty() {
        return false;
    }
    // Byte-order marks are unambiguous.
    if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) // UTF-8
        || bytes.starts_with(&[0xFF, 0xFE])   // UTF-16LE
        || bytes.starts_with(&[0xFE, 0xFF])
    // UTF-16BE
    {
        return true;
    }

    // Cap the scan so huge files stay cheap.
    let sample = &bytes[..bytes.len().min(4096)];

    // BOM-less UTF-16LE: ASCII text encodes as `<char> 0x00 <char> 0x00 …`, so
    // roughly half the bytes (the odd positions) are NUL and the even bytes are
    // printable.
    if sample.len() >= 16 {
        let nul_odd = sample.iter().skip(1).step_by(2).filter(|&&b| b == 0).count();
        if nul_odd * 2 >= sample.len() / 2 {
            let printable_even = sample.iter().step_by(2).filter(|&&b| is_text_byte(b)).count();
            if printable_even * 2 >= sample.len() / 2 - 2 {
                return true;
            }
        }
    }

    // Single-byte (ASCII / UTF-8): an embedded NUL means binary; otherwise
    // require the overwhelming majority to be text bytes.
    if sample.contains(&0) {
        return false;
    }
    let text = sample.iter().filter(|&&b| is_text_byte(b)).count();
    text * 100 >= sample.len() * 95
}

/// Decode `bytes` to a `String`, returning `(text, encoding_label)`.
///
/// Honours UTF-8 / UTF-16LE / UTF-16BE BOMs and BOM-less UTF-16LE, falling back
/// to lossy UTF-8. Never fails — undecodable sequences become U+FFFD. Pure std,
/// so both the viewer and CLI can share it.
pub fn decode_text(bytes: &[u8]) -> (String, &'static str) {
    if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
        return (String::from_utf8_lossy(rest).into_owned(), "UTF-8 (BOM)");
    }
    if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
        return (decode_utf16(rest, false), "UTF-16LE (BOM)");
    }
    if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
        return (decode_utf16(rest, true), "UTF-16BE (BOM)");
    }
    // BOM-less UTF-16LE detection (same signal as `is_probably_text`).
    let sample = &bytes[..bytes.len().min(4096)];
    if sample.len() >= 16 {
        let nul_odd = sample.iter().skip(1).step_by(2).filter(|&&b| b == 0).count();
        if nul_odd * 2 >= sample.len() / 2 {
            return (decode_utf16(bytes, false), "UTF-16LE");
        }
    }
    (String::from_utf8_lossy(bytes).into_owned(), "UTF-8 / ASCII")
}

fn decode_utf16(bytes: &[u8], big_endian: bool) -> String {
    let units: Vec<u16> = bytes
        .chunks_exact(2)
        .map(|c| {
            if big_endian {
                u16::from_be_bytes([c[0], c[1]])
            } else {
                u16::from_le_bytes([c[0], c[1]])
            }
        })
        .collect();
    String::from_utf16_lossy(&units)
}

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

    #[test]
    fn ascii_config_is_text() {
        let ini = b"[Video]\r\nWidth=1280\r\nHeight=720\r\n";
        assert!(is_probably_text(ini));
        assert_eq!(identify_format(ini), FileFormat::Text);
        let (text, enc) = decode_text(ini);
        assert!(text.contains("Width=1280"));
        assert_eq!(enc, "UTF-8 / ASCII");
    }

    #[test]
    fn magic_takes_precedence_over_text() {
        // An XPR2 header happens to start with printable bytes; the magic match
        // must win so it's never misfiled as text.
        let mut xpr = b"XPR2".to_vec();
        xpr.extend_from_slice(&[0u8; 32]);
        assert_eq!(identify_format(&xpr), FileFormat::Xpr2Texture);
    }

    #[test]
    fn binary_with_nul_is_not_text() {
        let bin = [0u8, 1, 2, 0xFF, 0x00, 0x89, 0x10, 0x00, 0x42];
        assert!(!is_probably_text(&bin));
        assert_eq!(identify_format(&bin), FileFormat::Unknown);
    }

    #[test]
    fn utf16le_bom_is_text_and_decodes() {
        let data = [0xFF, 0xFE, b'H', 0x00, b'i', 0x00];
        assert!(is_probably_text(&data));
        assert_eq!(identify_format(&data), FileFormat::Text);
        let (text, enc) = decode_text(&data);
        assert_eq!(text, "Hi");
        assert_eq!(enc, "UTF-16LE (BOM)");
    }

    #[test]
    fn bomless_utf16le_detected() {
        // "Hello, world!!!!" as UTF-16LE, no BOM (>=16 bytes so the heuristic runs).
        let mut data = Vec::new();
        for &b in b"Hello, world!!!!" {
            data.push(b);
            data.push(0);
        }
        assert!(is_probably_text(&data));
        let (text, enc) = decode_text(&data);
        assert_eq!(text, "Hello, world!!!!");
        assert_eq!(enc, "UTF-16LE");
    }

    #[test]
    fn empty_is_not_text() {
        assert!(!is_probably_text(&[]));
    }
}
