//! `StuffIt` archive reading (inspired by `sit-rs` / `StuffIt` format).
//!
//! Parses `StuffIt` (`.sit`) archive headers and entry metadata.
//! `StuffIt` is a classic Mac OS archive format using proprietary compression.
//! This module provides read-only inspection and metadata extraction.

use crate::error::{Error, Result};

/// `StuffIt` archive header.
#[derive(Debug, Clone, serde::Serialize)]
pub struct StuffItHeader {
    /// Magic signature identifier.
    pub signature: String,
    /// Archive version.
    pub version: u8,
    /// Number of root-level entries.
    pub num_entries: u16,
    /// Total archive size as reported in header.
    pub archive_size: u32,
    /// Whether this is a `StuffIt` 5+ (`StuffIt` X) archive.
    pub is_stuffit5: bool,
}

/// A single entry in a `StuffIt` archive.
#[derive(Debug, Clone, serde::Serialize)]
pub struct StuffItEntry {
    /// Entry name.
    pub name: String,
    /// Whether this entry is a directory.
    pub is_directory: bool,
    /// Compression method code.
    pub compression_method: u8,
    /// Compression method name.
    pub compression_method_name: String,
    /// Data fork compressed size.
    pub data_compressed_size: u32,
    /// Data fork uncompressed size.
    pub data_uncompressed_size: u32,
    /// Resource fork compressed size.
    pub rsrc_compressed_size: u32,
    /// Resource fork uncompressed size.
    pub rsrc_uncompressed_size: u32,
    /// Mac OS file type (4-char code).
    pub file_type: String,
    /// Mac OS creator code (4-char code).
    pub creator_code: String,
    /// Whether the entry is encrypted.
    pub is_encrypted: bool,
}

/// `StuffIt` archive analysis result.
#[derive(Debug, Clone, serde::Serialize)]
pub struct StuffItAnalysis {
    pub header: StuffItHeader,
    pub entries: Vec<StuffItEntry>,
}

/// Classic `StuffIt` magic: "SIT!" at offset 0.
const SIT_MAGIC: &[u8; 4] = b"SIT!";

/// `StuffIt` 5.x magic: "`StuffIt`" at offset 0.
const SIT5_MAGIC: &[u8; 7] = b"StuffIt";

/// SIT header size for classic format.
const SIT_HEADER_SIZE: usize = 22;

/// Probe whether data looks like a `StuffIt` archive.
#[must_use]
pub fn probe(data: &[u8]) -> bool {
    if data.len() < 4 {
        return false;
    }
    data.starts_with(SIT_MAGIC) || (data.len() >= 7 && data.starts_with(SIT5_MAGIC))
}

/// Parse a `StuffIt` archive and extract metadata.
///
/// # Errors
///
/// Returns error if the archive header is invalid.
pub fn analyze(data: &[u8]) -> Result<StuffItAnalysis> {
    if !probe(data) {
        return Err(Error::InvalidStuffIt("not a StuffIt archive".into()));
    }

    let is_stuffit5 = data.len() >= 7 && data.starts_with(SIT5_MAGIC);

    if is_stuffit5 {
        analyze_stuffit5(data)
    } else {
        analyze_classic(data)
    }
}

/// Parse classic `StuffIt` (v1-v4) archive.
///
/// # Errors
///
/// Returns an error if the header is too short or entries are malformed.
fn analyze_classic(data: &[u8]) -> Result<StuffItAnalysis> {
    if data.len() < SIT_HEADER_SIZE {
        return Err(Error::InvalidStuffIt("header too short".into()));
    }

    let signature = String::from_utf8_lossy(
        data.get(0..4)
            .ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?,
    )
    .into_owned();

    // Classic SIT header layout:
    // 0-3: "SIT!" magic
    // 4: number of entries (root level)
    // 5-8: total archive length
    // 9: version?
    let num_entries = u16::from(
        data.get(4)
            .copied()
            .ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?,
    );
    let archive_size = read_u32_be(data, 5)?;
    let version = data
        .get(9)
        .copied()
        .ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?;

    let header = StuffItHeader {
        signature,
        version,
        num_entries,
        archive_size,
        is_stuffit5: false,
    };

    let entries = parse_classic_entries(data, SIT_HEADER_SIZE, num_entries)?;

    Ok(StuffItAnalysis { header, entries })
}

/// Parse classic `StuffIt` entries.
///
/// # Errors
///
/// Returns an error if entry data is truncated or malformed.
fn parse_classic_entries(data: &[u8], start: usize, count: u16) -> Result<Vec<StuffItEntry>> {
    let mut entries = Vec::new();
    let mut offset = start;

    for _ in 0..count {
        if offset + 4 >= data.len() {
            break;
        }

        // Classic SIT entry: variable-length name followed by fixed fields.
        // Simplified parsing—real SIT has complex entry layouts.
        let rsrc_compressed = read_u32_be(data, offset)?;
        offset += 4;
        let rsrc_uncompressed = read_u32_be(data, offset)?;
        offset += 4;
        let data_compressed = read_u32_be(data, offset)?;
        offset += 4;
        let data_uncompressed = read_u32_be(data, offset)?;
        offset += 4;

        // Resource compression method + data compression method
        let rsrc_method = if let Some(&m) = data.get(offset) {
            offset += 1;
            m
        } else {
            0
        };
        let data_method = if let Some(&m) = data.get(offset) {
            offset += 1;
            m
        } else {
            0
        };

        // Encrypted flag
        let is_encrypted = if let Some(&e) = data.get(offset) {
            offset += 1;
            e & 0x10 != 0
        } else {
            false
        };

        // File type + creator (8 bytes)
        let file_type = if let Some(ft_slice) = data.get(offset..offset + 4) {
            let ft = String::from_utf8_lossy(ft_slice).into_owned();
            offset += 4;
            ft
        } else {
            "????".into()
        };

        let creator_code = if let Some(cc_slice) = data.get(offset..offset + 4) {
            let cc = String::from_utf8_lossy(cc_slice).into_owned();
            offset += 4;
            cc
        } else {
            "????".into()
        };

        // Name length + name
        let name_len = if let Some(&l) = data.get(offset) {
            offset += 1;
            l as usize
        } else {
            0
        };

        let name = if let Some(n_slice) = data.get(offset..offset + name_len) {
            let n = String::from_utf8_lossy(n_slice).into_owned();
            offset += name_len;
            n
        } else {
            format!("entry_{}", entries.len())
        };

        let method = data_method.max(rsrc_method);
        entries.push(StuffItEntry {
            name,
            is_directory: false,
            compression_method: method,
            compression_method_name: sit_method_name(method),
            data_compressed_size: data_compressed,
            data_uncompressed_size: data_uncompressed,
            rsrc_compressed_size: rsrc_compressed,
            rsrc_uncompressed_size: rsrc_uncompressed,
            file_type,
            creator_code,
            is_encrypted,
        });
    }

    Ok(entries)
}

/// Parse `StuffIt` 5.x (`StuffIt` X) archive.
///
/// # Errors
///
/// Returns an error if the header is too short or data is malformed.
fn analyze_stuffit5(data: &[u8]) -> Result<StuffItAnalysis> {
    if data.len() < 100 {
        return Err(Error::InvalidStuffIt("StuffIt 5 header too short".into()));
    }

    let signature = String::from_utf8_lossy(
        data.get(0..7)
            .ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?,
    )
    .into_owned();

    // StuffIt 5 has a more complex header; extract what we safely can.
    let version = data
        .get(7)
        .copied()
        .ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?;
    let header_size = read_u32_be(data, 14)? as usize;

    // Total archive size
    let archive_size = if data.len() >= 86 {
        read_u32_be(data, 82)?
    } else {
        data.len() as u32
    };

    // Number of top-level items
    let num_entries = if data.len() >= 90 {
        read_u16_be(data, 88)?
    } else {
        0
    };

    let header = StuffItHeader {
        signature,
        version,
        num_entries,
        archive_size,
        is_stuffit5: true,
    };

    // StuffIt 5 entry parsing is complex and proprietary.
    // We report the header info and note that entries require
    // the proprietary decompression algorithms.
    let entries = if header_size < data.len() {
        parse_stuffit5_entries(data, header_size, num_entries)?
    } else {
        Vec::new()
    };

    Ok(StuffItAnalysis { header, entries })
}

/// Attempt to parse `StuffIt` 5 entries (best-effort).
///
/// # Errors
///
/// Returns an error if entry data is truncated or malformed.
// rust-doctor: acknowledged — binary format parser with heuristic field extraction
fn parse_stuffit5_entries(data: &[u8], start: usize, _count: u16) -> Result<Vec<StuffItEntry>> {
    let mut entries = Vec::new();
    let mut offset = start;

    // StuffIt 5 entries have a 50-byte fixed header followed by a variable name.
    while offset + 50 < data.len() && entries.len() < 1000 {
        // Heuristic: check for reasonable name length
        let name_len = data.get(offset + 49).copied().map_or(0, usize::from);

        if name_len == 0 || name_len > 255 || offset + 50 + name_len > data.len() {
            break;
        }

        let name_slice = data
            .get(offset + 50..offset + 50 + name_len)
            .ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?;
        let name = String::from_utf8_lossy(name_slice).into_owned();

        // Check for non-printable characters as a validity heuristic
        if name.chars().any(|c| c.is_control() && c != '\t') {
            break;
        }

        let is_directory = data
            .get(offset)
            .copied()
            .ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?
            & 0x40
            != 0;
        let compression_method = data
            .get(offset + 3)
            .copied()
            .ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?;
        let data_compressed = read_u32_be(data, offset + 8)?;
        let data_uncompressed = read_u32_be(data, offset + 12)?;
        let rsrc_compressed = read_u32_be(data, offset + 16)?;
        let rsrc_uncompressed = read_u32_be(data, offset + 20)?;
        let is_encrypted = data
            .get(offset + 4)
            .copied()
            .ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?
            & 0x10
            != 0;

        let file_type = if let Some(ft_slice) = data.get(offset + 24..offset + 28) {
            String::from_utf8_lossy(ft_slice).into_owned()
        } else {
            "????".into()
        };

        let creator_code = if let Some(cc_slice) = data.get(offset + 28..offset + 32) {
            String::from_utf8_lossy(cc_slice).into_owned()
        } else {
            "????".into()
        };

        entries.push(StuffItEntry {
            name,
            is_directory,
            compression_method,
            compression_method_name: sit_method_name(compression_method),
            data_compressed_size: data_compressed,
            data_uncompressed_size: data_uncompressed,
            rsrc_compressed_size: rsrc_compressed,
            rsrc_uncompressed_size: rsrc_uncompressed,
            file_type,
            creator_code,
            is_encrypted,
        });

        offset += 50 + name_len;
    }

    Ok(entries)
}

/// Map `StuffIt` compression method code to name.
fn sit_method_name(method: u8) -> String {
    match method {
        0 => "none (stored)".into(),
        1 => "RLE".into(),
        2 => "LZC (Lempel-Ziv)".into(),
        3 => "Huffman".into(),
        5 => "LZAH".into(),
        6 => "fixedHuffman".into(),
        8 => "MW (Miller-Wegman)".into(),
        13 => "LZ+Huffman (method 13)".into(),
        14 => "Installer".into(),
        15 => "Arsenic".into(),
        _ => format!("unknown ({method})"),
    }
}

/// # Errors
///
/// Returns an error if the slice at `offset..offset+4` is out of bounds.
fn read_u32_be(data: &[u8], offset: usize) -> Result<u32> {
    let bytes: [u8; 4] = data
        .get(offset..offset + 4)
        .ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?
        .try_into()
        .map_err(|_| Error::InvalidStuffIt("data truncated".into()))?;
    Ok(u32::from_be_bytes(bytes))
}

/// # Errors
///
/// Returns an error if the slice at `offset..offset+2` is out of bounds.
fn read_u16_be(data: &[u8], offset: usize) -> Result<u16> {
    let bytes: [u8; 2] = data
        .get(offset..offset + 2)
        .ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?
        .try_into()
        .map_err(|_| Error::InvalidStuffIt("data truncated".into()))?;
    Ok(u16::from_be_bytes(bytes))
}

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

    #[test]
    fn test_probe_classic() {
        let data = b"SIT!\x05\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
        assert!(probe(data));
    }

    #[test]
    fn test_probe_stuffit5() {
        let mut data = vec![0u8; 100];
        data[..7].copy_from_slice(b"StuffIt");
        assert!(probe(&data));
    }

    #[test]
    fn test_probe_not_stuffit() {
        let data = b"PK\x03\x04extra";
        assert!(!probe(data));
    }

    #[test]
    fn test_analyze_classic_header() {
        let mut data = vec![0u8; 100];
        data[0..4].copy_from_slice(b"SIT!");
        data[4] = 2; // 2 entries
        data[5..9].copy_from_slice(&100u32.to_be_bytes()); // archive size
        data[9] = 1; // version

        let result = analyze(&data);
        assert!(result.is_ok());
        let analysis = result.expect("analysis should succeed");
        assert_eq!(analysis.header.signature, "SIT!");
        assert_eq!(analysis.header.num_entries, 2);
        assert!(!analysis.header.is_stuffit5);
    }

    #[test]
    fn test_analyze_not_stuffit() {
        let data = b"NOT_A_SIT_ARCHIVE";
        assert!(analyze(data).is_err());
    }

    #[test]
    fn test_method_names() {
        assert_eq!(sit_method_name(0), "none (stored)");
        assert_eq!(sit_method_name(1), "RLE");
        assert_eq!(sit_method_name(15), "Arsenic");
        assert!(sit_method_name(99).contains("unknown"));
    }
}
