//! Archive extraction support for ZIP and TAR formats.
//!
//! # Platform Behavior
//!
//! **Unix**: Full permission support with all `PermissionStrategy` variants functional.
//! File mode bits from archives are respected and applied according to the selected strategy.
//!
//! **Windows (non-Unix)**: Permission handling is a no-op - `PermissionStrategy` has no effect,
//! and permission values are ignored. The API accepts permission-related options for API compatibility,
//! but they are not applied on Windows platforms.

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

use crate::entry::{Entry, EntryKind};
use crate::error::{Error, Result};
use crate::format;
use crate::options::{self, ExtractOptions, HashStrategy};
use pulith_fs::workflow::Workspace;

use crate::entry::ArchiveReport;

mod tar;
mod zip;

pub use tar::TarSource;
pub use zip::ZipSource;

/// An entry read from archive but not yet processed.
///
/// The reader contains owned bytes for the file content, allowing
/// hash computation and file writing without lifetime issues.
pub struct PendingEntry {
    pub original_path: PathBuf,
    pub size: u64,
    pub mode: Option<u32>,
    pub kind: EntryKind,
    pub reader: Option<Box<dyn Read>>,
}

/// Archive-specific entry source trait.
pub trait EntrySource {
    fn entries(&mut self) -> Result<Box<dyn Iterator<Item = Result<PendingEntry>> + '_>>;
    fn format(&self) -> format::ArchiveFormat;
}

/// Extraction results.
pub struct Extracted {
    pub entries: Vec<Entry>,
    pub total_bytes: u64,
}

/// Main extraction pipeline.
///
/// Processes entries from the source, sanitizes paths, writes files to disk,
/// computes hashes if requested, applies permissions, and reports progress.
pub fn extract<S: EntrySource>(
    source: &mut S,
    destination: impl AsRef<Path>,
    options: &ExtractOptions,
) -> Result<Extracted> {
    let mut entries = Vec::new();
    let mut total_bytes = 0u64;
    let mut bytes_processed = 0u64;

    for pending in source.entries()? {
        let next_entry_count = entries.len() + 1;
        if let Some(limit) = options.max_entries
            && next_entry_count > limit
        {
            return Err(Error::EntryLimitExceeded {
                observed: next_entry_count,
                limit,
            });
        }

        let mut pending = pending?;
        bytes_processed += pending.size;
        total_bytes += pending.size;

        if let Some(limit_bytes) = options.max_total_bytes
            && total_bytes > limit_bytes
        {
            return Err(Error::ByteLimitExceeded {
                observed_bytes: total_bytes,
                limit_bytes,
            });
        }

        let mut entry = Entry::new(
            pending.original_path.clone(),
            pending.size,
            pending.mode,
            pending.kind.clone(),
        );

        // Sanitize the path using the composable API
        let sanitized = options.sanitize_path(&entry.original_path, destination.as_ref())?;

        entry = entry.with_target_path(sanitized.resolved.clone());

        // Write the entry to disk
        write_entry(
            &mut pending,
            &sanitized.resolved,
            destination.as_ref(),
            options,
        )?;

        // Compute hash from the extracted file contents
        if entry.is_file() && options.hash_strategy != HashStrategy::None {
            let hash = options
                .hash_strategy
                .compute(File::open(&sanitized.resolved)?)?;
            if let Some(hash_value) = hash {
                entry = entry.with_hash(hash_value);
            }
        }

        // Apply permissions to the target path
        if let Some(target_path) = &entry.target_path {
            options
                .perm_strategy
                .apply_to_path(target_path, pending.mode)?;
        }

        // Report progress if callback is provided
        if let Some(ref callback) = options.on_progress {
            let percentage = options.expected_total_bytes.and_then(|expected| {
                if expected > 0 {
                    Some((bytes_processed as f32 / expected as f32) * 100.0)
                } else {
                    None
                }
            });

            callback(options::Progress {
                bytes_processed,
                total_bytes: Some(total_bytes),
                percentage,
                current_file: Some(entry.original_path.clone()),
            });
        }

        entries.push(entry);
    }

    Ok(Extracted {
        entries,
        total_bytes,
    })
}

fn write_entry(
    pending: &mut PendingEntry,
    target_path: &Path,
    destination: &Path,
    options: &ExtractOptions,
) -> Result<()> {
    match &pending.kind {
        EntryKind::File => write_file(pending, target_path),
        EntryKind::Directory => ensure_directory(target_path),
        EntryKind::Symlink { target } => {
            let sanitized_target =
                options.sanitize_symlink_target(target, target_path, destination)?;
            write_symlink(&sanitized_target, target_path)
        }
    }
}

fn write_file(pending: &mut PendingEntry, target_path: &Path) -> Result<()> {
    if let Some(parent) = target_path.parent()
        && !parent.exists()
    {
        std::fs::create_dir_all(parent).map_err(|e| Error::DirectoryCreationFailed {
            path: parent.to_path_buf(),
            source: e,
        })?;
    }

    if let Some(ref mut reader) = pending.reader {
        let mut file = std::fs::File::create(target_path).map_err(|e| Error::ExtractionFailed {
            path: target_path.to_path_buf(),
            source: e,
        })?;
        std::io::copy(reader, &mut file)?;
    }

    Ok(())
}

fn ensure_directory(path: &Path) -> Result<()> {
    if !path.exists() {
        std::fs::create_dir_all(path).map_err(|e| Error::DirectoryCreationFailed {
            path: path.to_path_buf(),
            source: e,
        })?;
    }
    Ok(())
}

#[cfg(unix)]
fn write_symlink(target: &Path, link: &Path) -> Result<()> {
    use std::os::unix::fs::symlink;
    if let Some(parent) = link.parent()
        && !parent.exists()
    {
        std::fs::create_dir_all(parent).map_err(|e| Error::DirectoryCreationFailed {
            path: parent.to_path_buf(),
            source: e,
        })?;
    }
    symlink(target, link).map_err(|e| Error::SymlinkCreationFailed {
        target: target.to_path_buf(),
        link: link.to_path_buf(),
        source: e,
    })
}

#[cfg(windows)]
fn write_symlink(target: &Path, link: &Path) -> Result<()> {
    use std::os::windows::fs;
    if let Some(parent) = link.parent()
        && !parent.exists()
    {
        std::fs::create_dir_all(parent).map_err(|e| Error::DirectoryCreationFailed {
            path: parent.to_path_buf(),
            source: e,
        })?;
    }
    let is_dir_target = target.is_dir() || target.to_string_lossy().ends_with('/');
    if is_dir_target {
        fs::symlink_dir(target, link).map_err(|e| Error::SymlinkCreationFailed {
            target: target.to_path_buf(),
            link: link.to_path_buf(),
            source: e,
        })
    } else {
        fs::symlink_file(target, link).map_err(|e| Error::SymlinkCreationFailed {
            target: target.to_path_buf(),
            link: link.to_path_buf(),
            source: e,
        })
    }
}

/// Extract archive with automatic format detection.
///
/// This function detects the archive format from the reader, rewinds it,
/// and then extracts all entries to the specified destination directory.
pub fn extract_from_reader<R: Read + Seek + 'static>(
    mut reader: R,
    destination: &Path,
    options: &ExtractOptions,
) -> Result<ArchiveReport> {
    let format = format::detect_from_reader(&mut reader)?.ok_or(Error::UnsupportedFormat)?;
    reader.rewind()?;

    match format {
        format::ArchiveFormat::Zip => {
            let mut source = ZipSource::new(reader)?;
            let results = extract(&mut source, destination, options)?;
            Ok(create_report(format, results))
        }
        format::ArchiveFormat::Tar(codec) => {
            let mut source = TarSource::new(reader, codec)?;
            let results = extract(&mut source, destination, options)?;
            Ok(create_report(format, results))
        }
    }
}

/// Helper function to create an ArchiveReport from extraction results.
fn create_report(format: format::ArchiveFormat, results: Extracted) -> ArchiveReport {
    ArchiveReport {
        format,
        entry_count: results.entries.len(),
        total_bytes: results.total_bytes,
        entries: results.entries,
    }
}

/// Extract archive using explicit source.
///
/// This function allows direct use of an EntrySource implementation
/// without automatic format detection.
pub fn extract_with_source<S: EntrySource>(
    source: &mut S,
    destination: &Path,
    options: &ExtractOptions,
) -> Result<ArchiveReport> {
    let results = extract(source, destination, options)?;
    Ok(create_report(source.format(), results))
}

/// Extract to workspace for atomic commit.
pub fn extract_to_workspace<R: Read + Seek + 'static>(
    reader: R,
    destination: &Path,
    options: ExtractOptions,
) -> Result<WorkspaceExtraction> {
    let temp_dir = tempfile::Builder::new()
        .prefix("pulith-archive-")
        .tempdir()
        .map_err(|e| Error::ExtractionFailed {
            path: destination.to_path_buf(),
            source: e,
        })?;

    let workspace = Workspace::new(temp_dir.path(), destination).map_err(Error::from)?;

    let report = extract_from_reader(reader, temp_dir.path(), &options)?;

    Ok(WorkspaceExtraction {
        workspace,
        _temp_dir: temp_dir,
        report,
    })
}

pub struct WorkspaceExtraction {
    workspace: Workspace,
    _temp_dir: tempfile::TempDir,
    report: ArchiveReport,
}

impl WorkspaceExtraction {
    pub fn commit(self) -> Result<ArchiveReport> {
        self.workspace.commit()?;
        Ok(self.report)
    }

    pub fn abort(self) {
        drop(self.workspace);
    }

    pub fn report(&self) -> &ArchiveReport {
        &self.report
    }
}

#[cfg(test)]
mod tests {
    use std::io::Cursor;
    use std::io::Write;
    use std::path::Path;
    use std::path::PathBuf;

    use super::*;

    fn tar_archive_with_file_path(path: &str) -> Vec<u8> {
        let mut buffer = Vec::new();
        {
            let mut builder = ::tar::Builder::new(&mut buffer);
            let payload = b"evil";
            let mut header = ::tar::Header::new_gnu();
            header.set_size(payload.len() as u64);
            header.set_mode(0o644);
            header.set_cksum();
            builder
                .append_data(&mut header, "safe.txt", &payload[..])
                .unwrap();
            builder.finish().unwrap();
        }
        overwrite_tar_path(&mut buffer, path);
        buffer
    }

    fn overwrite_tar_path(tar: &mut [u8], path: &str) {
        assert!(tar.len() >= 512);
        let path_bytes = path.as_bytes();
        assert!(path_bytes.len() < 100);

        tar[..100].fill(0);
        tar[..path_bytes.len()].copy_from_slice(path_bytes);
        tar[148..156].fill(b' ');

        let checksum: u32 = tar[..512].iter().map(|byte| *byte as u32).sum();
        let checksum_octal = format!("{:06o}\0 ", checksum);
        tar[148..156].copy_from_slice(checksum_octal.as_bytes());
    }

    fn tar_archive_with_symlink(path: &str, target: &str) -> Vec<u8> {
        let mut buffer = Vec::new();
        {
            let mut builder = ::tar::Builder::new(&mut buffer);
            let mut header = ::tar::Header::new_gnu();
            header.set_entry_type(::tar::EntryType::Symlink);
            header.set_size(0);
            header.set_mode(0o777);
            header.set_link_name(target).unwrap();
            header.set_cksum();
            builder
                .append_data(&mut header, path, std::io::empty())
                .unwrap();
            builder.finish().unwrap();
        }
        buffer
    }

    #[cfg(feature = "xz")]
    fn compress_xz(data: &[u8]) -> Vec<u8> {
        let mut encoder = xz2::write::XzEncoder::new(Vec::new(), 6);
        encoder.write_all(data).unwrap();
        encoder.finish().unwrap()
    }

    #[cfg(feature = "zstd")]
    fn compress_zstd(data: &[u8]) -> Vec<u8> {
        let mut encoder = zstd::stream::Encoder::new(Vec::new(), 3).unwrap();
        encoder.write_all(data).unwrap();
        encoder.finish().unwrap()
    }

    struct TestSource {
        entries: Vec<Result<PendingEntry>>,
    }

    impl EntrySource for TestSource {
        fn entries(&mut self) -> Result<Box<dyn Iterator<Item = Result<PendingEntry>> + '_>> {
            Ok(Box::new(self.entries.drain(..)))
        }

        fn format(&self) -> format::ArchiveFormat {
            format::ArchiveFormat::Zip
        }
    }

    #[test]
    fn extract_from_reader_invalid_format() {
        let data = [0xDE, 0xAD, 0xBE, 0xEF];
        let cursor = Cursor::new(data);
        let result = extract_from_reader(cursor, Path::new("/tmp"), &ExtractOptions::default());
        assert!(result.is_err());
    }

    #[test]
    fn extract_computes_hash_from_written_file() {
        let temp_dir = tempfile::tempdir().unwrap();
        let mut source = TestSource {
            entries: vec![Ok(PendingEntry {
                original_path: PathBuf::from("bin/tool"),
                size: 5,
                mode: Some(0o755),
                kind: EntryKind::File,
                reader: Some(Box::new(Cursor::new(b"hello".to_vec()))),
            })],
        };

        let extracted = extract(
            &mut source,
            temp_dir.path(),
            &ExtractOptions::default().hash_strategy(HashStrategy::Sha256),
        )
        .unwrap();

        assert_eq!(
            extracted.entries[0].hash.as_deref(),
            Some("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824")
        );
    }

    #[test]
    fn extract_rejects_absolute_symlink_target() {
        let temp_dir = tempfile::tempdir().unwrap();
        let absolute_target = if cfg!(windows) {
            PathBuf::from("C:/escape")
        } else {
            PathBuf::from("/escape")
        };
        let mut source = TestSource {
            entries: vec![Ok(PendingEntry {
                original_path: PathBuf::from("bin/tool-link"),
                size: 0,
                mode: None,
                kind: EntryKind::Symlink {
                    target: absolute_target,
                },
                reader: None,
            })],
        };

        let result = extract(&mut source, temp_dir.path(), &ExtractOptions::default());
        assert!(matches!(result, Err(Error::AbsoluteSymlinkTarget { .. })));
    }

    #[test]
    fn extract_rejects_relative_escape_entry_path() {
        let temp_dir = tempfile::tempdir().unwrap();
        let mut source = TestSource {
            entries: vec![Ok(PendingEntry {
                original_path: PathBuf::from("../../escape.txt"),
                size: 4,
                mode: None,
                kind: EntryKind::File,
                reader: Some(Box::new(Cursor::new(b"evil".to_vec()))),
            })],
        };

        let result = extract(&mut source, temp_dir.path(), &ExtractOptions::default());
        assert!(matches!(result, Err(Error::ZipSlip { .. })));
    }

    #[test]
    fn extract_rejects_absolute_entry_path() {
        let temp_dir = tempfile::tempdir().unwrap();
        let absolute = if cfg!(windows) {
            PathBuf::from("C:/escape.txt")
        } else {
            PathBuf::from("/escape.txt")
        };
        let mut source = TestSource {
            entries: vec![Ok(PendingEntry {
                original_path: absolute,
                size: 4,
                mode: None,
                kind: EntryKind::File,
                reader: Some(Box::new(Cursor::new(b"evil".to_vec()))),
            })],
        };

        let result = extract(&mut source, temp_dir.path(), &ExtractOptions::default());
        assert!(matches!(result, Err(Error::ZipSlip { .. })));
    }

    #[test]
    fn extract_rejects_symlink_target_escape() {
        let temp_dir = tempfile::tempdir().unwrap();
        let mut source = TestSource {
            entries: vec![Ok(PendingEntry {
                original_path: PathBuf::from("bin/tool-link"),
                size: 0,
                mode: None,
                kind: EntryKind::Symlink {
                    target: PathBuf::from("../../escape"),
                },
                reader: None,
            })],
        };

        let result = extract(&mut source, temp_dir.path(), &ExtractOptions::default());
        assert!(matches!(result, Err(Error::SymlinkEscape { .. })));
    }

    #[test]
    fn extract_rejects_when_entry_limit_exceeded() {
        let temp_dir = tempfile::tempdir().unwrap();
        let mut source = TestSource {
            entries: vec![
                Ok(PendingEntry {
                    original_path: PathBuf::from("bin/a"),
                    size: 1,
                    mode: None,
                    kind: EntryKind::File,
                    reader: Some(Box::new(Cursor::new(b"a".to_vec()))),
                }),
                Ok(PendingEntry {
                    original_path: PathBuf::from("bin/b"),
                    size: 1,
                    mode: None,
                    kind: EntryKind::File,
                    reader: Some(Box::new(Cursor::new(b"b".to_vec()))),
                }),
            ],
        };

        let result = extract(
            &mut source,
            temp_dir.path(),
            &ExtractOptions::default().max_entries(1),
        );
        assert!(matches!(
            result,
            Err(Error::EntryLimitExceeded {
                observed: 2,
                limit: 1
            })
        ));
    }

    #[test]
    fn extract_rejects_when_total_byte_limit_exceeded() {
        let temp_dir = tempfile::tempdir().unwrap();
        let mut source = TestSource {
            entries: vec![
                Ok(PendingEntry {
                    original_path: PathBuf::from("bin/a"),
                    size: 4,
                    mode: None,
                    kind: EntryKind::File,
                    reader: Some(Box::new(Cursor::new(b"aaaa".to_vec()))),
                }),
                Ok(PendingEntry {
                    original_path: PathBuf::from("bin/b"),
                    size: 4,
                    mode: None,
                    kind: EntryKind::File,
                    reader: Some(Box::new(Cursor::new(b"bbbb".to_vec()))),
                }),
            ],
        };

        let result = extract(
            &mut source,
            temp_dir.path(),
            &ExtractOptions::default().max_total_bytes(7),
        );
        assert!(matches!(
            result,
            Err(Error::ByteLimitExceeded {
                observed_bytes: 8,
                limit_bytes: 7
            })
        ));
    }

    #[cfg(feature = "xz")]
    #[test]
    fn tar_xz_rejects_relative_escape_entry_fixture() {
        let tar = tar_archive_with_file_path("../escape");
        let compressed = compress_xz(&tar);
        let temp_dir = tempfile::tempdir().unwrap();
        let result = extract_from_reader(
            Cursor::new(compressed),
            temp_dir.path(),
            &ExtractOptions::default(),
        );
        assert!(matches!(result, Err(Error::ZipSlip { .. })));
    }

    #[cfg(feature = "xz")]
    #[test]
    fn tar_xz_rejects_absolute_entry_fixture() {
        let tar = tar_archive_with_file_path("/etc/evil");
        let compressed = compress_xz(&tar);
        let temp_dir = tempfile::tempdir().unwrap();
        let result = extract_from_reader(
            Cursor::new(compressed),
            temp_dir.path(),
            &ExtractOptions::default(),
        );
        assert!(matches!(result, Err(Error::ZipSlip { .. })));
    }

    #[cfg(feature = "xz")]
    #[test]
    fn tar_xz_rejects_symlink_escape_fixture() {
        let tar = tar_archive_with_symlink("bin/link", "../../escape");
        let compressed = compress_xz(&tar);
        let temp_dir = tempfile::tempdir().unwrap();
        let result = extract_from_reader(
            Cursor::new(compressed),
            temp_dir.path(),
            &ExtractOptions::default(),
        );
        assert!(matches!(result, Err(Error::SymlinkEscape { .. })));
    }

    #[cfg(feature = "zstd")]
    #[test]
    fn tar_zst_rejects_relative_escape_entry_fixture() {
        let tar = tar_archive_with_file_path("../escape");
        let compressed = compress_zstd(&tar);
        let temp_dir = tempfile::tempdir().unwrap();
        let result = extract_from_reader(
            Cursor::new(compressed),
            temp_dir.path(),
            &ExtractOptions::default(),
        );
        assert!(matches!(result, Err(Error::ZipSlip { .. })));
    }

    #[cfg(feature = "zstd")]
    #[test]
    fn tar_zst_rejects_absolute_entry_fixture() {
        let tar = tar_archive_with_file_path("/etc/evil");
        let compressed = compress_zstd(&tar);
        let temp_dir = tempfile::tempdir().unwrap();
        let result = extract_from_reader(
            Cursor::new(compressed),
            temp_dir.path(),
            &ExtractOptions::default(),
        );
        assert!(matches!(result, Err(Error::ZipSlip { .. })));
    }

    #[cfg(feature = "zstd")]
    #[test]
    fn tar_zst_rejects_symlink_escape_fixture() {
        let tar = tar_archive_with_symlink("bin/link", "../../escape");
        let compressed = compress_zstd(&tar);
        let temp_dir = tempfile::tempdir().unwrap();
        let result = extract_from_reader(
            Cursor::new(compressed),
            temp_dir.path(),
            &ExtractOptions::default(),
        );
        assert!(matches!(result, Err(Error::SymlinkEscape { .. })));
    }
}
