//! Gzip file inspection and analysis (inspired by `jt55401/gzinspector`).
//!
//! Parses and displays detailed gzip header information including
//! compression method, flags, timestamps, OS, extra fields, and checksums.

use std::io::Read;

use sha2::{Digest, Sha256};

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

/// Gzip member header information (RFC 1952).
#[derive(Debug, Clone, serde::Serialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct GzipHeader {
    /// Compression method (8 = deflate).
    pub compression_method: u8,
    /// Compression method name.
    pub compression_method_name: String,
    /// Header flags byte.
    pub flags: u8,
    /// FTEXT: file is probably ASCII text.
    pub is_text: bool,
    /// FHCRC: header CRC16 present.
    pub has_header_crc: bool,
    /// FEXTRA: extra field present.
    pub has_extra: bool,
    /// FNAME: original file name present.
    pub has_name: bool,
    /// FCOMMENT: comment present.
    pub has_comment: bool,
    /// Modification timestamp (Unix epoch).
    pub mtime: u32,
    /// Formatted modification time.
    pub mtime_formatted: String,
    /// Extra flags (compression level hint).
    pub extra_flags: u8,
    /// Extra flags description.
    pub extra_flags_description: String,
    /// Operating system code.
    pub os_code: u8,
    /// Operating system name.
    pub os_name: String,
    /// Original filename (if present).
    pub original_name: Option<String>,
    /// Comment (if present).
    pub comment: Option<String>,
    /// Extra field data (if present).
    pub extra_data: Option<Vec<u8>>,
    /// Header CRC16 (if present).
    pub header_crc16: Option<u16>,
    /// Total header size in bytes.
    pub header_size: usize,
}

/// Gzip trailer information.
#[derive(Debug, Clone, serde::Serialize)]
pub struct GzipTrailer {
    /// CRC-32 of uncompressed data.
    pub crc32: u32,
    /// Original file size mod 2^32.
    pub original_size: u32,
}

/// Complete gzip file analysis result.
#[derive(Debug, Clone, serde::Serialize)]
pub struct GzipAnalysis {
    /// Header information.
    pub header: GzipHeader,
    /// Trailer information (if readable).
    pub trailer: Option<GzipTrailer>,
    /// Compressed data size (excluding header/trailer).
    pub compressed_size: u64,
    /// Total file size.
    pub file_size: u64,
    /// SHA-256 of the compressed file.
    pub sha256: String,
    /// Number of gzip members detected.
    pub member_count: u32,
    /// Whether the file is a valid multi-member gzip.
    pub is_multi_member: bool,
}

/// Gzip magic bytes.
const GZIP_MAGIC: [u8; 2] = [0x1F, 0x8B];

/// Flag bit masks.
const FTEXT: u8 = 0x01;
const FHCRC: u8 = 0x02;
const FEXTRA: u8 = 0x04;
const FNAME: u8 = 0x08;
const FCOMMENT: u8 = 0x10;

/// Inspect a gzip file and return detailed analysis.
///
/// # Errors
///
/// Returns error if the data is not a valid gzip file.
pub fn inspect(data: &[u8]) -> Result<GzipAnalysis> {
    let header = parse_header(data)?;
    let trailer = parse_trailer(data);
    let file_size = data.len() as u64;
    let compressed_size = file_size.saturating_sub(header.header_size as u64 + 8);

    // Count members
    let (member_count, is_multi_member) = count_members(data);

    // SHA-256
    let mut hasher = Sha256::new();
    hasher.update(data);
    let sha256 = hex::encode(hasher.finalize());

    Ok(GzipAnalysis {
        header,
        trailer,
        compressed_size,
        file_size,
        sha256,
        member_count,
        is_multi_member,
    })
}

/// Parse gzip header from raw bytes.
///
/// # Errors
///
/// Returns error if the header is invalid or truncated.
// rust-doctor: acknowledged — binary format parser with conditional flag-dependent fields
pub fn parse_header(data: &[u8]) -> Result<GzipHeader> {
    if data.len() < 10 {
        return Err(Error::InvalidGzipHeader(
            "data too short (< 10 bytes)".into(),
        ));
    }

    let b0 = data
        .first()
        .copied()
        .ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?;
    let b1 = data
        .get(1)
        .copied()
        .ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?;
    if b0 != GZIP_MAGIC[0] || b1 != GZIP_MAGIC[1] {
        return Err(Error::InvalidGzipHeader(format!(
            "invalid magic: 0x{b0:02X}{b1:02X}, expected 0x1F8B",
        )));
    }

    let compression_method = data
        .get(2)
        .copied()
        .ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?;
    let compression_method_name = if compression_method == 8 {
        "deflate".to_string()
    } else {
        format!("unknown ({compression_method})")
    };

    let flags = data
        .get(3)
        .copied()
        .ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?;
    let is_text = (flags & FTEXT) != 0;
    let has_header_crc = (flags & FHCRC) != 0;
    let has_extra = (flags & FEXTRA) != 0;
    let has_name = (flags & FNAME) != 0;
    let has_comment = (flags & FCOMMENT) != 0;

    let mtime_bytes: [u8; 4] = data
        .get(4..8)
        .ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?
        .try_into()
        .map_err(|_| Error::InvalidGzipHeader("data truncated".into()))?;
    let mtime = u32::from_le_bytes(mtime_bytes);
    let mtime_formatted = format_mtime(mtime);

    let extra_flags = data
        .get(8)
        .copied()
        .ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?;
    let extra_flags_description = match extra_flags {
        2 => "maximum compression (slowest)".into(),
        4 => "fastest compression".into(),
        _ => format!("unknown ({extra_flags})"),
    };

    let os_code = data
        .get(9)
        .copied()
        .ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?;
    let os_name = os_code_name(os_code).to_string();

    let mut offset = 10;

    // Parse extra field
    let extra_data = if has_extra {
        let xlen_bytes: [u8; 2] = data
            .get(offset..offset + 2)
            .ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?
            .try_into()
            .map_err(|_| Error::InvalidGzipHeader("data truncated".into()))?;
        let xlen = u16::from_le_bytes(xlen_bytes) as usize;
        offset += 2;
        let extra = data
            .get(offset..offset + xlen)
            .ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?
            .to_vec();
        offset += xlen;
        Some(extra)
    } else {
        None
    };

    // Parse original filename
    let original_name = if has_name {
        let (name, new_offset) = read_null_terminated(data, offset)?;
        offset = new_offset;
        Some(name)
    } else {
        None
    };

    // Parse comment
    let comment = if has_comment {
        let (cmt, new_offset) = read_null_terminated(data, offset)?;
        offset = new_offset;
        Some(cmt)
    } else {
        None
    };

    // Parse header CRC16
    let header_crc16 = if has_header_crc {
        let crc_bytes: [u8; 2] = data
            .get(offset..offset + 2)
            .ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?
            .try_into()
            .map_err(|_| Error::InvalidGzipHeader("data truncated".into()))?;
        let crc = u16::from_le_bytes(crc_bytes);
        offset += 2;
        Some(crc)
    } else {
        None
    };

    Ok(GzipHeader {
        compression_method,
        compression_method_name,
        flags,
        is_text,
        has_header_crc,
        has_extra,
        has_name,
        has_comment,
        mtime,
        mtime_formatted,
        extra_flags,
        extra_flags_description,
        os_code,
        os_name,
        original_name,
        comment,
        extra_data,
        header_crc16,
        header_size: offset,
    })
}

/// Parse gzip trailer (last 8 bytes).
fn parse_trailer(data: &[u8]) -> Option<GzipTrailer> {
    if data.len() < 18 {
        return None;
    }
    let tlen = data.len();
    let crc_bytes: [u8; 4] = data.get(tlen - 8..tlen - 4)?.try_into().ok()?;
    let size_bytes: [u8; 4] = data.get(tlen - 4..tlen)?.try_into().ok()?;
    let crc32 = u32::from_le_bytes(crc_bytes);
    let original_size = u32::from_le_bytes(size_bytes);
    Some(GzipTrailer {
        crc32,
        original_size,
    })
}

/// Count the number of gzip members in a concatenated gzip file.
fn count_members(data: &[u8]) -> (u32, bool) {
    let mut count = 0u32;
    let mut decoder = flate2::read::MultiGzDecoder::new(data);
    let mut buf = [0u8; 8192];
    loop {
        match decoder.read(&mut buf) {
            Ok(0) => break,
            Ok(_) => {
                // We count a new member each time we successfully decompress.
                // `MultiGzDecoder` handles concatenated members transparently.
            },
            Err(_) => break,
        }
    }

    // Re-count by scanning for magic bytes as a heuristic
    let mut pos = 0;
    while let Some(&[b0, b1]) = data
        .get(pos..pos + 2)
        .and_then(|s| <&[u8; 2]>::try_from(s).ok())
    {
        if b0 == 0x1F && b1 == 0x8B {
            count += 1;
            pos += 10; // skip minimum header size
        } else {
            pos += 1;
        }
    }

    let is_multi = count > 1;
    (count.max(1), is_multi)
}

/// Verify the CRC-32 of decompressed gzip content.
///
/// # Errors
///
/// Returns error on decompression failure or checksum mismatch.
pub fn verify_crc(data: &[u8]) -> Result<bool> {
    let mut decoder = flate2::read::GzDecoder::new(data);
    let mut decompressed = Vec::new();
    decoder.read_to_end(&mut decompressed)?;

    let computed_crc = crc32fast::hash(&decompressed);
    let trailer = parse_trailer(data);

    if let Some(t) = trailer {
        Ok(computed_crc == t.crc32)
    } else {
        Ok(true) // No trailer to verify
    }
}

/// Read a null-terminated string from raw bytes.
///
/// # Errors
///
/// Returns an error if no null terminator is found.
fn read_null_terminated(data: &[u8], start: usize) -> Result<(String, usize)> {
    let remainder = data
        .get(start..)
        .ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?;
    let end = remainder
        .iter()
        .position(|&b| b == 0)
        .map(|p| start + p)
        .ok_or_else(|| Error::InvalidGzipHeader("unterminated string".into()))?;

    let s = String::from_utf8_lossy(
        data.get(start..end)
            .ok_or_else(|| Error::InvalidGzipHeader("data truncated".into()))?,
    )
    .into_owned();
    Ok((s, end + 1))
}

fn format_mtime(mtime: u32) -> String {
    if mtime == 0 {
        return "not set".into();
    }
    chrono::DateTime::from_timestamp(i64::from(mtime), 0).map_or_else(
        || format!("{mtime} (invalid)"),
        |dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
    )
}

const fn os_code_name(code: u8) -> &'static str {
    match code {
        0 => "FAT filesystem (MS-DOS, OS/2, NT/Win32)",
        1 => "Amiga",
        2 => "VMS (or OpenVMS)",
        3 => "Unix",
        4 => "VM/CMS",
        5 => "Atari TOS",
        6 => "HPFS filesystem (OS/2, NT)",
        7 => "Macintosh",
        8 => "Z-System",
        9 => "CP/M",
        10 => "TOPS-20",
        11 => "NTFS filesystem (NT)",
        12 => "QDOS",
        13 => "Acorn RISCOS",
        255 => "Unknown",
        _ => "Unrecognized",
    }
}

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

    fn make_gzip_data() -> Vec<u8> {
        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
        std::io::Write::write_all(&mut encoder, b"Hello, gzip inspector!").ok();
        encoder.finish().expect("gzip finish failed")
    }

    #[test]
    fn test_parse_header_valid() {
        let data = make_gzip_data();
        let header = parse_header(&data).expect("parse failed");
        assert_eq!(header.compression_method, 8);
        assert_eq!(header.compression_method_name, "deflate");
    }

    #[test]
    fn test_parse_header_invalid_magic() {
        let data = [0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03];
        assert!(parse_header(&data).is_err());
    }

    #[test]
    fn test_parse_header_too_short() {
        let data = [0x1F, 0x8B];
        assert!(parse_header(&data).is_err());
    }

    #[test]
    fn test_inspect_valid() {
        let data = make_gzip_data();
        let analysis = inspect(&data).expect("inspect failed");
        assert_eq!(analysis.header.compression_method, 8);
        assert!(analysis.file_size > 0);
        assert!(!analysis.sha256.is_empty());
    }

    #[test]
    fn test_verify_crc() {
        let data = make_gzip_data();
        let valid = verify_crc(&data).expect("verify failed");
        assert!(valid);
    }

    #[test]
    fn test_os_code_names() {
        assert_eq!(os_code_name(0), "FAT filesystem (MS-DOS, OS/2, NT/Win32)");
        assert_eq!(os_code_name(3), "Unix");
        assert_eq!(os_code_name(255), "Unknown");
    }

    #[test]
    fn test_parse_trailer() {
        let data = make_gzip_data();
        let trailer = parse_trailer(&data);
        assert!(trailer.is_some());
    }
}
