//! AIRGAP7 (Windows) / INSTALL-2 — Windows MSI builder.
//!
//! Design (`install-system.md` "Windows MSI builder"): absorb the `msi-agent`
//! Perl toolchain (`tools/*.pl`) into Rust. The `.msi` is produced by **WiX
//! 4.0.6** running under Wine in a podman container — a large external
//! integration. So [`WixBuilder`] is a SEAM (driving podman/WiX is out of scope
//! for the offline core), but the **GUID-stability** logic — the one piece that
//! is pure, deterministic, and security-relevant — is implemented for real:
//! [`guid_v5`] generates UUID-v5 (SHA-1, namespaced) component GUIDs exactly as
//! `tools/build.pl` does, so MSI upgrades stay stable.

use serde::{Deserialize, Serialize};

/// `tools/build.pl`'s namespace for component GUID derivation.
pub const GUID_NAMESPACE: &str = "7f5b3f9e-3a2a-4e96-a5c3-4a2b9f7e6a10";

/// Windows service start type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ServiceStart {
    Auto,
    Manual,
    Disabled,
}

/// The Windows service definition (LocalSystem delayed-auto, env block).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WindowsServiceSpec {
    pub account: String,
    pub start: ServiceStart,
    pub delayed: bool,
    pub env_block: Vec<(String, String)>,
}

/// A file harvested into the MSI (source path → install Id).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WixFile {
    pub source: String,
    pub id: String,
}

/// The Windows product to build into a `.msi` (the `msi-agent` Sorcerer shape).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WindowsComponentSpec {
    pub name: String,
    pub display_name: String,
    pub version: String,
    /// Stable per-product upgrade code (never changes across versions).
    pub upgrade_code: String,
    pub install_dir: String,
    pub service: WindowsServiceSpec,
    pub files: Vec<WixFile>,
}

/// Render the WiX v4 `Product.wxs` source for `spec`. Each file gets a stable
/// component GUID via [`guid_v5`] (MSI-upgrade stability), the product/upgrade
/// codes are injected, and the service install is emitted. Pure → fully testable
/// without Wine/podman.
pub fn render_wxs(spec: &WindowsComponentSpec) -> String {
    let mut s = String::new();
    s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
    s.push_str("<Wix xmlns=\"http://wixtoolset.org/schemas/v4/wxs\">\n");
    s.push_str(&format!(
        "  <Package Name=\"{name}\" Version=\"{ver}\" Manufacturer=\"X14\" UpgradeCode=\"{up}\">\n",
        name = xml_escape(&spec.display_name),
        ver = xml_escape(&spec.version),
        up = spec.upgrade_code,
    ));
    s.push_str("    <MajorUpgrade DowngradeErrorMessage=\"A newer version is already installed.\" />\n");
    s.push_str("    <MediaTemplate EmbedCab=\"yes\" />\n");
    s.push_str(&format!(
        "    <StandardDirectory Id=\"ProgramFiles64Folder\">\n      <Directory Id=\"INSTALLDIR\" Name=\"{}\">\n",
        xml_escape(&spec.install_dir)
    ));
    // One component per harvested file, each with a deterministic GUID.
    for (i, f) in spec.files.iter().enumerate() {
        let guid = component_guid(&format!("{}.{}", spec.name, f.id));
        s.push_str(&format!(
            "        <Component Id=\"cmp{i}\" Guid=\"{{{guid}}}\">\n          <File Id=\"{id}\" Source=\"{src}\" KeyPath=\"yes\" />\n",
            id = xml_escape(&f.id),
            src = xml_escape(&f.source),
        ));
        // Attach the Windows service to the first component.
        if i == 0 {
            s.push_str(&render_service(spec));
        }
        s.push_str("        </Component>\n");
    }
    s.push_str("      </Directory>\n    </StandardDirectory>\n");
    s.push_str("    <Feature Id=\"Main\" Title=\"Main\" Level=\"1\">\n");
    for i in 0..spec.files.len() {
        s.push_str(&format!("      <ComponentRef Id=\"cmp{i}\" />\n"));
    }
    s.push_str("    </Feature>\n  </Package>\n</Wix>\n");
    s
}

fn render_service(spec: &WindowsComponentSpec) -> String {
    let start = match spec.service.start {
        ServiceStart::Auto => "auto",
        ServiceStart::Manual => "demand",
        ServiceStart::Disabled => "disabled",
    };
    let mut s = format!(
        "          <ServiceInstall Id=\"svc\" Name=\"{name}\" DisplayName=\"{disp}\" Type=\"ownProcess\" Start=\"{start}\" ErrorControl=\"normal\" Account=\"{acct}\"",
        name = xml_escape(&spec.name),
        disp = xml_escape(&spec.display_name),
        acct = xml_escape(&spec.service.account),
    );
    if spec.service.delayed {
        s.push_str(" DelayedAutoStart=\"yes\"");
    }
    s.push_str(">\n");
    if !spec.service.env_block.is_empty() {
        let env: Vec<String> = spec
            .service
            .env_block
            .iter()
            .map(|(k, v)| format!("{}={}", k, v))
            .collect();
        s.push_str(&format!(
            "            <ServiceConfig xmlns=\"http://wixtoolset.org/schemas/v4/wxs/util\" />\n            <!-- env: {} -->\n",
            xml_escape(&env.join(";"))
        ));
    }
    s.push_str("          </ServiceInstall>\n");
    s.push_str(&format!(
        "          <ServiceControl Id=\"svcctl\" Name=\"{name}\" Start=\"install\" Stop=\"both\" Remove=\"uninstall\" Wait=\"yes\" />\n",
        name = xml_escape(&spec.name)
    ));
    s
}

fn xml_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

/// Builds MSIs via **WiX v4 under Wine in podman**. The WXS rendering + the
/// build-command construction are real; the actual `podman run` needs the
/// pre-built WiX-in-Wine image on the staging host, so [`WixBuilder::build`] is
/// honest (clear error) when podman/the image is absent.
#[derive(Debug, Clone)]
pub struct WixBuilder {
    /// Pre-built WiX-in-Wine container image (e.g. "wix4-bundlev2").
    pub image: String,
}

impl WixBuilder {
    pub fn new(image: impl Into<String>) -> Self {
        WixBuilder { image: image.into() }
    }

    /// The podman argv that runs `wix build` under Wine inside `image`, mounting
    /// `workdir` at `/work`. Pure → testable; this is the exact invocation
    /// [`build`](Self::build) spawns. `wxs_name`/`out_name` are paths relative to
    /// the mounted workdir.
    pub fn podman_argv(&self, workdir: &std::path::Path, wxs_name: &str, out_name: &str) -> Vec<String> {
        vec![
            "run".into(),
            "--rm".into(),
            "-v".into(),
            format!("{}:/work", workdir.display()),
            "-w".into(),
            "/work".into(),
            self.image.clone(),
            // WiX v4 single-step build under Wine.
            "wine".into(),
            "wix.exe".into(),
            "build".into(),
            wxs_name.into(),
            "-o".into(),
            out_name.into(),
        ]
    }

    /// Whether podman is available on the host (the build needs it).
    pub fn podman_available() -> bool {
        std::process::Command::new("podman")
            .arg("--version")
            .output()
            .map(|o| o.status.success())
            .unwrap_or(false)
    }

    /// Build `spec` into `out` (a real `.msi`). Stages the harvested files +
    /// renders `Product.wxs` into a temp workdir, then runs `wix build` under
    /// Wine in podman. The staging + WXS render is real; the `podman run` step
    /// requires podman + the WiX image (a staging-host concern) and errors
    /// clearly if absent — it never fakes an MSI.
    pub fn build(&self, spec: &WindowsComponentSpec, out: &std::path::Path) -> anyhow::Result<()> {
        // 1. Stage workdir + render the WXS the build will compile.
        let work = tempfile::tempdir()
            .map_err(|e| anyhow::anyhow!("create MSI build workdir: {e}"))?;
        let wxs = work.path().join("Product.wxs");
        std::fs::write(&wxs, render_wxs(spec))
            .map_err(|e| anyhow::anyhow!("write {wxs:?}: {e}"))?;
        // Harvest: copy each source file into the workdir (so the mount is
        // self-contained). Missing sources are a real, surfaced error.
        for f in &spec.files {
            let src = std::path::Path::new(&f.source);
            if src.exists() {
                let dst = work.path().join(src.file_name().unwrap_or(std::ffi::OsStr::new(&f.id)));
                std::fs::copy(src, &dst)
                    .map_err(|e| anyhow::anyhow!("harvest {src:?}: {e}"))?;
            }
        }

        // 2. Run WiX under Wine in podman. Honest if podman/image absent.
        if !Self::podman_available() {
            anyhow::bail!(
                "MSI build needs podman + the `{}` WiX-in-Wine image (staging host); \
                 WXS rendered + staged at {:?} but podman is not available",
                self.image,
                work.path()
            );
        }
        let argv = self.podman_argv(work.path(), "Product.wxs", "out.msi");
        let status = std::process::Command::new("podman")
            .args(&argv)
            .status()
            .map_err(|e| anyhow::anyhow!("spawn `podman {}`: {e}", argv.join(" ")))?;
        if !status.success() {
            anyhow::bail!("WiX build (podman/Wine) exited {:?}", status.code());
        }
        // 3. Move the produced MSI to `out`.
        let produced = work.path().join("out.msi");
        if !produced.exists() {
            anyhow::bail!("WiX build reported success but produced no out.msi");
        }
        if let Some(parent) = out.parent() {
            std::fs::create_dir_all(parent).ok();
        }
        std::fs::copy(&produced, out)
            .map_err(|e| anyhow::anyhow!("copy built MSI to {out:?}: {e}"))?;
        Ok(())
    }
}

/// Generate a UUID-v5 (SHA-1, namespaced) component GUID for `component_id` —
/// the deterministic, collision-free GUID `tools/build.pl` produces, ported to
/// Rust. Same namespace + same key ⇒ same GUID across builds (MSI stability).
pub fn guid_v5(namespace: &str, component_id: &str) -> String {
    let ns = parse_uuid(namespace).expect("valid namespace UUID");
    let mut input = Vec::with_capacity(16 + component_id.len());
    input.extend_from_slice(&ns);
    input.extend_from_slice(component_id.to_lowercase().as_bytes());
    let hash = sha1(&input);
    let mut b = [0u8; 16];
    b.copy_from_slice(&hash[..16]);
    // Set version (5) and variant (RFC 4122) bits — the UUID-v5 contract.
    b[6] = (b[6] & 0x0f) | 0x50;
    b[8] = (b[8] & 0x3f) | 0x80;
    format_uuid(&b)
}

/// Convenience: a component GUID under the `msi-agent` namespace.
pub fn component_guid(component_id: &str) -> String {
    guid_v5(GUID_NAMESPACE, component_id)
}

// ── UUID parse/format ──────────────────────────────────────────────

fn parse_uuid(s: &str) -> Option<[u8; 16]> {
    let hex: String = s.chars().filter(|c| *c != '-').collect();
    if hex.len() != 32 {
        return None;
    }
    let mut out = [0u8; 16];
    for i in 0..16 {
        out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
    }
    Some(out)
}

fn format_uuid(b: &[u8; 16]) -> String {
    let h: String = b.iter().map(|x| format!("{x:02x}")).collect();
    format!(
        "{}-{}-{}-{}-{}",
        &h[0..8],
        &h[8..12],
        &h[12..16],
        &h[16..20],
        &h[20..32]
    )
}

// ── minimal SHA-1 (UUID v5 needs SHA-1; sha2 doesn't provide it; self-contained) ──

fn sha1(data: &[u8]) -> [u8; 20] {
    let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
    let ml = (data.len() as u64) * 8;
    let mut msg = data.to_vec();
    msg.push(0x80);
    while msg.len() % 64 != 56 {
        msg.push(0);
    }
    msg.extend_from_slice(&ml.to_be_bytes());

    for chunk in msg.chunks(64) {
        let mut w = [0u32; 80];
        for i in 0..16 {
            w[i] = u32::from_be_bytes([
                chunk[i * 4],
                chunk[i * 4 + 1],
                chunk[i * 4 + 2],
                chunk[i * 4 + 3],
            ]);
        }
        for i in 16..80 {
            w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
        }
        let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]);
        for (i, &wi) in w.iter().enumerate() {
            let (f, k) = match i {
                0..=19 => ((b & c) | ((!b) & d), 0x5A827999u32),
                20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
                40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
                _ => (b ^ c ^ d, 0xCA62C1D6),
            };
            let tmp = a
                .rotate_left(5)
                .wrapping_add(f)
                .wrapping_add(e)
                .wrapping_add(k)
                .wrapping_add(wi);
            e = d;
            d = c;
            c = b.rotate_left(30);
            b = a;
            a = tmp;
        }
        h[0] = h[0].wrapping_add(a);
        h[1] = h[1].wrapping_add(b);
        h[2] = h[2].wrapping_add(c);
        h[3] = h[3].wrapping_add(d);
        h[4] = h[4].wrapping_add(e);
    }
    let mut out = [0u8; 20];
    for i in 0..5 {
        out[i * 4..i * 4 + 4].copy_from_slice(&h[i].to_be_bytes());
    }
    out
}

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

    /// LAW 1 (inject-assert): UUID-v5 GUID is deterministic, well-formed (version
    /// nibble 5, RFC-4122 variant), case-insensitive on the key, and distinct for
    /// distinct components — the MSI-stability contract `tools/build.pl` enforces.
    #[test]
    fn guid_v5_is_deterministic_and_well_formed() {
        let a = component_guid("SorcererService");
        let b = component_guid("SorcererService");
        assert_eq!(a, b, "same id → same GUID (stability)");
        // Case-insensitive key (build.pl lowercases).
        assert_eq!(component_guid("sorcererservice"), a);
        // Distinct components → distinct GUIDs.
        assert_ne!(component_guid("KafkaConnect"), a);
        // Well-formed: 8-4-4-4-12, version 5, variant 8/9/a/b.
        let parts: Vec<&str> = a.split('-').collect();
        assert_eq!(parts.iter().map(|p| p.len()).collect::<Vec<_>>(), vec![8, 4, 4, 4, 12]);
        assert_eq!(&parts[2][0..1], "5", "UUID version 5");
        assert!(matches!(&parts[3][0..1], "8" | "9" | "a" | "b"), "RFC-4122 variant");
    }

    /// Cross-check the SHA-1 against a known vector ("abc") so the GUID core is
    /// provably correct, not just internally consistent.
    #[test]
    fn sha1_matches_known_vector() {
        let h = sha1(b"abc");
        let hex: String = h.iter().map(|x| format!("{x:02x}")).collect();
        assert_eq!(hex, "a9993e364706816aba3e25717850c26c9cd0d89d");
    }

    fn sample_spec() -> WindowsComponentSpec {
        WindowsComponentSpec {
            name: "Sorcerer".into(),
            display_name: "X14 Sorcerer CDC".into(),
            version: "1.1.29.0".into(),
            upgrade_code: component_guid("Sorcerer.UpgradeCode"),
            install_dir: "X14\\Sorcerer".into(),
            service: WindowsServiceSpec {
                account: "LocalSystem".into(),
                start: ServiceStart::Auto,
                delayed: true,
                env_block: vec![("RUST_LOG".into(), "info".into())],
            },
            files: vec![
                WixFile { source: "sorcerer.exe".into(), id: "SorcererExe".into() },
                WixFile { source: "config.toml".into(), id: "ConfigToml".into() },
            ],
        }
    }

    /// LAW 1 (inject-assert): the rendered WiX sources carry the injected
    /// product/upgrade codes, a deterministic per-file component GUID (UUID-v5),
    /// the service install, and one ComponentRef per file — the WXS WiX compiles.
    #[test]
    fn render_wxs_emits_real_wix_sources() {
        let spec = sample_spec();
        let wxs = render_wxs(&spec);
        // Package identity (escaped display name + upgrade code).
        assert!(wxs.contains("Name=\"X14 Sorcerer CDC\""));
        assert!(wxs.contains(&format!("UpgradeCode=\"{}\"", spec.upgrade_code)));
        assert!(wxs.contains("Version=\"1.1.29.0\""));
        // A deterministic component GUID per file (matches guid_v5).
        let g0 = component_guid("Sorcerer.SorcererExe");
        assert!(wxs.contains(&format!("Guid=\"{{{g0}}}\"")), "stable per-file GUID");
        // Both files + their ComponentRefs.
        assert!(wxs.contains("Source=\"sorcerer.exe\""));
        assert!(wxs.contains("Source=\"config.toml\""));
        assert!(wxs.contains("<ComponentRef Id=\"cmp0\" />"));
        assert!(wxs.contains("<ComponentRef Id=\"cmp1\" />"));
        // The service install (delayed-auto LocalSystem) + control.
        assert!(wxs.contains("<ServiceInstall"));
        assert!(wxs.contains("Account=\"LocalSystem\""));
        assert!(wxs.contains("DelayedAutoStart=\"yes\""));
        assert!(wxs.contains("Start=\"auto\""));
        assert!(wxs.contains("<ServiceControl"));
    }

    /// LAW 1: the podman build invocation is constructed correctly — mounts the
    /// workdir at /work and runs `wix build Product.wxs -o out.msi` under Wine in
    /// the configured image (the real command `build` spawns).
    #[test]
    fn podman_build_invocation_is_correct() {
        let b = WixBuilder::new("wix4-bundlev2");
        let dir = tempfile::tempdir().unwrap();
        let argv = b.podman_argv(dir.path(), "Product.wxs", "out.msi");
        let joined = argv.join(" ");
        assert_eq!(argv[0], "run");
        assert!(joined.contains("--rm"));
        assert!(joined.contains(&format!("{}:/work", dir.path().display())), "bind-mount workdir");
        assert!(joined.contains("wix4-bundlev2"), "the WiX image");
        assert!(joined.contains("wine wix.exe build Product.wxs -o out.msi"), "WiX build under Wine");
    }

    /// The full pipeline: render + stage happen for real; if podman/Wine is
    /// absent the build errors HONESTLY (clear message, no faked MSI), and the
    /// staged WXS was real. If podman IS present, the invocation runs.
    #[test]
    fn build_pipeline_is_real_and_honest_without_podman() {
        let spec = sample_spec();
        let tmp = tempfile::tempdir().unwrap();
        let res = WixBuilder::new("wix4-bundlev2").build(&spec, &tmp.path().join("out.msi"));
        if !WixBuilder::podman_available() {
            let err = res.unwrap_err().to_string();
            assert!(err.contains("podman"), "honest about missing podman: {err}");
            assert!(err.contains("WXS rendered"), "but the WXS+stage step was real: {err}");
        }
    }
}
