//! # skidbladnir — *Skíðblaðnir*
//!
//! A **100% Rust** framework to **pack** a product (or a whole product lake) into an
//! air-gapped bundle, **move** it across the gap, **unfold** the target (existing server
//! via Redfish, IaC via Terraform, or a KVM VM for test), and **install + configure +
//! start** every product in dependency order — no shell glue.
//!
//! Motif: *Skíðblaðnir*, the ship that carries every god + their war-gear yet folds into a
//! pouch — fold the whole stack into one archive, carry it over the gap, unfold, raise the house.
//!
//! Pipeline: `pack → archive → fetch ║airgap║ → unfold → install → start(ordered)`.
//!
//! Design + source prompt + prior-art map: `nornir/.nornir/airgap-install.md`.
//! Plan: EPIC AIRGAP in `nornir/.nornir/nordisk_plan.md`. Prior art: `dist`,
//! `airgap-kubernetes-main`, `airgap_llm`, `tunnr`, `msi-agent`, `bifrost`.
//!
//! ## What's always-on vs feature-gated (honest)
//! - **Real, offline, pure-Rust:** [`pack`] (staging tree + VERSION-outside),
//!   [`archive`] (tar+zstd bundle + content-addressed manifest), [`fetch`]
//!   (verify-on-receipt, zero network), [`platform`] (os-release detect +
//!   unpack), [`pki`] `SelfGenerated` (ed25519 self-signed certs that *verify*),
//!   [`layout`] (version-dated dirs + atomic symlink swap), [`product`]
//!   (ComponentSpec + the generalized systemd [`Installer`]), [`orchestrate`]
//!   (dependency-ordered start waves), [`events`] (airgap_events → warehouse JSONL),
//!   [`msi::guid_v5`] (UUID-v5 GUID stability).
//! - **Feature-gated, fully implemented (a real call when the feature is on):** the
//!   znippy archive engine ([`archive`] `znippy` feature — pure-Rust
//!   content-cache + delta), Vault PKI ([`pki::VaultClient`]), Redfish /
//!   Terraform unfold ([`unfold`]), the WiX-in-podman MSI build ([`msi::WixBuilder`]),
//!   the per-target container dep-capture ([`pack::ContainerBuild`]).

use serde::{Deserialize, Serialize};

/// Reusable systemd-service installer — the extracted, parameterized form of
/// `nornir install systemd` (`SystemdServiceSpec` + pure `systemd_unit`/`env_file`
/// generators + privileged `install_systemd`). Holger and other nordisk products
/// reuse this to install themselves; nornir/holger are NOT yet swapped over.
/// Design + guide: `.nornir/airgap-installer.md`.
pub mod systemd;

/// INSTALL IDENTITY — a record minted at install time (per `install_systemd_scoped`)
/// that distinguishes the same machine, a reinstall on the same machine, and a
/// different machine. A reinstall-stable host-derived `machine_fp` plus a fresh
/// UUIDv7 `install_id`. Identity references credentials (ssh key / node cert) by
/// fingerprint; it is not itself a credential. See [`identity::ensure`].
pub mod identity;

/// INSTALL-TREE — the backend-neutral service-install abstraction: ONE
/// declarative [`service::ServiceSpec`] (exec · user · scope · env · deps ·
/// restart · sockets) rendered to many init/service systems through the
/// [`service::ServiceRenderer`] trait. Generalizes the systemd-only [`systemd`]
/// limb into a tree that targets modern (2015+) init systems. Spike ships two
/// real renderers — systemd (system + `--user`) and OpenRC. Design + the full
/// landscape survey: `.nornir/install-tree-design.md`.
pub mod service;

/// AIRGAP1 — collect a product's artifacts + target-identical deps into a
/// versioned staging tree; VERSION file outside the bundle for easy bumping.
pub mod pack;

/// AIRGAP2 — znippy bundle writer/reader (content cache + delta; no full
/// decompress on read). Real default: `tar + zstd` ([`archive::TarZstd`]); the
/// znippy engine lands behind the `znippy` feature.
pub mod archive;

/// AIRGAP3 (transfer) — move + verify the bundle across the gap (zero network).
pub mod fetch;

/// AIRGAP3 (target) — per-OS specialization (SELinux toggle) + unpack.
pub mod platform;

/// AIRGAP4 — PKI: `PkiSource` ExistingVault | SelfGenerated | TempVault.
pub mod pki;

/// AIRGAP5 — data dir + version-dated software dir + atomic symlink upgrade swap.
pub mod layout;

/// AIRGAP6 — per-product install: `ComponentSpec` + the generalized `Installer`
/// (service user, systemd unit, unpack, configure data path, open ports).
pub mod product;

/// PRODUCT-INSTALL LIFECYCLE — install (create service + unpack product + provision
/// data & log dirs) vs update (swap software, preserve data/log) vs uninstall
/// (remove service, optionally purge) over the neutral service tree. Every op
/// returns an [`install::InstallOutcome`] (dirs · files · verbs as data); a thin
/// [`install::apply`] runs the privileged verbs. Built on [`layout`] (per-product
/// data+log provisioning), [`product::ComponentSpec::to_service_spec`], [`service`]
/// renderers, [`archive`] (znippy unfold) and [`identity`].
pub mod install;

/// AIRGAP7 — the target abstraction + three unfold backends (Redfish · Terraform · KVM).
pub mod unfold;

/// MACHINE — reusable VM/container **lifecycle control** for downstream test suites
/// (holger · nornir · tunnr): boot a real machine to test systemd/PID1/init, true
/// airgap egress isolation, kernel behaviour. Server-side only (behind `machine`);
/// never compiled into the lean embedded client. Impls behind `kvm` / `container`.
#[cfg(feature = "machine")]
pub mod machine;

/// INSTALL-MATRIX — the live multi-OS `(os × backend × scope)` install test matrix
/// (design `.nornir/install-tree-design.md` Part C): boot a KVM guest per OS, write
/// the rendered unit, run the renderer's enable/start verbs, and assert via the
/// machine-injected `skidbladnir-probe` that the service installed + started +
/// stayed healthy — emitting one [`matrix::ProbeReport`] row per cell. Heavy /
/// server-side (behind `matrix`, implies `kvm`).
#[cfg(feature = "matrix")]
pub mod matrix;

/// AIRGAP8 — dependency-ordered install + start (topo sort).
pub mod orchestrate;

/// AIRGAP8 — `airgap_events` telemetry stream (warehouse JSONL sink + emitter).
pub mod events;

/// INSTALL-2 (Windows) — MSI builder seam + real UUID-v5 GUID stability.
pub mod msi;

/// Declarative lake/product descriptor (replaces the `dist` ansible playbooks).
pub mod config {
    use super::pki::PkiSource;
    use super::product::ComponentSpec;
    use super::*;

    /// A whole lake of products to airgap + install, with shared PKI + version.
    /// Carries [`ComponentSpec`]s (the install detail) — the dep-graph view is
    /// derived via `ComponentSpec::as_product` for [`crate::orchestrate`].
    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
    pub struct LakeSpec {
        pub version: String,
        pub products: Vec<ComponentSpec>,
        pub pki: PkiSource,
    }

    impl LakeSpec {
        /// The dependency-ordered start waves for this lake (AIRGAP8).
        pub fn start_waves(&self) -> anyhow::Result<Vec<Vec<String>>> {
            let products: Vec<_> = self.products.iter().map(|c| c.as_product()).collect();
            crate::orchestrate::start_waves(&products)
        }
    }
}

/// A tiny, dependency-free mock HTTP(S-less) server for the external-integration
/// tests (Vault PKI · Redfish BMC). It captures the exact request line, headers
/// and body the integration sent — so the tests assert the **request shape** (the
/// inject-assert law), not just "didn't crash" — and replays a canned response.
///
/// Hand-rolled over `std::net::TcpListener` (no test-only HTTP dep) so the lean
/// crate pulls nothing extra. Single-threaded, one request per route registered.
// Used only by the feature-gated integration tests (`vault` Vault PKI, `redfish`
// BMC); under the default/offline-core feature set those tests are compiled out,
// so the helper is legitimately unreferenced — keep a scoped allow rather than a
// crate-wide one.
#[cfg(test)]
#[allow(dead_code)]
pub(crate) mod mockhttp {
    use std::io::{BufRead, BufReader, Read, Write};
    use std::net::TcpListener;
    use std::sync::mpsc::{channel, Receiver};
    use std::thread::JoinHandle;

    /// A request the mock captured.
    #[derive(Debug, Clone)]
    pub struct CapturedRequest {
        pub method: String,
        pub path: String,
        pub headers: Vec<(String, String)>,
        pub body: String,
    }

    impl CapturedRequest {
        pub fn header(&self, name: &str) -> Option<&str> {
            self.headers
                .iter()
                .find(|(k, _)| k.eq_ignore_ascii_case(name))
                .map(|(_, v)| v.as_str())
        }
    }

    /// A running mock server. `base_url()` is what the client connects to; on
    /// drop the worker thread is joined. Serves `expect` requests then stops.
    pub struct MockServer {
        base_url: String,
        rx: Receiver<CapturedRequest>,
        handle: Option<JoinHandle<()>>,
    }

    impl MockServer {
        /// Bind to an ephemeral localhost port; each incoming request is answered
        /// with `status` + `body` (JSON), and the captured request is sent back
        /// over the channel. Serves `count` requests, then the thread exits.
        pub fn start(count: usize, status: u16, body_response: &'static str) -> Self {
            let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
            let addr = listener.local_addr().unwrap();
            let (tx, rx) = channel();
            let handle = std::thread::spawn(move || {
                for _ in 0..count {
                    let (mut stream, _) = match listener.accept() {
                        Ok(s) => s,
                        Err(_) => break,
                    };
                    let mut reader = BufReader::new(stream.try_clone().unwrap());
                    let mut request_line = String::new();
                    if reader.read_line(&mut request_line).is_err() {
                        break;
                    }
                    let mut parts = request_line.split_whitespace();
                    let method = parts.next().unwrap_or("").to_string();
                    let path = parts.next().unwrap_or("").to_string();

                    let mut headers = Vec::new();
                    let mut content_length = 0usize;
                    loop {
                        let mut line = String::new();
                        if reader.read_line(&mut line).is_err() {
                            break;
                        }
                        let trimmed = line.trim_end();
                        if trimmed.is_empty() {
                            break;
                        }
                        if let Some((k, v)) = trimmed.split_once(':') {
                            let k = k.trim().to_string();
                            let v = v.trim().to_string();
                            if k.eq_ignore_ascii_case("content-length") {
                                content_length = v.parse().unwrap_or(0);
                            }
                            headers.push((k, v));
                        }
                    }
                    let mut body = vec![0u8; content_length];
                    if content_length > 0 {
                        let _ = reader.read_exact(&mut body);
                    }

                    let _ = tx.send(CapturedRequest {
                        method,
                        path,
                        headers,
                        body: String::from_utf8_lossy(&body).to_string(),
                    });

                    let reason = if (200..300).contains(&status) { "OK" } else { "ERR" };
                    let resp = format!(
                        "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{response}",
                        len = body_response.len(),
                        response = body_response
                    );
                    let _ = stream.write_all(resp.as_bytes());
                    let _ = stream.flush();
                }
            });
            MockServer {
                base_url: format!("http://{addr}"),
                rx,
                handle: Some(handle),
            }
        }

        pub fn base_url(&self) -> &str {
            &self.base_url
        }

        /// Block for the next captured request.
        pub fn next_request(&self) -> CapturedRequest {
            self.rx
                .recv_timeout(std::time::Duration::from_secs(5))
                .expect("mock server received a request")
        }
    }

    impl Drop for MockServer {
        fn drop(&mut self) {
            if let Some(h) = self.handle.take() {
                let _ = h.join();
            }
        }
    }
}

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

    /// LAW 1 (inject-assert): a whole LakeSpec round-trips through JSON AND its
    /// derived start-waves respect the dependency edges declared in the specs —
    /// the end-to-end "config → ordered bring-up" contract.
    #[test]
    fn lakespec_roundtrips_and_derives_ordered_waves() {
        let lake = config::LakeSpec {
            version: "20250505".into(),
            pki: pki::PkiSource::SelfGenerated,
            products: vec![
                product::ComponentSpec::new("holger", "linux-x86_64/holger", "_holger"),
                product::ComponentSpec::new("nornir-server", "linux-x86_64/nornir-server", "nornir")
                    .after("holger"),
            ],
        };

        let back: config::LakeSpec =
            serde_json::from_str(&serde_json::to_string(&lake).unwrap()).unwrap();
        assert_eq!(back, lake);

        let waves = lake.start_waves().unwrap();
        assert_eq!(waves[0], vec!["holger"], "root first");
        assert_eq!(waves[1], vec!["nornir-server"], "dependent after");
    }
}
