//! IPFB archive reader (`*.pak` table-of-contents + `*.p00`/`*.pNN` data segments).
//!
//! Project Sylpheed packs almost all of its data into a custom archive format.
//! Each archive is a pair (or set): a `*.pak` index and one or more `*.pNN`
//! data segments (`.p00`, `.p01`, … concatenated in order).
//!
//! ## `.pak` layout (all fields big-endian)
//!
//! ```text
//! Offset  Size  Field
//! 0x00    4     Magic: "IPFB"
//! 0x04    4     entry_count
//! 0x08    4     block_size   (always 0x800 in retail — the data-offset alignment unit)
//! 0x0C    4     flags        (always 0x1000_0000 in retail; purpose unconfirmed)
//! 0x10    12*n  TOC: n × { u32 name_hash, u32 offset, u32 comp_size }
//! ```
//!
//! - `name_hash` — the TOC is **sorted ascending by `name_hash`** (binary-searchable).
//!   The hash is a custom Barrett-reduced polynomial over the **lowercased** path,
//!   recovered from the retail title — see [`crate::hash`]. Look up entries by their
//!   original path with [`PakArchive::find_by_name`] / [`PakArchive::read_by_name`]
//!   (retail paths use backslash separators, e.g. `eng\weapon.tbl`).
//! - `offset` — byte offset into the **concatenated** `.pNN` segments, `block_size`-aligned.
//! - `comp_size` — stored (compressed) size of the entry.
//!
//! ## Entry payload — the `"Z1"` container
//!
//! Each stored entry is:
//!
//! ```text
//! Offset  Size  Field
//! 0x00    2     Magic: "Z1"
//! 0x02    4     uncompressed_size (big-endian)
//! 0x06    4     aux (checksum/adler-like; not validated here)
//! 0x0A    ..    zlib stream (RFC 1950; 0x78 0xDA / 0x9C …)
//! ```
//!
//! [`PakArchive::read`] decompresses this transparently. A handful of entries in
//! some archives are stored raw (no `"Z1"` header); those are returned verbatim.

use std::io::Read;
use std::path::{Path, PathBuf};

use flate2::read::ZlibDecoder;
use thiserror::Error;

/// Magic at the start of every `.pak` index file.
pub const IPFB_MAGIC: [u8; 4] = *b"IPFB";
/// Magic at the start of every compressed entry payload.
pub const Z1_MAGIC: [u8; 2] = *b"Z1";

#[derive(Debug, Error)]
pub enum PakError {
    #[error("bad IPFB magic: expected {:?}, got {got:?}", IPFB_MAGIC)]
    BadMagic { got: [u8; 4] },

    #[error("truncated .pak: need {needed} bytes, have {have}")]
    Truncated { needed: usize, have: usize },

    #[error("entry offset 0x{offset:x}+{size} runs past segment data ({data_len} bytes)")]
    OffsetOutOfRange {
        offset: u32,
        size: u32,
        data_len: usize,
    },

    #[error("no data segments (.p00, .p01, …) found next to {0}")]
    NoSegments(PathBuf),

    #[error("zlib decompression failed for entry at 0x{offset:x}: {source}")]
    Inflate {
        offset: u32,
        #[source]
        source: std::io::Error,
    },

    #[error("io error reading {path}: {source}")]
    Io {
        path: String,
        #[source]
        source: std::io::Error,
    },
}

/// One table-of-contents record.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PakEntry {
    /// Custom hash of the entry's original path (unknown preimage — see module docs).
    pub name_hash: u32,
    /// Byte offset into the concatenated segment data, `block_size`-aligned.
    pub offset: u32,
    /// Stored (compressed) byte length.
    pub comp_size: u32,
}

/// A parsed IPFB archive: its TOC plus the concatenated segment bytes.
pub struct PakArchive {
    /// `block_size` header field (data-offset alignment; 0x800 in retail).
    pub block_size: u32,
    /// `flags` header field (0x1000_0000 in retail).
    pub flags: u32,
    entries: Vec<PakEntry>,
    data: Vec<u8>,
}

impl PakArchive {
    /// Open an archive from a `.pak` path, auto-loading the sibling `.p00`/`.pNN`
    /// segments (concatenated in numeric order).
    pub fn open(pak_path: impl AsRef<Path>) -> Result<Self, PakError> {
        let pak_path = pak_path.as_ref();
        let index = std::fs::read(pak_path).map_err(|e| PakError::Io {
            path: pak_path.display().to_string(),
            source: e,
        })?;

        // Segments share the stem, with extension .p00, .p01, … . Load in order
        // until the first gap. `foo.pak` → `foo.p00`, `foo.p01`, …
        let base = pak_path.with_extension("");
        let mut data = Vec::new();
        for i in 0..100u32 {
            let seg = base.with_extension(format!("p{i:02}"));
            if !seg.exists() {
                break;
            }
            let mut bytes = std::fs::read(&seg).map_err(|e| PakError::Io {
                path: seg.display().to_string(),
                source: e,
            })?;
            data.append(&mut bytes);
        }
        if data.is_empty() {
            return Err(PakError::NoSegments(pak_path.to_path_buf()));
        }

        Self::from_parts(&index, data)
    }

    /// Parse from already-loaded bytes (the `.pak` index and the concatenated
    /// segment data). Useful for tests and WASM, where files arrive over HTTP.
    pub fn from_parts(index: &[u8], data: Vec<u8>) -> Result<Self, PakError> {
        if index.len() < 16 {
            return Err(PakError::Truncated {
                needed: 16,
                have: index.len(),
            });
        }
        let magic: [u8; 4] = index[0..4].try_into().unwrap();
        if magic != IPFB_MAGIC {
            return Err(PakError::BadMagic { got: magic });
        }
        let entry_count = be32(index, 4) as usize;
        let block_size = be32(index, 8);
        let flags = be32(index, 12);

        let toc_len = 16 + entry_count * 12;
        if index.len() < toc_len {
            return Err(PakError::Truncated {
                needed: toc_len,
                have: index.len(),
            });
        }

        let mut entries = Vec::with_capacity(entry_count);
        for i in 0..entry_count {
            let o = 16 + i * 12;
            entries.push(PakEntry {
                name_hash: be32(index, o),
                offset: be32(index, o + 4),
                comp_size: be32(index, o + 8),
            });
        }

        Ok(Self {
            block_size,
            flags,
            entries,
            data,
        })
    }

    /// Parse **only** the TOC from a `.pak` index, without any segment data.
    ///
    /// For huge archives (`sound.pak` is ~1 GB across `.p00..p04`) this lets a
    /// caller locate one entry's `(offset, size)` and read just that byte range
    /// from the segments, instead of loading the whole thing into memory. The
    /// returned entries are sorted by `name_hash` (binary-searchable).
    pub fn parse_toc(index: &[u8]) -> Result<Vec<PakEntry>, PakError> {
        // Reuse from_parts' validation with an empty data blob; the entries are
        // independent of the data.
        Ok(Self::from_parts(index, Vec::new())?.entries)
    }

    /// All TOC entries, in stored order (ascending `name_hash`).
    pub fn entries(&self) -> &[PakEntry] {
        &self.entries
    }

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

    /// Whether the archive has no entries.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Look up an entry by its `name_hash` (binary search; TOC is sorted).
    pub fn find(&self, name_hash: u32) -> Option<&PakEntry> {
        self.entries
            .binary_search_by_key(&name_hash, |e| e.name_hash)
            .ok()
            .map(|i| &self.entries[i])
    }

    /// The raw stored bytes for an entry (still `"Z1"`-wrapped / compressed).
    pub fn stored_bytes(&self, entry: &PakEntry) -> Result<&[u8], PakError> {
        let start = entry.offset as usize;
        let end = start + entry.comp_size as usize;
        self.data
            .get(start..end)
            .ok_or(PakError::OffsetOutOfRange {
                offset: entry.offset,
                size: entry.comp_size,
                data_len: self.data.len(),
            })
    }

    /// Decompress an entry to its raw payload bytes. Handles the `"Z1"` container
    /// (zlib); entries stored without a `"Z1"` header are returned verbatim.
    pub fn read(&self, entry: &PakEntry) -> Result<Vec<u8>, PakError> {
        let stored = self.stored_bytes(entry)?;
        decompress_entry(stored, entry.offset)
    }

    /// Convenience: [`find`](Self::find) + [`read`](Self::read) by `name_hash`.
    pub fn read_by_hash(&self, name_hash: u32) -> Option<Result<Vec<u8>, PakError>> {
        self.find(name_hash).map(|e| self.read(e))
    }

    /// Look up an entry by its original path, hashing it the way the game does
    /// ([`crate::hash::name_hash`]). Names are case-insensitive; path separators
    /// must be **backslash** to match the on-disc form, e.g. `eng\weapon.tbl`.
    pub fn find_by_name(&self, name: &str) -> Option<&PakEntry> {
        self.find(crate::hash::name_hash(name))
    }

    /// Convenience: [`find_by_name`](Self::find_by_name) + [`read`](Self::read).
    pub fn read_by_name(&self, name: &str) -> Option<Result<Vec<u8>, PakError>> {
        self.find_by_name(name).map(|e| self.read(e))
    }
}

/// Decompress a single stored entry payload (the `"Z1"` container, or raw).
pub fn decompress_entry(stored: &[u8], offset_for_err: u32) -> Result<Vec<u8>, PakError> {
    if stored.len() >= 10 && stored[0..2] == Z1_MAGIC {
        let usize_hint = be32(stored, 2) as usize;
        let mut out = Vec::with_capacity(usize_hint.min(64 * 1024 * 1024));
        ZlibDecoder::new(&stored[10..])
            .read_to_end(&mut out)
            .map_err(|e| PakError::Inflate {
                offset: offset_for_err,
                source: e,
            })?;
        Ok(out)
    } else {
        // Rare: entry stored uncompressed.
        Ok(stored.to_vec())
    }
}

#[inline]
fn be32(b: &[u8], o: usize) -> u32 {
    u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}

/// Best-effort human label for a decompressed entry's inner format, from its
/// first bytes: a known signature (`IDXD`, fonts, `png`, …), a printable 4-char
/// tag (RATC, IXUD, LSTA, …), or a hex fallback for still-unidentified magics.
/// Shared by the CLI `pak list` and the GUI pack browser.
pub fn inner_format_label(payload: &[u8]) -> String {
    if payload.len() < 4 {
        return "empty".into();
    }
    let m = &payload[0..4];

    // Known binary/text signatures → friendly short tags (so fonts/images don't
    // show as bare hex like `00010000` / `89504E47`).
    let known = match m {
        b"IDXD" => Some("IDXD"),
        b"\x89PNG" => Some("png"),
        b"\x00\x01\x00\x00" | b"true" | b"typ1" => Some("ttf"), // sfnt TrueType
        b"OTTO" => Some("otf"),                                 // OpenType (CFF)
        b"ttcf" => Some("ttc"),                                 // TrueType Collection
        b"RIFF" => Some("riff"),
        b"DDS " => Some("dds"),
        _ if payload.starts_with(b"<?xml") => Some("xml"),
        _ => None,
    };
    if let Some(tag) = known {
        return tag.into();
    }

    if m.iter().all(|&b| b.is_ascii_graphic()) {
        // Many inner formats are 4-char tags (RATC, T8aD, IXUD, LSTA, …).
        String::from_utf8_lossy(m).into_owned()
    } else {
        format!("{:02X}{:02X}{:02X}{:02X}", m[0], m[1], m[2], m[3])
    }
}

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

    #[test]
    fn parses_synthetic_toc() {
        // header: IPFB, count=1, block=0x800, flags=0x10000000
        let mut idx = Vec::new();
        idx.extend_from_slice(&IPFB_MAGIC);
        idx.extend_from_slice(&1u32.to_be_bytes());
        idx.extend_from_slice(&0x800u32.to_be_bytes());
        idx.extend_from_slice(&0x1000_0000u32.to_be_bytes());
        // one entry: hash=0xAABBCCDD, offset=0, size=len(payload)
        // payload: "Z1" + usize + aux + zlib("hello")
        let mut zl = Vec::new();
        {
            use flate2::{write::ZlibEncoder, Compression};
            use std::io::Write;
            let mut enc = ZlibEncoder::new(&mut zl, Compression::default());
            enc.write_all(b"hello").unwrap();
            enc.finish().unwrap();
        }
        let mut payload = Vec::new();
        payload.extend_from_slice(&Z1_MAGIC);
        payload.extend_from_slice(&5u32.to_be_bytes()); // uncompressed size
        payload.extend_from_slice(&0u32.to_be_bytes()); // aux
        payload.extend_from_slice(&zl);

        idx.extend_from_slice(&0xAABB_CCDDu32.to_be_bytes());
        idx.extend_from_slice(&0u32.to_be_bytes());
        idx.extend_from_slice(&(payload.len() as u32).to_be_bytes());

        let arc = PakArchive::from_parts(&idx, payload).unwrap();
        assert_eq!(arc.len(), 1);
        assert_eq!(arc.block_size, 0x800);
        let e = arc.find(0xAABB_CCDD).unwrap();
        assert_eq!(arc.read(e).unwrap(), b"hello");
        assert!(arc.find(0x1234_5678).is_none());
    }
}
