use apple_bundles::MacOsApplicationBundleBuilder;
use apple_codesign::{SigningSettings, UnifiedSigner};
use icns::{IconFamily, Image};
use simple_file_manifest::FileEntry;
use std::{
    env,
    error::Error,
    ffi::OsString,
    fs::{self, File},
    io::{self, BufReader},
    path::Path,
};

const APP_NAME: &str = "IronStorage";
const APP_EXECUTABLE: &str = "ironstorage-desktop";
const BUNDLE_ID: &str = "de.rfc1437.ironstorage";
const MIB: u64 = 1024 * 1024;

fn main() -> Result<(), Box<dyn Error>> {
    let args: Vec<OsString> = env::args_os().collect();
    if args.len() != 5 {
        return Err(
            "usage: ironstorage-macos-packager <version> <release-dir> <icon.png> <output.dmg>"
                .into(),
        );
    }

    package(
        args[1].to_str().ok_or("version is not valid UTF-8")?,
        Path::new(&args[2]),
        Path::new(&args[3]),
        Path::new(&args[4]),
    )
}

fn package(
    release_tag: &str,
    release_dir: &Path,
    icon_path: &Path,
    output: &Path,
) -> Result<(), Box<dyn Error>> {
    let version = bundle_version(release_tag)?;
    let output_dir = output.parent().unwrap_or_else(|| Path::new("."));
    fs::create_dir_all(output_dir)?;

    if output.try_exists()? {
        return Err(format!("output already exists: {}", output.display()).into());
    }

    let bundle_path = output_dir.join(format!("{APP_NAME}.app"));
    if bundle_path.try_exists()? {
        return Err(format!("staging bundle already exists: {}", bundle_path.display()).into());
    }

    let desktop = release_dir.join("ironstorage-desktop");
    let cli = release_dir.join("ironstorage");
    let tui = release_dir.join("ironstorage-tui");
    for executable in [&desktop, &cli, &tui] {
        if !executable.is_file() {
            return Err(format!("release executable is missing: {}", executable.display()).into());
        }
    }

    let mut bundle = MacOsApplicationBundleBuilder::new(APP_NAME)?;
    bundle.set_info_plist_required_keys(APP_NAME, BUNDLE_ID, version, "IrSt", APP_EXECUTABLE)?;
    bundle.set_info_plist_key("CFBundleShortVersionString", version)?;
    bundle.set_info_plist_key("CFBundleIconFile", format!("{APP_NAME}.icns"))?;
    bundle.set_info_plist_key("LSApplicationCategoryType", "public.app-category.utilities")?;
    bundle.set_info_plist_key("NSHighResolutionCapable", true)?;
    bundle.add_icon(icns(icon_path)?)?;
    bundle.add_file_macos(APP_EXECUTABLE, FileEntry::new_from_path(desktop, true))?;
    bundle.add_file_macos("ironstorage", FileEntry::new_from_path(cli, true))?;
    bundle.add_file_macos("ironstorage-tui", FileEntry::new_from_path(tui, true))?;
    let materialized = bundle.materialize_bundle(output_dir)?;

    let signer = UnifiedSigner::new(SigningSettings::default());
    signer.sign_path_in_place(&materialized)?;
    apple_dmg::create_dmg(
        &materialized,
        output,
        APP_NAME,
        dmg_sectors(directory_size(&materialized)?)?,
    )?;
    signer.sign_path_in_place(output)?;
    fs::remove_dir_all(materialized)?;
    Ok(())
}

fn bundle_version(tag: &str) -> Result<&str, Box<dyn Error>> {
    let version = tag.strip_prefix('v').unwrap_or(tag);
    let version = version.split(['-', '+']).next().unwrap_or_default();
    let parts: Vec<&str> = version.split('.').collect();
    if !(1..=3).contains(&parts.len())
        || parts
            .iter()
            .any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()))
    {
        return Err(format!("release tag is not a numeric Apple bundle version: {tag}").into());
    }
    Ok(version)
}

fn icns(path: &Path) -> Result<Vec<u8>, Box<dyn Error>> {
    let image = Image::read_png(BufReader::new(File::open(path)?))?;
    let mut family = IconFamily::new();
    family.add_icon(&image)?;
    let mut bytes = Vec::new();
    family.write(&mut bytes)?;
    Ok(bytes)
}

fn directory_size(path: &Path) -> io::Result<u64> {
    fs::read_dir(path)?.try_fold(0_u64, |total, entry| {
        let entry = entry?;
        let metadata = entry.metadata()?;
        let size = if metadata.is_dir() {
            directory_size(&entry.path())?
        } else {
            metadata.len()
        };
        total
            .checked_add(size)
            .ok_or_else(|| io::Error::other("bundle size overflow"))
    })
}

fn dmg_sectors(bundle_bytes: u64) -> Result<u32, Box<dyn Error>> {
    let capacity = bundle_bytes
        .checked_add((bundle_bytes / 10).max(16 * MIB))
        .ok_or("DMG capacity overflow")?
        .max(64 * MIB);
    Ok(u32::try_from(capacity.div_ceil(512))?)
}

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

    #[test]
    fn apple_bundle_version_comes_from_semver_tag() {
        assert_eq!(bundle_version("v1.2.3").unwrap(), "1.2.3");
        assert_eq!(bundle_version("2.0.0-rc.1").unwrap(), "2.0.0");
        assert!(bundle_version("nightly").is_err());
        assert!(bundle_version("1.2.3.4").is_err());
    }

    #[test]
    fn dmg_capacity_has_filesystem_headroom() {
        let sectors = dmg_sectors(100 * MIB).unwrap();
        assert!(u64::from(sectors) * 512 >= 116 * MIB);
        assert_eq!(dmg_sectors(1).unwrap(), 131_072);
    }
}
