//! Native `[bootstrap.packages]` support for OCI builds.
//!
//! This intentionally does not use a container engine. mise unpacks the pulled
//! base image into a temporary rootfs, asks the matching host package manager
//! to install into that rootfs, then emits the filesystem changes as one OCI
//! layer.

use std::collections::BTreeMap;
use std::fs;
use std::io::{BufRead, BufReader, Read};
#[cfg(unix)]
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::{Component, Path, PathBuf};
use std::process::Command;

use eyre::{Context, Result, bail};
use flate2::read::GzDecoder;
use jdx_tar::{Archive, EntryUnpacker, UnpackOptions};
use tempfile::TempDir;
use walkdir::WalkDir;

use crate::file;
use crate::oci::layer::{self, LayerBlob};
use crate::oci::layout::ImageLayout;
use crate::oci::manifest::Descriptor;
use crate::system::ManagerPackages;
use crate::system::packages::PackageRequest;

#[derive(Debug, Clone)]
struct FsEntry {
    kind: FsEntryKind,
    mode: u32,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum FsEntryKind {
    Dir,
    File { size: u64, hash: [u8; 32] },
    Symlink { target: PathBuf },
    Other,
}

pub struct SystemPackagesLayer {
    pub blob: LayerBlob,
    pub manager: &'static str,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OciPackageManager {
    Apk,
    Apt,
}

impl OciPackageManager {
    fn name(self) -> &'static str {
        match self {
            Self::Apk => "apk",
            Self::Apt => "apt",
        }
    }
}

pub fn build_system_packages_layer(
    layout: &ImageLayout,
    base_layers: &[Descriptor],
    managers: &[ManagerPackages],
    architecture: &str,
) -> Result<Option<SystemPackagesLayer>> {
    let Some((manager, requests)) = collect_package_requests(managers)? else {
        return Ok(None);
    };
    if base_layers.is_empty() {
        bail!(
            "mise oci requires an {}-based base image when [bootstrap.packages] contains {} \
             entries; `scratch` has no {} metadata",
            manager.name(),
            manager.name(),
            manager.name(),
        );
    }
    validate_host_requirements(manager)?;

    let td = TempDir::with_prefix(format!("mise-oci-{}-rootfs-", manager.name())).wrap_err_with(
        || {
            format!(
                "creating temp rootfs for {} system packages",
                manager.name()
            )
        },
    )?;
    let rootfs = td.path().join("rootfs");
    file::create_dir_all(&rootfs)?;
    unpack_base_layers(layout, base_layers, &rootfs)?;

    let before = prepare_and_snapshot(manager, &rootfs)?;
    match manager {
        OciPackageManager::Apk => {
            apk_install_into_rootfs(&rootfs, &requests, architecture)?;
            clean_apk_transients(&rootfs)?;
        }
        OciPackageManager::Apt => {
            apt_install_into_rootfs(&rootfs, &requests, architecture)?;
            clean_apt_transients(&rootfs)?;
        }
    }
    let diff_dir = td.path().join("diff");
    materialize_diff(&rootfs, &before, &diff_dir)?;
    if WalkDir::new(&diff_dir).into_iter().count() <= 1 {
        info!(
            "oci: {} system packages produced no filesystem changes",
            manager.name()
        );
        return Ok(None);
    }
    info!(
        "oci: adding {} system packages: {}",
        manager.name(),
        requests
            .iter()
            .map(ToString::to_string)
            .collect::<Vec<_>>()
            .join(", ")
    );
    Ok(Some(SystemPackagesLayer {
        blob: layer::build_layer_from_dir_preserve_metadata(&diff_dir, "")?,
        manager: manager.name(),
    }))
}

fn validate_host_requirements(manager: OciPackageManager) -> Result<()> {
    match manager {
        OciPackageManager::Apk => {
            if std::env::consts::OS != "linux" {
                bail!("mise oci needs a Linux host to install apk system packages into an image");
            }
            if file::which("apk").is_none() {
                bail!("mise oci needs `apk` on PATH to install apk system packages into the image");
            }
            if !crate::system::sudo::is_root() {
                bail!(
                    "mise oci needs to run as root to install apk system packages because apk \
                     executes package scripts in a chroot"
                );
            }
        }
        OciPackageManager::Apt => {
            if file::which("apt-get").is_none() {
                bail!(
                    "mise oci needs `apt-get` on PATH to install apt system packages into the image"
                );
            }
            if file::which("dpkg").is_none() {
                bail!(
                    "mise oci needs `dpkg` on PATH to install apt system packages into the image"
                );
            }
        }
    }
    Ok(())
}

fn prepare_and_snapshot(
    manager: OciPackageManager,
    rootfs: &Path,
) -> Result<BTreeMap<PathBuf, FsEntry>> {
    match manager {
        OciPackageManager::Apk => prepare_apk_install(rootfs)?,
        OciPackageManager::Apt => prepare_apt_install(rootfs)?,
    }
    snapshot(rootfs)
}

fn collect_package_requests(
    managers: &[ManagerPackages],
) -> Result<Option<(OciPackageManager, Vec<PackageRequest>)>> {
    let mut apk = vec![];
    let mut apt = vec![];
    let mut unsupported = vec![];
    for mgr in managers {
        if mgr.disabled || mgr.requests.is_empty() {
            continue;
        }
        match mgr.manager.name() {
            "apk" => apk.extend(mgr.requests.clone()),
            "apt" => apt.extend(mgr.requests.clone()),
            other => unsupported.push(other.to_string()),
        }
    }
    if !unsupported.is_empty() {
        unsupported.sort();
        unsupported.dedup();
        bail!(
            "mise oci currently supports only apt and apk entries in [bootstrap.packages]; \
             unsupported manager(s): {}",
            unsupported.join(", ")
        );
    }
    if !apk.is_empty() && !apt.is_empty() {
        bail!(
            "mise oci cannot mix apk and apt entries in [bootstrap.packages]; use the package \
             manager matching the selected base image"
        );
    }
    if !apk.is_empty() {
        Ok(Some((OciPackageManager::Apk, apk)))
    } else if !apt.is_empty() {
        Ok(Some((OciPackageManager::Apt, apt)))
    } else {
        Ok(None)
    }
}

fn unpack_base_layers(layout: &ImageLayout, layers: &[Descriptor], rootfs: &Path) -> Result<()> {
    for layer in layers {
        let path = layout.blob_path(&layer.digest);
        let reader = open_layer_reader(layer, &path)?;
        let mut archive = Archive::new(reader);
        let mut options = UnpackOptions::default();
        let mut unpacker = EntryUnpacker::new(rootfs, &mut options)
            .wrap_err_with(|| format!("preparing base layer extraction {}", layer.digest))?;
        for entry in archive
            .entries()
            .wrap_err_with(|| format!("reading base layer {}", layer.digest))?
        {
            let mut entry =
                entry.wrap_err_with(|| format!("reading base layer entry {}", layer.digest))?;
            let rel = clean_layer_path(&entry.path()?)
                .wrap_err_with(|| format!("reading base layer entry path {}", layer.digest))?;
            if rel.as_os_str().is_empty() || apply_oci_whiteout(rootfs, &rel)? {
                continue;
            }
            unpacker
                .unpack(&mut entry)
                .wrap_err_with(|| format!("unpacking base layer {}", layer.digest))?;
        }
        unpacker
            .finish()
            .wrap_err_with(|| format!("finishing base layer extraction {}", layer.digest))?;
    }
    Ok(())
}

fn open_layer_reader(layer: &Descriptor, path: &Path) -> Result<Box<dyn Read>> {
    let file =
        fs::File::open(path).wrap_err_with(|| format!("opening base layer {}", path.display()))?;
    let mut reader = BufReader::new(file);
    Ok(match layer_compression(layer, &mut reader)? {
        LayerCompression::Gzip => Box::new(GzDecoder::new(reader)),
        LayerCompression::Zstd => Box::new(zstd::stream::read::Decoder::new(reader)?),
        LayerCompression::Tar => Box::new(reader),
    })
}

enum LayerCompression {
    Gzip,
    Zstd,
    Tar,
}

fn layer_compression<R: BufRead>(layer: &Descriptor, reader: &mut R) -> Result<LayerCompression> {
    if layer.media_type.ends_with("+gzip")
        || layer.media_type.ends_with(".gzip")
        || layer.media_type.ends_with(".tar.gzip")
    {
        return Ok(LayerCompression::Gzip);
    }
    if layer.media_type.ends_with("+zstd") || layer.media_type.ends_with(".zstd") {
        return Ok(LayerCompression::Zstd);
    }
    if layer.media_type.ends_with(".tar") || layer.media_type.ends_with("+tar") {
        return Ok(LayerCompression::Tar);
    }

    let magic = reader.fill_buf()?;
    if magic.starts_with(&[0x1f, 0x8b]) {
        Ok(LayerCompression::Gzip)
    } else if magic.starts_with(&[0x28, 0xb5, 0x2f, 0xfd]) {
        Ok(LayerCompression::Zstd)
    } else {
        Ok(LayerCompression::Tar)
    }
}

fn clean_layer_path(path: &Path) -> Result<PathBuf> {
    let mut out = PathBuf::new();
    for component in path.components() {
        match component {
            Component::CurDir | Component::RootDir => {}
            Component::Normal(part) => out.push(part),
            Component::ParentDir | Component::Prefix(_) => {
                bail!("invalid OCI layer path {}", path.display())
            }
        }
    }
    Ok(out)
}

fn apply_oci_whiteout(rootfs: &Path, rel: &Path) -> Result<bool> {
    let Some(name) = rel.file_name().and_then(|n| n.to_str()) else {
        return Ok(false);
    };
    let parent = rel.parent().unwrap_or_else(|| Path::new(""));
    if name == ".wh..wh..opq" {
        let dir = rootfs.join(parent);
        if dir.is_dir() {
            for entry in fs::read_dir(&dir)? {
                remove_path(&entry?.path())?;
            }
        }
        return Ok(true);
    }
    let Some(target) = name.strip_prefix(".wh.") else {
        return Ok(false);
    };
    remove_path(&rootfs.join(parent).join(target))?;
    Ok(true)
}

fn remove_path(path: &Path) -> Result<()> {
    match fs::symlink_metadata(path).map(|m| m.file_type()) {
        Ok(ft) if ft.is_dir() && !ft.is_symlink() => fs::remove_dir_all(path)
            .wrap_err_with(|| format!("removing directory {}", path.display()))?,
        Ok(_) => fs::remove_file(path).wrap_err_with(|| format!("removing {}", path.display()))?,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
        Err(e) => {
            return Err(e).wrap_err_with(|| format!("reading metadata for {}", path.display()));
        }
    }
    Ok(())
}

fn prepare_apt_install(rootfs: &Path) -> Result<()> {
    if !rootfs.join("etc/apt").is_dir() {
        bail!(
            "mise oci found apt packages in [bootstrap.packages], but the base image does not \
             contain /etc/apt. Use a Debian/Ubuntu base image or remove the apt entries."
        );
    }
    prepare_apt_rootfs(rootfs)
}

fn prepare_apk_install(rootfs: &Path) -> Result<()> {
    if !rootfs.join("etc/apk").is_dir() || !rootfs.join("lib/apk/db").is_dir() {
        bail!(
            "mise oci found apk packages in [bootstrap.packages], but the base image does not \
             contain an apk database. Use an Alpine/Wolfi base image or remove the apk entries."
        );
    }
    Ok(())
}

fn apk_install_into_rootfs(
    rootfs: &Path,
    requests: &[PackageRequest],
    architecture: &str,
) -> Result<()> {
    let mut args = vec![
        "--root".to_string(),
        rootfs.display().to_string(),
        "--arch".to_string(),
        apk_architecture(architecture)?.to_string(),
        "--no-cache".to_string(),
        "add".to_string(),
        "--".to_string(),
    ];
    args.extend(requests.iter().map(|request| match &request.version {
        Some(version) => format!("{}={version}", request.name),
        None => request.name.clone(),
    }));
    info!("apk {}", args.join(" "));
    let output = Command::new("apk")
        .args(&args)
        .output()
        .wrap_err("running apk for OCI system packages")?;
    if !output.status.success() {
        bail!(
            "apk failed while installing OCI system packages: {}\n{}",
            output.status,
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    Ok(())
}

fn apt_install_into_rootfs(
    rootfs: &Path,
    requests: &[PackageRequest],
    architecture: &str,
) -> Result<()> {
    let status = rootfs.join("var/lib/dpkg/status");
    if let Some(parent) = status.parent() {
        file::create_dir_all(parent)?;
    }
    if !status.exists() {
        file::write(&status, "")?;
    }
    file::create_dir_all(rootfs.join("var/cache/apt/archives/partial"))?;
    file::create_dir_all(rootfs.join("var/lib/apt/lists/partial"))?;

    let update_args = apt_root_args(rootfs, &status, architecture)?
        .into_iter()
        .chain(["update".to_string()])
        .collect();
    run_apt_get(update_args)?;

    let install_args = vec![
        "install".to_string(),
        "-y".to_string(),
        "--no-install-recommends".to_string(),
        "--".to_string(),
    ];
    let package_args = requests.iter().map(|p| match &p.version {
        Some(v) => format!("{}={v}", p.name),
        None => p.name.clone(),
    });
    let args = apt_root_args(rootfs, &status, architecture)?
        .into_iter()
        .chain(install_args)
        .chain(package_args)
        .collect();
    run_apt_get(args)
}

fn prepare_apt_rootfs(rootfs: &Path) -> Result<()> {
    let apt_dir = rootfs.join("etc/apt");
    for entry in WalkDir::new(&apt_dir)
        .follow_links(false)
        .sort_by_file_name()
    {
        let entry = entry?;
        if !entry.file_type().is_file() {
            continue;
        }
        let path = entry.path();
        let bytes = file::read(path)?;
        let Ok(mut s) = String::from_utf8(bytes) else {
            continue;
        };
        let original = s.clone();
        let root = rootfs.to_string_lossy();
        for p in [
            "/usr/share/keyrings/",
            "/etc/apt/",
            "/var/cache/apt",
            "/var/lib/apt",
            "/var/log/apt",
        ] {
            s = s.replace(p, &format!("{root}{p}"));
        }
        if s != original {
            file::write(path, s)?;
        }
    }
    Ok(())
}

fn apt_root_args(rootfs: &Path, status: &Path, architecture: &str) -> Result<Vec<String>> {
    Ok(vec![
        "-o".to_string(),
        format!("Dir={}", rootfs.display()),
        "-o".to_string(),
        "Dir::Etc=etc/apt".to_string(),
        "-o".to_string(),
        "Dir::Etc::sourcelist=sources.list".to_string(),
        "-o".to_string(),
        "Dir::Etc::sourceparts=sources.list.d".to_string(),
        "-o".to_string(),
        "Dir::Etc::trusted=trusted.gpg".to_string(),
        "-o".to_string(),
        "Dir::Etc::trustedparts=trusted.gpg.d".to_string(),
        "-o".to_string(),
        "Dir::State=var/lib/apt".to_string(),
        "-o".to_string(),
        format!("Dir::State::status={}", status.display()),
        "-o".to_string(),
        "Dir::Cache=var/cache/apt".to_string(),
        "-o".to_string(),
        "Dir::Log=var/log/apt".to_string(),
        "-o".to_string(),
        "APT::Sandbox::User=root".to_string(),
        "-o".to_string(),
        format!("APT::Architecture={}", apt_architecture(architecture)?),
        "-o".to_string(),
        format!("DPkg::Options::=--root={}", rootfs.display()),
        "-o".to_string(),
        "DPkg::Options::=--force-not-root".to_string(),
    ])
}

fn run_apt_get(args: Vec<String>) -> Result<()> {
    info!("apt-get {}", args.join(" "));
    let output = Command::new("apt-get")
        .args(&args)
        .env("DEBIAN_FRONTEND", "noninteractive")
        .output()
        .wrap_err("running apt-get for OCI system packages")?;
    if !output.status.success() {
        bail!(
            "apt-get failed while installing OCI system packages: {}\n{}",
            output.status,
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    Ok(())
}

/// Normalize the non-reproducible state that apt/dpkg postinstall scripts
/// leave behind, so re-running `mise oci build` with identical inputs yields a
/// byte-identical package layer (the reproducibility guarantee mise documents).
/// Everything cleared here is a regenerable cache, a timestamped log, or a
/// first-boot sentinel, never real package content.
fn clean_apt_transients(rootfs: &Path) -> Result<()> {
    // apt's own download cache and package lists.
    remove_dir_children(&rootfs.join("var/cache/apt/archives"))?;
    remove_path(&rootfs.join("var/cache/apt/pkgcache.bin"))?;
    remove_path(&rootfs.join("var/cache/apt/srcpkgcache.bin"))?;
    remove_dir_children(&rootfs.join("var/lib/apt/lists"))?;

    // ldconfig's aux-cache is a binary cache that embeds mtimes; it is rebuilt
    // automatically at runtime.
    remove_dir_children(&rootfs.join("var/cache/ldconfig"))?;

    // Every file under /var/log is a timestamped, non-deterministic log
    // (dpkg.log, alternatives.log, fontconfig.log, apt/*, etc.) and none of it is
    // package content. Clearing the whole tree rather than enumerating known
    // files also covers logs that future postinstall scripts invent.
    remove_files_recursively(&rootfs.join("var/log"))?;

    // machine-id is written by systemd's postinstall. It must remain present
    // but empty (an empty machine-id is systemd's "first boot" marker), so
    // truncate rather than remove. The dbus copy is usually a symlink to
    // /etc/machine-id, which truncate_file_if_regular leaves alone.
    truncate_file_if_regular(&rootfs.join("etc/machine-id"))?;
    truncate_file_if_regular(&rootfs.join("var/lib/dbus/machine-id"))?;
    Ok(())
}

fn clean_apk_transients(rootfs: &Path) -> Result<()> {
    remove_dir_children(&rootfs.join("var/cache/apk"))?;
    remove_path(&rootfs.join("var/log/apk.log"))
}

/// Remove every regular file under `dir` (recursively), leaving the directory
/// structure and any symlinks intact. A missing `dir` is a no-op.
fn remove_files_recursively(dir: &Path) -> Result<()> {
    if !dir.is_dir() {
        return Ok(());
    }
    for entry in WalkDir::new(dir).follow_links(false) {
        let entry = entry?;
        if entry.file_type().is_file() {
            remove_path(entry.path())?;
        }
    }
    Ok(())
}

/// Truncate `path` to zero length when it is a regular file. Used for first-boot
/// sentinels (machine-id) that must stay present but empty. Symlinks and missing
/// files are left untouched.
fn truncate_file_if_regular(path: &Path) -> Result<()> {
    match fs::symlink_metadata(path) {
        Ok(md) if md.file_type().is_file() => {
            file::write(path, "").wrap_err_with(|| format!("truncating {}", path.display()))?;
        }
        Ok(_) => {}
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
        Err(e) => {
            return Err(e).wrap_err_with(|| format!("reading metadata for {}", path.display()));
        }
    }
    Ok(())
}

fn remove_dir_children(path: &Path) -> Result<()> {
    match fs::read_dir(path) {
        Ok(entries) => {
            for entry in entries {
                remove_path(&entry?.path())?;
            }
            Ok(())
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(e).wrap_err_with(|| format!("reading directory {}", path.display())),
    }
}

fn apt_architecture(oci_arch: &str) -> Result<&'static str> {
    match oci_arch {
        "amd64" | "x86_64" => Ok("amd64"),
        "arm64" | "aarch64" => Ok("arm64"),
        other => bail!("apt system packages are not supported for OCI architecture {other:?}"),
    }
}

fn apk_architecture(oci_arch: &str) -> Result<&'static str> {
    match oci_arch {
        "amd64" | "x86_64" => Ok("x86_64"),
        "arm64" | "aarch64" => Ok("aarch64"),
        other => bail!("apk system packages are not supported for OCI architecture {other:?}"),
    }
}

fn snapshot(root: &Path) -> Result<BTreeMap<PathBuf, FsEntry>> {
    let mut out = BTreeMap::new();
    for entry in WalkDir::new(root).follow_links(false).sort_by_file_name() {
        let entry = entry?;
        let rel = entry.path().strip_prefix(root)?;
        if rel.as_os_str().is_empty() {
            continue;
        }
        out.insert(rel.to_path_buf(), fs_entry(entry.path())?);
    }
    Ok(out)
}

fn fs_entry(path: &Path) -> Result<FsEntry> {
    let md = fs::symlink_metadata(path)?;
    let mode = mode(&md);
    let ft = md.file_type();
    let kind = if ft.is_dir() {
        FsEntryKind::Dir
    } else if ft.is_symlink() {
        FsEntryKind::Symlink {
            target: fs::read_link(path)?,
        }
    } else if ft.is_file() {
        FsEntryKind::File {
            size: md.len(),
            hash: file_hash(path)?,
        }
    } else {
        FsEntryKind::Other
    };
    Ok(FsEntry { kind, mode })
}

#[cfg(unix)]
fn mode(md: &fs::Metadata) -> u32 {
    md.permissions().mode() & 0o7777
}

#[cfg(not(unix))]
fn mode(_md: &fs::Metadata) -> u32 {
    0o644
}

fn file_hash(path: &Path) -> Result<[u8; 32]> {
    use sha2::{Digest, Sha256};
    let mut f = fs::File::open(path)?;
    let mut h = Sha256::new();
    let mut buf = [0u8; 64 * 1024];
    loop {
        let n = f.read(&mut buf)?;
        if n == 0 {
            break;
        }
        h.update(&buf[..n]);
    }
    Ok(h.finalize().into())
}

fn materialize_diff(
    rootfs: &Path,
    before: &BTreeMap<PathBuf, FsEntry>,
    diff_dir: &Path,
) -> Result<()> {
    file::create_dir_all(diff_dir)?;
    let after = snapshot(rootfs)?;

    let mut whiteouted_dirs: Vec<PathBuf> = vec![];
    for (rel, old) in before {
        if after.contains_key(rel) {
            continue;
        }
        if whiteouted_dirs.iter().any(|dir| rel.starts_with(dir)) {
            continue;
        }
        write_whiteout(diff_dir, rel)?;
        if matches!(old.kind, FsEntryKind::Dir) {
            whiteouted_dirs.push(rel.clone());
        }
    }

    for (rel, entry) in after {
        if let Some(old) = before.get(&rel) {
            if same_entry(old, &entry) {
                continue;
            }
            if !same_entry_type(old, &entry) {
                write_whiteout(diff_dir, &rel)?;
            }
        }
        copy_entry(rootfs, diff_dir, &rel, &entry)?;
    }
    Ok(())
}

fn same_entry(a: &FsEntry, b: &FsEntry) -> bool {
    a.mode == b.mode && a.kind == b.kind
}

fn same_entry_type(a: &FsEntry, b: &FsEntry) -> bool {
    matches!(
        (&a.kind, &b.kind),
        (FsEntryKind::Dir, FsEntryKind::Dir)
            | (FsEntryKind::File { .. }, FsEntryKind::File { .. })
            | (FsEntryKind::Symlink { .. }, FsEntryKind::Symlink { .. })
            | (FsEntryKind::Other, FsEntryKind::Other)
    )
}

fn write_whiteout(diff_dir: &Path, rel: &Path) -> Result<()> {
    let Some(name) = rel.file_name() else {
        return Ok(());
    };
    let parent = rel.parent().unwrap_or_else(|| Path::new(""));
    let whiteout = diff_dir
        .join(parent)
        .join(format!(".wh.{}", name.to_string_lossy()));
    if let Some(parent) = whiteout.parent() {
        file::create_dir_all(parent)?;
    }
    file::write(whiteout, "")?;
    Ok(())
}

fn copy_entry(rootfs: &Path, diff_dir: &Path, rel: &Path, entry: &FsEntry) -> Result<()> {
    let src = rootfs.join(rel);
    let dst = diff_dir.join(rel);
    if let Some(parent) = dst.parent() {
        file::create_dir_all(parent)?;
    }
    match &entry.kind {
        FsEntryKind::Dir => {
            file::create_dir_all(&dst)?;
            set_mode(&dst, entry.mode)?;
        }
        FsEntryKind::File { .. } => {
            file::copy(&src, &dst)?;
            set_mode(&dst, entry.mode)?;
        }
        FsEntryKind::Symlink { target } => {
            #[cfg(unix)]
            symlink(target, &dst)?;
            #[cfg(not(unix))]
            {
                let _ = target;
                bail!("OCI system package layers with symlinks require a unix host");
            }
        }
        FsEntryKind::Other => {
            warn!(
                "oci: skipping unsupported filesystem entry {}",
                src.display()
            );
        }
    }
    Ok(())
}

fn set_mode(path: &Path, mode: u32) -> Result<()> {
    #[cfg(unix)]
    fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
    #[cfg(not(unix))]
    {
        let _ = (path, mode);
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use super::*;
    use crate::system::packages::{
        SystemPackageManager, apk::ApkManager, apt::AptManager, dnf::DnfManager,
    };

    fn request(name: &str, version: Option<&str>) -> PackageRequest {
        PackageRequest {
            name: name.to_string(),
            version: version.map(str::to_string),
            tap_url: None,
        }
    }

    fn manager_packages(
        manager: Arc<dyn SystemPackageManager>,
        requests: Vec<PackageRequest>,
    ) -> ManagerPackages {
        ManagerPackages {
            manager,
            requests,
            disabled: false,
        }
    }

    fn write_file(path: &Path, contents: &str) {
        file::create_dir_all(path.parent().unwrap()).unwrap();
        file::write(path, contents).unwrap();
    }

    #[test]
    fn collect_package_requests_accepts_apk() {
        let managers = [manager_packages(
            Arc::new(ApkManager::new()),
            vec![request("jq", Some("1.8.2-r1"))],
        )];
        let (manager, requests) = collect_package_requests(&managers).unwrap().unwrap();
        assert_eq!(manager, OciPackageManager::Apk);
        assert_eq!(requests, vec![request("jq", Some("1.8.2-r1"))]);
    }

    #[test]
    fn collect_package_requests_accepts_apt() {
        let managers = [manager_packages(
            Arc::new(AptManager::new()),
            vec![request("curl", None)],
        )];
        let (manager, requests) = collect_package_requests(&managers).unwrap().unwrap();
        assert_eq!(manager, OciPackageManager::Apt);
        assert_eq!(requests, vec![request("curl", None)]);
    }

    #[test]
    fn collect_package_requests_rejects_mixed_managers() {
        let managers = [
            manager_packages(Arc::new(ApkManager::new()), vec![request("jq", None)]),
            manager_packages(Arc::new(AptManager::new()), vec![request("curl", None)]),
        ];
        let err = collect_package_requests(&managers).unwrap_err();
        assert!(err.to_string().contains("cannot mix apk and apt"));
    }

    #[test]
    fn collect_package_requests_rejects_unsupported_managers() {
        let managers = [manager_packages(
            Arc::new(DnfManager::new()),
            vec![request("jq", None)],
        )];
        let err = collect_package_requests(&managers).unwrap_err();
        assert!(err.to_string().contains("unsupported manager(s): dnf"));
    }

    #[test]
    fn apk_architecture_uses_apk_names() {
        assert_eq!(apk_architecture("amd64").unwrap(), "x86_64");
        assert_eq!(apk_architecture("arm64").unwrap(), "aarch64");
        assert!(apk_architecture("riscv64").is_err());
    }

    #[test]
    fn clean_apk_transients_removes_cache_and_log() {
        let td = TempDir::with_prefix("mise-oci-apk-clean-test-").unwrap();
        let rootfs = td.path().join("rootfs");
        write_file(&rootfs.join("var/cache/apk/APKINDEX.tar.gz"), "index");
        write_file(&rootfs.join("var/log/apk.log"), "install jq\n");
        write_file(&rootfs.join("usr/bin/jq"), "ELF-payload");

        clean_apk_transients(&rootfs).unwrap();

        assert!(!rootfs.join("var/cache/apk/APKINDEX.tar.gz").exists());
        assert!(!rootfs.join("var/log/apk.log").exists());
        assert_eq!(
            fs::read_to_string(rootfs.join("usr/bin/jq")).unwrap(),
            "ELF-payload"
        );
    }

    #[test]
    fn apt_preparation_is_excluded_from_package_diff() {
        let td = TempDir::with_prefix("mise-oci-apt-snapshot-test-").unwrap();
        let rootfs = td.path().join("rootfs");
        let sources = rootfs.join("etc/apt/sources.list");
        write_file(
            &sources,
            "deb [signed-by=/usr/share/keyrings/test.gpg] https://example.invalid stable main\n",
        );

        let before = prepare_and_snapshot(OciPackageManager::Apt, &rootfs).unwrap();
        assert!(
            fs::read_to_string(&sources)
                .unwrap()
                .contains(&rootfs.display().to_string())
        );

        write_file(&rootfs.join("usr/bin/example"), "package-payload");
        let diff = td.path().join("diff");
        materialize_diff(&rootfs, &before, &diff).unwrap();

        assert!(diff.join("usr/bin/example").is_file());
        assert!(!diff.join("etc/apt/sources.list").exists());
    }

    #[test]
    fn clean_apt_transients_normalizes_nondeterministic_state() {
        let td = TempDir::with_prefix("mise-oci-clean-test-").unwrap();
        let rootfs = td.path().join("rootfs");

        // Timestamped logs at various depths under /var/log.
        let logs = [
            "var/log/dpkg.log",
            "var/log/alternatives.log",
            "var/log/fontconfig.log",
            "var/log/apt/history.log",
            "var/log/apt/term.log",
        ];
        for rel in logs {
            write_file(&rootfs.join(rel), "2026-07-02 12:00:00 install foo\n");
        }

        // Regenerable ldconfig cache.
        let aux_cache = rootfs.join("var/cache/ldconfig/aux-cache");
        write_file(&aux_cache, "binary-cache-with-mtimes");

        // machine-id sentinel: a regular file that must be truncated, not removed.
        let etc_machine_id = rootfs.join("etc/machine-id");
        write_file(&etc_machine_id, "deadbeefdeadbeefdeadbeefdeadbeef\n");

        // Real package content that must survive untouched, including a file
        // that lives outside the transient trees.
        let payload = rootfs.join("usr/bin/curl");
        write_file(&payload, "ELF-payload");

        clean_apt_transients(&rootfs).unwrap();

        // All logs removed, directory structure preserved.
        for rel in logs {
            assert!(!rootfs.join(rel).exists(), "{rel} should be removed");
        }
        assert!(rootfs.join("var/log").is_dir());
        assert!(rootfs.join("var/log/apt").is_dir());

        // ldconfig cache cleared, containing dir kept.
        assert!(!aux_cache.exists());
        assert!(rootfs.join("var/cache/ldconfig").is_dir());

        // machine-id truncated to empty but still present.
        assert!(etc_machine_id.is_file());
        assert_eq!(fs::read_to_string(&etc_machine_id).unwrap(), "");

        // Payload untouched.
        assert_eq!(fs::read_to_string(&payload).unwrap(), "ELF-payload");
    }

    #[test]
    fn clean_apt_transients_is_a_noop_on_a_bare_rootfs() {
        let td = TempDir::with_prefix("mise-oci-clean-empty-").unwrap();
        let rootfs = td.path().join("rootfs");
        file::create_dir_all(&rootfs).unwrap();
        // No transient paths exist; every helper must tolerate the missing dirs.
        clean_apt_transients(&rootfs).unwrap();
    }

    #[cfg(unix)]
    #[test]
    fn clean_apt_transients_leaves_machine_id_symlink_untouched() {
        let td = TempDir::with_prefix("mise-oci-clean-symlink-").unwrap();
        let rootfs = td.path().join("rootfs");
        write_file(&rootfs.join("etc/machine-id"), "abc\n");
        file::create_dir_all(rootfs.join("var/lib/dbus")).unwrap();
        // dbus's machine-id is conventionally a symlink to /etc/machine-id;
        // truncating through it would clobber the real file, so it's skipped.
        let dbus = rootfs.join("var/lib/dbus/machine-id");
        symlink("/etc/machine-id", &dbus).unwrap();

        clean_apt_transients(&rootfs).unwrap();

        let md = fs::symlink_metadata(&dbus).unwrap();
        assert!(
            md.file_type().is_symlink(),
            "dbus machine-id symlink must be preserved"
        );
        assert_eq!(fs::read_link(&dbus).unwrap(), Path::new("/etc/machine-id"));
    }
}
