//! Archive extraction with cycle detection and path-traversal protection.
//!
//! Pure-Rust archive handling with memory safety guarantees:
//!   - No buffer overflows (bounds-checked `PathBuf` and `Vec`)
//!   - RAII resource cleanup via `Drop`
//!   - Archive-in-archive cycle detection via SHA-256 graph
//!   - Path-traversal protection (strips `../` and leading `/`)

use std::collections::HashSet;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};

use sha2::{Digest, Sha256};

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

// ---------------------------------------------------------------------------
// Archive format detection
// ---------------------------------------------------------------------------

/// Known extractable archive extensions.
const ARCHIVE_EXTENSIONS: &[&str] = &[
    "tar", "tar.gz", "tgz", "tar.bz2", "tbz2", "tar.xz", "txz", "tar.lzma", "tar.zst",
    "zip", "jar", "war", "ear", "gz", "bz2", "xz", "lzma", "zst",
    "rpm", "deb", "cpio", "7z", "ar",
];

/// Check if a file path looks extractable. Returns a confidence 0.0-1.0.
pub fn is_extractable(path: &Path) -> f64 {
    let name = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();

    for ext in ARCHIVE_EXTENSIONS {
        if name.ends_with(&format!(".{ext}")) {
            return 1.0;
        }
    }

    // Check magic bytes if extension is ambiguous
    if let Ok(mut f) = fs::File::open(path) {
        let mut magic = [0u8; 8];
        if f.read_exact(&mut magic).is_ok() {
            return match &magic {
                // PK (ZIP/JAR/WAR)
                [0x50, 0x4B, 0x03, 0x04, ..] => 1.0,
                // Gzip
                [0x1F, 0x8B, ..] => 0.9,
                // XZ
                [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00, ..] => 1.0,
                // BZ2
                [0x42, 0x5A, 0x68, ..] => 0.9,
                // 7z
                [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, ..] => 1.0,
                // tar (ustar at offset 257, but check for common patterns)
                _ => 0.0,
            };
        }
    }

    0.0
}

/// Split a filename into (basename, full_extension, final_extension).
/// Handles compound extensions like ".tar.gz".
pub fn split_ext(path: &Path) -> (String, String, String) {
    let name = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("");

    // Check for compound extensions
    let compound = [
        ".tar.gz", ".tar.bz2", ".tar.xz", ".tar.lzma", ".tar.zst",
    ];
    for ext in &compound {
        if let Some(base) = name.strip_suffix(ext) {
            return (
                base.to_string(),
                ext.to_string(),
                ext.rsplit('.').next().unwrap_or("").to_string(),
            );
        }
    }

    // Simple extension
    if let Some(dot_pos) = name.rfind('.') {
        let base = &name[..dot_pos];
        let ext = &name[dot_pos..];
        (base.to_string(), ext.to_string(), ext[1..].to_string())
    } else {
        (name.to_string(), String::new(), String::new())
    }
}

// ---------------------------------------------------------------------------
// Extraction engine
// ---------------------------------------------------------------------------

/// Extract an archive to a destination directory.
///
/// Returns the path where contents were extracted.
pub fn extract(source: &Path, dest: &Path) -> Result<PathBuf> {
    let (basename, _, _) = split_ext(source);
    let target = dest.join(&basename);
    fs::create_dir_all(&target).map_err(|e| Error::Io {
        path: target.clone(),
        source: e,
    })?;

    extract_to(source, &target)?;
    Ok(target)
}

/// Extract an archive to a specific target directory, with cycle detection.
pub fn extract_to(source: &Path, target: &Path) -> Result<()> {
    let mut visited = HashSet::new();
    extract_inner(source, target, &mut visited)
}

fn extract_inner(
    source: &Path,
    target: &Path,
    visited: &mut HashSet<[u8; 32]>,
) -> Result<()> {
    // Cycle detection: hash the source file
    let hash = file_sha256(source)?;
    if !visited.insert(hash) {
        return Err(Error::ArchiveCycle {
            path: source.to_owned(),
        });
    }

    let name = source
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();

    // Dispatch by format
    if name.ends_with(".zip") || name.ends_with(".jar") || name.ends_with(".war") || name.ends_with(".ear") {
        extract_zip(source, target)
    } else if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
        extract_tar_compressed(source, target, Compression::Gzip)
    } else if name.ends_with(".tar.bz2") || name.ends_with(".tbz2") {
        extract_tar_compressed(source, target, Compression::Bzip2)
    } else if name.ends_with(".tar.xz") || name.ends_with(".txz") {
        extract_tar_compressed(source, target, Compression::Xz)
    } else if name.ends_with(".tar.lzma") {
        extract_tar_compressed(source, target, Compression::Lzma)
    } else if name.ends_with(".tar") {
        extract_tar(source, target)
    } else if name.ends_with(".gz") {
        decompress_single(source, target, Compression::Gzip)
    } else if name.ends_with(".bz2") {
        decompress_single(source, target, Compression::Bzip2)
    } else if name.ends_with(".xz") {
        decompress_single(source, target, Compression::Xz)
    } else if name.ends_with(".lzma") {
        decompress_single(source, target, Compression::Lzma)
    } else {
        // Try as tar, fall back to raw decompression
        extract_tar(source, target).or_else(|_| decompress_single(source, target, Compression::Gzip))
    }
}

enum Compression {
    Gzip,
    Bzip2,
    Xz,
    Lzma,
}

fn extract_zip(source: &Path, target: &Path) -> Result<()> {
    let file = fs::File::open(source).map_err(|e| Error::Io {
        path: source.to_owned(),
        source: e,
    })?;

    let mut archive = zip::ZipArchive::new(file).map_err(|e| Error::Extraction {
        path: source.to_owned(),
        message: e.to_string(),
    })?;

    for i in 0..archive.len() {
        let mut entry = archive.by_index(i).map_err(|e| Error::Extraction {
            path: source.to_owned(),
            message: e.to_string(),
        })?;

        // Path-traversal protection: force relative, reject ".."
        let name = entry.name().to_string();
        let safe_name = sanitize_entry_path(&name);
        let out_path = target.join(&safe_name);

        if entry.is_dir() {
            fs::create_dir_all(&out_path).map_err(|e| Error::Io {
                path: out_path.clone(),
                source: e,
            })?;
            // Uniform permissions: 0755 for dirs
            #[cfg(unix)]
            set_permissions(&out_path, 0o755);
        } else {
            if let Some(parent) = out_path.parent() {
                let _ = fs::create_dir_all(parent); // best-effort; File::create below fails if dir missing
            }
            let mut out_file = fs::File::create(&out_path).map_err(|e| Error::Io {
                path: out_path.clone(),
                source: e,
            })?;
            io::copy(&mut entry, &mut out_file).map_err(|e| Error::Io {
                path: out_path.clone(),
                source: e,
            })?;
            // Uniform permissions: 0644 for files
            #[cfg(unix)]
            set_permissions(&out_path, 0o644);
        }
    }

    Ok(())
}

fn extract_tar(source: &Path, target: &Path) -> Result<()> {
    let file = fs::File::open(source).map_err(|e| Error::Io {
        path: source.to_owned(),
        source: e,
    })?;
    unpack_tar(file, target, source)
}

fn extract_tar_compressed(source: &Path, target: &Path, comp: Compression) -> Result<()> {
    let file = fs::File::open(source).map_err(|e| Error::Io {
        path: source.to_owned(),
        source: e,
    })?;

    match comp {
        Compression::Gzip => {
            let decoder = flate2::read::GzDecoder::new(file);
            unpack_tar(decoder, target, source)
        }
        Compression::Bzip2 => {
            let decoder = bzip2::read::BzDecoder::new(file);
            unpack_tar(decoder, target, source)
        }
        Compression::Xz => {
            let decoder = xz2::read::XzDecoder::new(file);
            unpack_tar(decoder, target, source)
        }
        Compression::Lzma => {
            let decoder = xz2::read::XzDecoder::new(file);
            unpack_tar(decoder, target, source)
        }
    }
}

fn unpack_tar<R: Read>(reader: R, target: &Path, source: &Path) -> Result<()> {
    let mut archive = tar::Archive::new(reader);
    archive.set_preserve_permissions(false);
    archive.set_overwrite(true);

    for entry in archive.entries().map_err(|e| Error::Extraction {
        path: source.to_owned(),
        message: e.to_string(),
    })? {
        let mut entry = entry.map_err(|e| Error::Extraction {
            path: source.to_owned(),
            message: e.to_string(),
        })?;

        let raw_path = entry
            .path()
            .map_err(|e| Error::Extraction {
                path: source.to_owned(),
                message: e.to_string(),
            })?
            .to_path_buf();

        let safe = sanitize_entry_path(&raw_path.to_string_lossy());
        let out_path = target.join(&safe);

        if entry.header().entry_type().is_dir() {
            let _ = fs::create_dir_all(&out_path); // best-effort for dir entries
            #[cfg(unix)]
            set_permissions(&out_path, 0o755);
        } else if entry.header().entry_type().is_file() {
            if let Some(parent) = out_path.parent() {
                let _ = fs::create_dir_all(parent); // best-effort; File::create below fails if dir missing
            }
            let mut out_file = fs::File::create(&out_path).map_err(|e| Error::Io {
                path: out_path.clone(),
                source: e,
            })?;
            io::copy(&mut entry, &mut out_file).map_err(|e| Error::Io {
                path: out_path.clone(),
                source: e,
            })?;
            #[cfg(unix)]
            set_permissions(&out_path, 0o644);
        }
    }

    Ok(())
}

fn decompress_single(source: &Path, target: &Path, comp: Compression) -> Result<()> {
    let (basename, _, _) = split_ext(source);
    let out_path = target.join(&basename);

    let file = fs::File::open(source).map_err(|e| Error::Io {
        path: source.to_owned(),
        source: e,
    })?;

    let mut out = fs::File::create(&out_path).map_err(|e| Error::Io {
        path: out_path.clone(),
        source: e,
    })?;

    match comp {
        Compression::Gzip => {
            let mut decoder = flate2::read::GzDecoder::new(file);
            io::copy(&mut decoder, &mut out).map_err(|e| Error::Io {
                path: out_path,
                source: e,
            })?;
        }
        Compression::Bzip2 => {
            let mut decoder = bzip2::read::BzDecoder::new(file);
            io::copy(&mut decoder, &mut out).map_err(|e| Error::Io {
                path: out_path,
                source: e,
            })?;
        }
        Compression::Xz | Compression::Lzma => {
            let mut decoder = xz2::read::XzDecoder::new(file);
            io::copy(&mut decoder, &mut out).map_err(|e| Error::Io {
                path: out_path,
                source: e,
            })?;
        }
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Strip leading "/" and ".." components to prevent path traversal.
fn sanitize_entry_path(path: &str) -> String {
    let stripped = path.trim_start_matches('/');
    stripped
        .split('/')
        .filter(|c| *c != ".." && !c.is_empty())
        .collect::<Vec<_>>()
        .join("/")
}

/// Compute SHA-256 of a file for cycle detection.
fn file_sha256(path: &Path) -> Result<[u8; 32]> {
    let mut file = fs::File::open(path).map_err(|e| Error::Io {
        path: path.to_owned(),
        source: e,
    })?;
    let mut hasher = Sha256::new();
    let mut buf = [0u8; 8192];
    loop {
        let n = file.read(&mut buf).map_err(|e| Error::Io {
            path: path.to_owned(),
            source: e,
        })?;
        if n == 0 {
            break;
        }
        hasher.update(&buf[..n]);
    }
    Ok(hasher.finalize().into())
}

#[cfg(unix)]
fn set_permissions(path: &Path, mode: u32) {
    use std::os::unix::fs::PermissionsExt;
    let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
}

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

    #[test]
    fn test_sanitize_path() {
        assert_eq!(sanitize_entry_path("/etc/passwd"), "etc/passwd");
        assert_eq!(sanitize_entry_path("../../../etc/passwd"), "etc/passwd");
        assert_eq!(sanitize_entry_path("safe/path/file.txt"), "safe/path/file.txt");
        assert_eq!(sanitize_entry_path(""), "");
    }

    #[test]
    fn test_split_ext() {
        let (b, f, e) = split_ext(Path::new("data.tar.gz"));
        assert_eq!(b, "data");
        assert_eq!(f, ".tar.gz");
        assert_eq!(e, "gz");

        let (b, f, e) = split_ext(Path::new("archive.zip"));
        assert_eq!(b, "archive");
        assert_eq!(f, ".zip");
        assert_eq!(e, "zip");

        let (b, f, _) = split_ext(Path::new("noext"));
        assert_eq!(b, "noext");
        assert_eq!(f, "");
    }

    #[test]
    fn test_is_extractable() {
        assert_eq!(is_extractable(Path::new("file.tar.gz")), 1.0);
        assert_eq!(is_extractable(Path::new("file.zip")), 1.0);
        assert_eq!(is_extractable(Path::new("file.txt")), 0.0);
    }

    #[test]
    fn test_zip_extraction() {
        use std::io::Write;

        let dir = tempfile::tempdir().unwrap();
        let zip_path = dir.path().join("test.zip");

        // Create a minimal ZIP
        let file = fs::File::create(&zip_path).unwrap();
        let mut zip_writer = zip::ZipWriter::new(file);
        let options = zip::write::SimpleFileOptions::default();
        zip_writer.start_file("hello.txt", options).unwrap();
        zip_writer.write_all(b"SHA256 inside zip").unwrap();
        zip_writer.finish().unwrap();

        // Extract
        let out_dir = dir.path().join("out");
        fs::create_dir_all(&out_dir).unwrap();
        extract_zip(&zip_path, &out_dir).unwrap();

        let content = fs::read_to_string(out_dir.join("hello.txt")).unwrap();
        assert_eq!(content, "SHA256 inside zip");
    }
}
