//! Compact Pro archive reading (inspired by `cyco/cpt-rs`).
//!
//! Parses `.cpt` (Compact Pro) archive headers and entry metadata.
//! Compact Pro is a classic Mac OS archive format using RLE and LZH compression.
//!
//! # File Format
//!
//! The archive starts with an 8-byte header:
//! - Byte 0: File identifier (always `0x01`)
//! - Byte 1: Volume number (`0x01` for single-volume)
//! - Bytes 2–3: Cross-volume magic number
//! - Bytes 4–7: Offset to file/directory headers from start of file
//!
//! The header area (at the specified offset) contains:
//! - Bytes 0–3: CRC-32 of the header
//! - Bytes 4–5: Total number of files and directories
//! - Byte 6: Comment length
//! - Bytes 7–N: Comment text

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

/// Compact Pro archive header.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptHeader {
    /// Volume number.
    pub volume_number: u8,
    /// Cross-volume magic.
    pub cross_volume_magic: u16,
    /// Offset to the entry headers from the start of the file.
    pub header_offset: u32,
    /// CRC-32 of the header area.
    pub header_crc32: u32,
    /// Total number of files and directories.
    pub total_entries: u16,
    /// Archive comment (if any).
    pub comment: Option<String>,
}

/// A file entry in a Compact Pro archive.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptFileEntry {
    /// File name.
    pub name: String,
    /// Volume number.
    pub volume_number: u8,
    /// Offset to file data from start of archive.
    pub data_offset: u32,
    /// Mac OS file type (4-char code).
    pub file_type: String,
    /// Mac OS creator code (4-char code).
    pub creator_code: String,
    /// Resource fork uncompressed size.
    pub rsrc_uncompressed_size: u32,
    /// Data fork uncompressed size.
    pub data_uncompressed_size: u32,
    /// Resource fork compressed size.
    pub rsrc_compressed_size: u32,
    /// Data fork compressed size.
    pub data_compressed_size: u32,
    /// Whether resource fork uses LZH compression.
    pub rsrc_lzh: bool,
    /// Whether data fork uses LZH compression.
    pub data_lzh: bool,
    /// Whether the file is encrypted.
    pub is_encrypted: bool,
    /// CRC-32 of uncompressed data (data + resource forks concatenated).
    pub crc32: u32,
}

/// A directory entry in a Compact Pro archive.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptDirEntry {
    /// Directory name.
    pub name: String,
    /// Total number of files and directories inside (including subdirs).
    pub total_children: u16,
}

/// A parsed entry (file or directory).
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "type")]
pub enum CptEntry {
    File(CptFileEntry),
    Directory(CptDirEntry),
}

impl CptEntry {
    /// Get the entry name.
    #[must_use]
    #[allow(dead_code)]
    pub fn name(&self) -> &str {
        match self {
            Self::File(f) => &f.name,
            Self::Directory(d) => &d.name,
        }
    }

    /// Whether this entry is a directory.
    #[must_use]
    #[allow(dead_code)]
    pub const fn is_directory(&self) -> bool {
        matches!(self, Self::Directory(_))
    }
}

/// Complete Compact Pro archive analysis.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CptAnalysis {
    pub header: CptHeader,
    pub entries: Vec<CptEntry>,
}

/// Compact Pro file identifier byte.
const CPT_IDENTIFIER: u8 = 0x01;

/// Probe whether data looks like a Compact Pro archive.
#[must_use]
pub fn probe(data: &[u8]) -> bool {
    // First byte must be 0x01, second byte volume number (usually 0x01)
    data.len() >= 8
        && data.first().copied() == Some(CPT_IDENTIFIER)
        && data.get(1).copied() == Some(0x01)
}

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

    let header = parse_header(data)?;
    let entries = parse_entries(data, &header)?;

    Ok(CptAnalysis { header, entries })
}

/// Verify the CRC-32 of the header area.
///
/// # Errors
///
/// Returns error on CRC mismatch.
pub fn verify(data: &[u8]) -> Result<bool> {
    let header = parse_header(data)?;
    let offset = header.header_offset as usize;

    if offset + 6 >= data.len() {
        return Err(Error::InvalidCompactPro(
            "header offset out of bounds".into(),
        ));
    }

    // CRC-32 covers bytes after the CRC field itself
    let crc_start = offset + 4;
    let crc_data = data
        .get(crc_start..)
        .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
    let computed = crc32fast::hash(crc_data);

    Ok(computed == header.header_crc32)
}

/// Parse the Compact Pro archive header.
///
/// # Errors
///
/// Returns an error if the data is too short or the header offset is invalid.
fn parse_header(data: &[u8]) -> Result<CptHeader> {
    if data.len() < 8 {
        return Err(Error::InvalidCompactPro("data too short for header".into()));
    }

    let volume_number = data
        .get(1)
        .copied()
        .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
    let cvm_bytes: [u8; 2] = data
        .get(2..4)
        .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
        .try_into()
        .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
    let cross_volume_magic = u16::from_be_bytes(cvm_bytes);
    let ho_bytes: [u8; 4] = data
        .get(4..8)
        .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
        .try_into()
        .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
    let header_offset = u32::from_be_bytes(ho_bytes);

    let offset = header_offset as usize;
    if offset + 7 > data.len() {
        return Err(Error::InvalidCompactPro(
            "header offset points beyond file end".into(),
        ));
    }

    let crc_bytes: [u8; 4] = data
        .get(offset..offset + 4)
        .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
        .try_into()
        .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
    let header_crc32 = u32::from_be_bytes(crc_bytes);
    let te_bytes: [u8; 2] = data
        .get(offset + 4..offset + 6)
        .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
        .try_into()
        .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
    let total_entries = u16::from_be_bytes(te_bytes);
    let comment_len =
        data.get(offset + 6)
            .copied()
            .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))? as usize;

    let comment = if comment_len > 0 {
        let comment_bytes = data
            .get(offset + 7..offset + 7 + comment_len)
            .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
        Some(String::from_utf8_lossy(comment_bytes).into_owned())
    } else {
        None
    };

    Ok(CptHeader {
        volume_number,
        cross_volume_magic,
        header_offset,
        header_crc32,
        total_entries,
        comment,
    })
}

/// Parse Compact Pro archive entries from the data following the header.
///
/// # Errors
///
/// Returns an error if entry data is truncated or malformed.
// rust-doctor: acknowledged — binary format parser with sequential field extraction
fn parse_entries(data: &[u8], header: &CptHeader) -> Result<Vec<CptEntry>> {
    let offset = header.header_offset as usize;
    let comment_len = data.get(offset + 6).copied().unwrap_or(0) as usize;
    let mut pos = offset + 7 + comment_len;
    let mut entries = Vec::new();

    for _ in 0..header.total_entries {
        if pos >= data.len() {
            break;
        }

        let name_len_and_type = data
            .get(pos)
            .copied()
            .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
        let is_directory = (name_len_and_type & 0x80) != 0;
        let name_len = (name_len_and_type & 0x7F) as usize;

        if name_len == 0 || pos + 1 + name_len > data.len() {
            break;
        }

        let name_bytes = data
            .get(pos + 1..pos + 1 + name_len)
            .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
        let name = String::from_utf8_lossy(name_bytes).into_owned();
        pos += 1 + name_len;

        if is_directory {
            // Directory: 2 bytes for total children count
            if pos + 2 > data.len() {
                break;
            }
            let tc_bytes: [u8; 2] = data
                .get(pos..pos + 2)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let total_children = u16::from_be_bytes(tc_bytes);
            pos += 2;
            entries.push(CptEntry::Directory(CptDirEntry {
                name,
                total_children,
            }));
        } else {
            // File entry: variable fields after name
            if pos + 46 > data.len() {
                break;
            }

            let volume_number = data
                .get(pos)
                .copied()
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
            pos += 1;
            let data_off_raw: [u8; 4] = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let data_offset = u32::from_be_bytes(data_off_raw);
            pos += 4;
            let ftype_raw = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
            let file_type = String::from_utf8_lossy(ftype_raw).into_owned();
            pos += 4;
            let creator_raw = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?;
            let creator_code = String::from_utf8_lossy(creator_raw).into_owned();
            pos += 4;
            // Skip creation/modification dates and finder flags (14 bytes)
            pos += 14;
            let checksum_raw: [u8; 4] = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let crc32 = u32::from_be_bytes(checksum_raw);
            pos += 4;
            let flags_raw: [u8; 2] = data
                .get(pos..pos + 2)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let flags = u16::from_be_bytes(flags_raw);
            pos += 2;
            let is_encrypted = (flags & 0x01) != 0;
            let rsrc_lzh = (flags & 0x02) != 0;
            let data_lzh = (flags & 0x04) != 0;

            let rsrc_uncomp_raw: [u8; 4] = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let rsrc_uncompressed_size = u32::from_be_bytes(rsrc_uncomp_raw);
            pos += 4;
            let data_uncomp_raw: [u8; 4] = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let data_uncompressed_size = u32::from_be_bytes(data_uncomp_raw);
            pos += 4;
            let rsrc_comp_raw: [u8; 4] = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let rsrc_compressed_size = u32::from_be_bytes(rsrc_comp_raw);
            pos += 4;
            let data_comp_raw: [u8; 4] = data
                .get(pos..pos + 4)
                .ok_or_else(|| Error::InvalidCompactPro("data truncated".into()))?
                .try_into()
                .map_err(|_| Error::InvalidCompactPro("data truncated".into()))?;
            let data_compressed_size = u32::from_be_bytes(data_comp_raw);
            pos += 4;

            entries.push(CptEntry::File(CptFileEntry {
                name,
                volume_number,
                data_offset,
                file_type,
                creator_code,
                rsrc_uncompressed_size,
                data_uncompressed_size,
                rsrc_compressed_size,
                data_compressed_size,
                rsrc_lzh,
                data_lzh,
                is_encrypted,
                crc32,
            }));
        }
    }

    Ok(entries)
}

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

    fn make_minimal_cpt() -> Vec<u8> {
        let mut data = vec![0u8; 256];
        data[0] = 0x01; // identifier
        data[1] = 0x01; // volume
        data[2] = 0x00; // cross-volume magic high
        data[3] = 0x00; // cross-volume magic low

        // Header offset = 100
        let offset: u32 = 100;
        data[4..8].copy_from_slice(&offset.to_be_bytes());

        // At offset 100: header area
        let o = 100;
        data[o..o + 4].copy_from_slice(&0u32.to_be_bytes()); // CRC
        data[o + 4..o + 6].copy_from_slice(&0u16.to_be_bytes()); // 0 entries
        data[o + 6] = 0; // no comment

        data
    }

    #[test]
    fn test_probe_valid() {
        let data = make_minimal_cpt();
        assert!(probe(&data));
    }

    #[test]
    fn test_probe_invalid() {
        let data = [0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08];
        assert!(!probe(&data));
    }

    #[test]
    fn test_analyze_minimal() {
        let data = make_minimal_cpt();
        let result = analyze(&data);
        assert!(result.is_ok());
        let analysis = result.expect("should parse");
        assert_eq!(analysis.header.total_entries, 0);
        assert!(analysis.entries.is_empty());
    }

    #[test]
    fn test_analyze_not_cpt() {
        let data = b"PK\x03\x04notcpt";
        assert!(analyze(data).is_err());
    }

    #[test]
    fn test_cpt_entry_name() {
        let dir = CptEntry::Directory(CptDirEntry {
            name: "TestDir".into(),
            total_children: 3,
        });
        assert_eq!(dir.name(), "TestDir");
        assert!(dir.is_directory());
    }
}
