//! SevenZip archive format compress function

use std::{
    io::{BufWriter, Read, Seek, Write},
    path::{Path, PathBuf},
};

use bstr::ByteSlice;
use fs_err as fs;
use fs_err::PathExt;
use same_file::Handle;
use sevenz_rust2::ArchiveEntry;

use crate::{
    Result,
    error::{Error, FinalError},
    info,
    list::{FileInArchive, ListFileType},
    utils::{
        BytesFmt, FileVisibilityPolicy, PathFmt, cd_into_same_dir_as, copy_limited_decompression,
        ensure_parent_dir_exists, is_same_file_as_output, validate_dest_inside_root, validate_entry_path,
    },
    warning,
};

pub fn unpack_archive<R>(reader: R, output_path: &Path, password: Option<&[u8]>) -> Result<u64>
where
    R: Read + Seek,
{
    let mut files_unpacked = 0;

    let entry_extract_fn =
        |entry: &ArchiveEntry, reader: &mut dyn Read, path: &PathBuf| -> Result<bool, sevenz_rust2::Error> {
            // Manually handle writing all files from 7z archive (the library defaults ignore empty files)

            let name_as_path = Path::new(entry.name());
            let safe_relpath = match validate_entry_path(name_as_path) {
                Ok(p) => p,
                Err(e) => {
                    warning!("skipping unsafe 7z entry {}: {}", PathFmt(name_as_path), e);
                    return Ok(true);
                }
            };
            let file_path = output_path.join(&safe_relpath);

            if let Err(e) = validate_dest_inside_root(output_path, &file_path) {
                warning!("skipping 7z entry {}: {}", PathFmt(&file_path), e);
                return Ok(true);
            }

            if entry.is_directory() {
                info!("File {} extracted to {}", entry.name(), PathFmt(&file_path));
                if !path.fs_err_try_exists()? {
                    fs::create_dir_all(path)?;
                }
            } else {
                info!("extracted ({}) {}", BytesFmt(entry.size()), PathFmt(&file_path));

                ensure_parent_dir_exists(path)?;

                let file = fs::File::create(path)?;
                let mut writer = BufWriter::new(file);
                copy_limited_decompression(reader, &mut writer)?;

                use filetime_creation as ft;
                // Surface mtime-set failures as warnings so users know timestamps weren't preserved
                if let Err(e) = ft::set_file_handle_times(
                    writer.get_ref().file(),
                    Some(ft::FileTime::from_system_time(entry.access_date().into())),
                    Some(ft::FileTime::from_system_time(entry.last_modified_date().into())),
                    Some(ft::FileTime::from_system_time(entry.creation_date().into())),
                ) {
                    warning!("could not set timestamps on {}: {e}", PathFmt(&file_path));
                }
            }

            files_unpacked += 1;
            Ok(true) // Always proceed
        };

    match password {
        Some(password) => sevenz_rust2::decompress_with_extract_fn_and_password(
            reader,
            output_path,
            sevenz_rust2::Password::from(password.to_str().map_err(|err| Error::InvalidPassword {
                reason: err.to_string(),
            })?),
            entry_extract_fn,
        )?,
        None => sevenz_rust2::decompress_with_extract_fn(reader, output_path, entry_extract_fn)?,
    }

    Ok(files_unpacked)
}

/// List contents of `archive_path`, returning a vector of archive entries
pub fn list_archive<R>(reader: R, password: Option<&[u8]>) -> Result<impl Iterator<Item = Result<FileInArchive>>>
where
    R: Read + Seek,
{
    let mut files = Vec::new();

    let entry_extract_fn = |entry: &ArchiveEntry, _: &mut dyn Read, _: &PathBuf| {
        files.push(Ok(FileInArchive {
            path: entry.name().into(),
            file_type: if entry.is_directory() {
                ListFileType::Directory
            } else {
                ListFileType::File
            },
            size: Some(entry.size()),
        }));
        Ok(true)
    };

    match password {
        Some(password) => {
            let password = match password.to_str() {
                Ok(p) => p,
                Err(err) => {
                    return Err(Error::InvalidPassword {
                        reason: err.to_string(),
                    });
                }
            };
            sevenz_rust2::decompress_with_extract_fn_and_password(
                reader,
                ".",
                sevenz_rust2::Password::from(password),
                entry_extract_fn,
            )?;
        }
        None => sevenz_rust2::decompress_with_extract_fn(reader, ".", entry_extract_fn)?,
    }

    Ok(files.into_iter())
}

pub fn build_archive<W>(
    files: &[PathBuf],
    output_path: &Path,
    writer: W,
    file_visibility_policy: FileVisibilityPolicy,
) -> Result<W>
where
    W: Write + Seek,
{
    let mut writer = sevenz_rust2::ArchiveWriter::new(writer)?;
    let output_handle = Handle::from_path(output_path);

    for filename in files {
        let previous_location = cd_into_same_dir_as(filename)?;
        let _cwd_guard = crate::utils::CwdGuard::new(previous_location);

        // Unwrap safety:
        //   paths should be canonicalized by now, and the root directory rejected.
        let filename = filename.file_name().unwrap();

        for entry in file_visibility_policy.build_walker(filename) {
            let entry = entry?;
            let path = entry.path();

            // Avoid compressing the output file into itself
            if let Ok(handle) = output_handle.as_ref()
                && is_same_file_as_output(path, handle)
            {
                warning!("Cannot compress {} into itself, skipping", PathFmt(output_path));
                continue;
            }

            info!("Compressing {}", PathFmt(path));

            // use metadata instead of symlink_metadata, 7z doesn't support symlinks
            let metadata = path.metadata()?;

            let entry_name = path.to_str().ok_or_else(|| {
                FinalError::with_title("7z requires that all entry names are valid UTF-8")
                    .detail(format!("File {} has a non-UTF-8 name", PathFmt(path)))
            })?;

            let entry = sevenz_rust2::ArchiveEntry::from_path(path, entry_name.to_owned());
            let entry_data = if metadata.is_dir() {
                None
            } else {
                Some(fs::File::open(path)?)
            };

            writer.push_archive_entry::<fs::File>(entry, entry_data)?;
        }
    }

    let bytes = writer.finish()?;
    Ok(bytes)
}
