//! AIRGAP1 — pack (build side).
//!
//! Design (`.nornir/airgap-install.md` §2 Phase 0): collect each product's
//! artifacts + *all* target-identical deps into a versioned **staging tree**,
//! then [`crate::archive`] turns that tree into the bundle. The `VERSION` file
//! lives **outside** the bundle for easy bumping (the `dist` pattern). When a
//! component's deps must be 100% target-alike, it is built in a per-target
//! container — that capture step is a seam ([`ContainerBuild`]) since it needs
//! Docker/podman, which is not available (and not wanted) at airgap-install time.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

/// One bundled product's contents + provenance (what was packed, from where, how).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PackManifest {
    pub product: String,
    /// Airgap generation, date-stamped (e.g. "20250505").
    pub version: String,
    /// Whether deps were captured via a per-target container (100% target-alike).
    pub built_in_container: bool,
    /// Relative artifact paths inside the staging tree.
    pub artifacts: Vec<String>,
}

/// A staging tree being assembled on the build host — the input to
/// [`crate::archive::Archive::pack`].
#[derive(Debug, Clone)]
pub struct Staging {
    pub root: PathBuf,
    pub version: String,
}

impl Staging {
    /// Create a fresh, empty staging tree at `root` with the given version stamp.
    pub fn new(root: impl Into<PathBuf>, version: impl Into<String>) -> Result<Self> {
        let root = root.into();
        std::fs::create_dir_all(&root)
            .with_context(|| format!("create staging root {}", root.display()))?;
        Ok(Staging {
            root,
            version: version.into(),
        })
    }

    /// Add a product artifact (a real file) under `rel` inside the staging tree
    /// (e.g. `linux-x86_64/holger`). Copies the bytes; returns the relative path.
    pub fn add_artifact(&self, src: &Path, rel: &str) -> Result<String> {
        let dest = self.root.join(rel);
        if let Some(parent) = dest.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("create {}", parent.display()))?;
        }
        std::fs::copy(src, &dest)
            .with_context(|| format!("copy {} → {}", src.display(), dest.display()))?;
        Ok(rel.to_string())
    }

    /// Write a product's [`ComponentSpec`] manifest into the staging tree under
    /// `manifests/<name>.json` (so the installer reads it back from the bundle).
    pub fn add_component_spec(&self, spec: &crate::product::ComponentSpec) -> Result<String> {
        let rel = format!("manifests/{}.json", spec.name);
        let dest = self.root.join(&rel);
        std::fs::create_dir_all(dest.parent().unwrap()).ok();
        std::fs::write(&dest, serde_json::to_vec_pretty(spec)?)
            .with_context(|| format!("write {}", dest.display()))?;
        Ok(rel)
    }

    /// Enumerate every file in the staging tree (relative paths).
    pub fn artifacts(&self) -> Result<Vec<String>> {
        let mut out = Vec::new();
        walk(&self.root, &self.root, &mut out)?;
        out.sort();
        Ok(out)
    }
}

/// The VERSION file outside the bundle — the easy-bump pattern from `dist`. We
/// write it NEXT TO the bundle, not inside it, so bumping the generation is a
/// one-line edit that doesn't require repacking.
pub fn write_version_file(beside_bundle: &Path, version: &str) -> Result<PathBuf> {
    let dir = beside_bundle.parent().unwrap_or_else(|| Path::new("."));
    std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
    let p = dir.join("VERSION");
    std::fs::write(&p, format!("{version}\n")).with_context(|| format!("write {}", p.display()))?;
    Ok(p)
}

/// Pack `product` into a [`PackManifest`] by enumerating the assembled staging
/// tree. (The actual bundle bytes are produced by [`crate::archive`]; this
/// records provenance.)
pub fn pack(staging: &Staging, product: &str, built_in_container: bool) -> Result<PackManifest> {
    Ok(PackManifest {
        product: product.to_string(),
        version: staging.version.clone(),
        built_in_container,
        artifacts: staging.artifacts()?,
    })
}

/// Per-target container build for 100%-target-alike dep capture (the `dist`
/// reason a build sometimes runs in a container). It needs a container runtime,
/// which is a build-host concern, never an airgap-target one.
pub trait ContainerBuild {
    /// Build `product` for `target_triple` inside a container and copy the
    /// resulting artifacts into `into` (a staging subdir).
    fn build_into(&self, product: &str, target_triple: &str, into: &Path) -> Result<Vec<String>>;
}

/// A real, target-identical dep-capture build driven by **podman** via
/// `std::process` (no shell script — the LAW: containers controlled in Rust).
///
/// `dist`'s reason for building in a container is that a component's runtime deps
/// must be **100% target-alike**: the base image *is* the target's userland, so
/// whatever the build links/copies inside the container is exactly what the
/// target has. This impl renders a Containerfile, `podman build`s it to capture
/// the product's artifacts + deps into `/out`, then `podman create` + `podman cp`
/// extracts `/out` into the staging subdir. Honest if podman is absent (a clear
/// error), but every invocation is a real subprocess.
#[derive(Debug, Clone)]
pub struct PodmanBuild {
    /// Base image whose userland equals the target's (e.g.
    /// `registry.access.redhat.com/ubi9/ubi-minimal` for a RHEL9 target). This is
    /// what makes the captured deps target-identical.
    pub base_image: String,
    /// Build steps that fetch/produce the product + its deps into `/out` inside
    /// the container (the `dist` "wget-in-container" dep capture). Each line is a
    /// `RUN <step>` in the Containerfile. Rendered, never shelled out by us.
    pub capture_steps: Vec<String>,
    /// The `podman` executable (overridable for `docker`-compatible runtimes).
    pub podman: String,
}

impl PodmanBuild {
    /// A per-target build that captures deps into `/out` on `base_image`.
    pub fn new(base_image: impl Into<String>) -> Self {
        PodmanBuild {
            base_image: base_image.into(),
            capture_steps: Vec::new(),
            podman: "podman".to_string(),
        }
    }

    /// Add a dep-capture step (rendered as one `RUN` line). E.g.
    /// `.step("dnf install -y openssl-libs && cp /usr/bin/holger /out/")`.
    pub fn step(mut self, run: impl Into<String>) -> Self {
        self.capture_steps.push(run.into());
        self
    }

    /// Render the Containerfile this build runs — a real artifact (testable
    /// without a container runtime), so the build invocation is honest and
    /// inspectable. `target_triple` is exported so capture steps can branch on it.
    pub fn containerfile(&self, product: &str, target_triple: &str) -> String {
        let mut s = String::new();
        s.push_str(&format!("FROM {}\n", self.base_image));
        s.push_str(&format!("ARG PRODUCT={product}\n"));
        s.push_str(&format!("ARG TARGET={target_triple}\n"));
        s.push_str("RUN mkdir -p /out\n");
        for step in &self.capture_steps {
            s.push_str(&format!("RUN {step}\n"));
        }
        s
    }

    /// Whether the configured podman runtime is present (`podman --version`).
    pub fn runtime_available(&self) -> bool {
        std::process::Command::new(&self.podman)
            .arg("--version")
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
    }
}

impl ContainerBuild for PodmanBuild {
    fn build_into(&self, product: &str, target_triple: &str, into: &Path) -> Result<Vec<String>> {
        use std::process::Command;
        if !self.runtime_available() {
            anyhow::bail!(
                "container dep-capture for `{product}` ({target_triple}) needs `{}` on PATH \
                 — install podman (a build-host concern; never run at airgap-install time)",
                self.podman
            );
        }
        std::fs::create_dir_all(into).with_context(|| format!("create {}", into.display()))?;

        // Render the Containerfile into a build context dir.
        let ctx = tempfile::tempdir().context("create build context")?;
        let containerfile = ctx.path().join("Containerfile");
        std::fs::write(&containerfile, self.containerfile(product, target_triple))
            .with_context(|| format!("write {}", containerfile.display()))?;

        // 1. Build the image that captures target-identical deps into /out.
        let tag = format!("skidbladnir-capture/{product}:{}", target_triple.replace('/', "-"));
        let build = Command::new(&self.podman)
            .args(["build", "--file"])
            .arg(&containerfile)
            .args(["--build-arg", &format!("PRODUCT={product}")])
            .args(["--build-arg", &format!("TARGET={target_triple}")])
            .args(["--tag", &tag])
            .arg(ctx.path())
            .status()
            .with_context(|| format!("spawn `{} build`", self.podman))?;
        if !build.success() {
            anyhow::bail!("`{} build` failed for `{product}` ({target_triple})", self.podman);
        }

        // 2. Create a (non-running) container so we can copy /out out of it.
        let create = Command::new(&self.podman)
            .args(["create", &tag])
            .output()
            .with_context(|| format!("spawn `{} create`", self.podman))?;
        if !create.status.success() {
            anyhow::bail!(
                "`{} create` failed: {}",
                self.podman,
                String::from_utf8_lossy(&create.stderr)
            );
        }
        let cid = String::from_utf8_lossy(&create.stdout).trim().to_string();

        // 3. Copy the captured /out tree into the staging subdir, then clean up.
        let cp = Command::new(&self.podman)
            .arg("cp")
            .arg(format!("{cid}:/out/."))
            .arg(into)
            .status()
            .with_context(|| format!("spawn `{} cp`", self.podman))?;
        let _ = Command::new(&self.podman).args(["rm", "-f", &cid]).status();
        if !cp.success() {
            anyhow::bail!("`{} cp` failed to extract /out for `{product}`", self.podman);
        }

        // Enumerate what was captured (relative paths under `into`).
        let mut out = Vec::new();
        walk(into, into, &mut out)?;
        out.sort();
        Ok(out)
    }
}

fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) -> Result<()> {
    for e in std::fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
        let p = e?.path();
        if p.is_dir() {
            walk(root, &p, out)?;
        } else if p.is_file() {
            out.push(p.strip_prefix(root).unwrap_or(&p).to_string_lossy().replace('\\', "/"));
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write as _;

    /// LAW 1 (inject-assert): assemble a real staging tree (artifact + spec),
    /// pack → assert the manifest enumerates them, the version stamp matches, and
    /// the VERSION file is written OUTSIDE the bundle.
    #[test]
    fn pack_collects_staging_tree_and_version_file_is_outside() {
        let scratch = tempfile::tempdir().unwrap();
        let staging = Staging::new(scratch.path().join("stage"), "20250505").unwrap();

        // A real artifact file.
        let bin = scratch.path().join("holger-bin");
        let mut f = std::fs::File::create(&bin).unwrap();
        f.write_all(b"ELF...holger").unwrap();
        staging.add_artifact(&bin, "linux-x86_64/holger").unwrap();
        // Its component spec.
        staging
            .add_component_spec(&crate::product::ComponentSpec::holger())
            .unwrap();

        let manifest = pack(&staging, "holger", true).unwrap();
        assert_eq!(manifest.product, "holger");
        assert_eq!(manifest.version, "20250505");
        assert!(manifest.built_in_container);
        assert!(manifest.artifacts.contains(&"linux-x86_64/holger".to_string()));
        assert!(manifest.artifacts.contains(&"manifests/holger.json".to_string()));

        // VERSION file lives BESIDE the bundle (easy bump), not in the tree.
        let bundle = scratch.path().join("out/lake.tar.zst");
        let vf = write_version_file(&bundle, "20250505").unwrap();
        assert_eq!(vf, scratch.path().join("out/VERSION"));
        assert_eq!(std::fs::read_to_string(&vf).unwrap(), "20250505\n");
        assert!(!staging.root.join("VERSION").exists(), "VERSION not in bundle tree");
    }

    /// LAW 1 (inject-assert): the podman dep-capture build renders a real, exact
    /// Containerfile from the base image + capture steps (FROM, ARGs, /out, every
    /// RUN), and — when podman is absent — `build_into` fails with a clear,
    /// honest error rather than faking a capture.
    #[test]
    fn podman_build_renders_containerfile_and_is_honest_when_absent() {
        let build = PodmanBuild::new("registry.access.redhat.com/ubi9/ubi-minimal")
            .step("microdnf install -y openssl-libs")
            .step("cp /usr/bin/holger /out/holger && cp /usr/lib64/libssl.so.3 /out/");

        // The rendered Containerfile is exact and ordered.
        let cf = build.containerfile("holger", "x86_64-unknown-linux-gnu");
        let expected = "\
FROM registry.access.redhat.com/ubi9/ubi-minimal
ARG PRODUCT=holger
ARG TARGET=x86_64-unknown-linux-gnu
RUN mkdir -p /out
RUN microdnf install -y openssl-libs
RUN cp /usr/bin/holger /out/holger && cp /usr/lib64/libssl.so.3 /out/
";
        assert_eq!(cf, expected, "Containerfile must render byte-for-byte");

        // Point at a runtime that does not exist → honest failure, no fake output.
        let scratch = tempfile::tempdir().unwrap();
        let into = scratch.path().join("stage/linux-x86_64");
        let mut absent = PodmanBuild::new("scratch");
        absent.podman = "podman-does-not-exist-xyz".to_string();
        assert!(!absent.runtime_available(), "phantom runtime not available");
        let err = absent
            .build_into("holger", "x86_64-unknown-linux-gnu", &into)
            .unwrap_err()
            .to_string();
        assert!(err.contains("needs `podman-does-not-exist-xyz`"), "clear error: {err}");
        // No artifacts were faked into the staging dir.
        assert!(
            !into.exists() || std::fs::read_dir(&into).map(|mut d| d.next().is_none()).unwrap_or(true),
            "absent runtime must not fabricate captured artifacts"
        );
    }
}
