//! AIRGAP3 (transfer half) — move + **verify** the bundle across the gap.
//!
//! Design (`install-system.md` "verify on receive" + the airgap law): every byte
//! that crosses the gap is verified before it is trusted. The transfer itself
//! (USB / one-time relay / age-encrypted courier) is out-of-band — on the target
//! the bundle simply *appears* at a local path. [`verify_on_receipt`] is the
//! gate: it recomputes the bundle's content hashes against the signed
//! [`crate::archive::BundleManifest`] and refuses to proceed on any mismatch.
//!
//! Crucially this does **zero network IO** — the airgap target never reaches out.

use std::path::Path;

use anyhow::{Context, Result};

use crate::archive::{load_manifest, sha256_file, Archive, Bundle, BundleManifest};

/// The verification verdict for a received bundle.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifyReport {
    /// The bundle's recomputed file hash matched the sidecar (whole-archive).
    pub bundle_hash_ok: bool,
    /// The manifest's internal root hash recomputes (no tampering of the list).
    pub manifest_root_ok: bool,
    /// Per-entry digests that did NOT match after extraction (empty = all good).
    pub bad_entries: Vec<String>,
}

impl VerifyReport {
    pub fn is_ok(&self) -> bool {
        self.bundle_hash_ok && self.manifest_root_ok && self.bad_entries.is_empty()
    }
}

/// Verify a received bundle against its sidecar manifest. Recomputes:
/// 1. the manifest's own root hash (the entry list wasn't tampered),
/// 2. each entry's content hash by streaming it out of the archive.
///
/// `expected_bundle_sha` is the out-of-band-communicated whole-file SHA-256 (e.g.
/// printed on the USB label / sent over the secure channel); when supplied we
/// also confirm the archive file itself is byte-identical.
pub fn verify_on_receipt(
    bundle: &Path,
    archive: &dyn Archive,
    expected_bundle_sha: Option<&str>,
) -> Result<VerifyReport> {
    let manifest: BundleManifest =
        load_manifest(bundle).context("load bundle sidecar manifest")?;

    let manifest_root_ok = manifest.verify_root();

    let bundle_hash_ok = match expected_bundle_sha {
        Some(expect) => sha256_file(bundle)? == expect,
        // No out-of-band hash supplied → we can only attest the internal chain.
        None => true,
    };

    // Re-derive each entry's hash from the actual archive contents in a SINGLE
    // streaming pass (TarZstd decompresses the tar once) — never the old
    // O(entries × total-bytes) per-entry full-archive rescan.
    let bad = archive
        .verify_entries(bundle, &manifest)
        .context("verify bundle entries")?;

    Ok(VerifyReport {
        bundle_hash_ok,
        manifest_root_ok,
        bad_entries: bad,
    })
}

/// Convenience: verify, and on success return the [`Bundle`] handle; on failure
/// an error naming what was wrong (so a partial/corrupt transfer never installs).
pub fn fetch_and_verify(bundle: &Path, archive: &dyn Archive) -> Result<Bundle> {
    let report = verify_on_receipt(bundle, archive, None)?;
    if !report.is_ok() {
        anyhow::bail!(
            "bundle verify FAILED (manifest_root_ok={}, bundle_hash_ok={}, bad_entries={:?}) — \
             refusing to install a corrupt airgap bundle",
            report.manifest_root_ok,
            report.bundle_hash_ok,
            report.bad_entries
        );
    }
    Ok(Bundle::new(bundle))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::archive::{Archive, EntryDigest, TarZstd, manifest_path};
    use std::io::Write as _;

    fn make_bundle() -> (tempfile::TempDir, std::path::PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let staging = dir.path().join("stage");
        std::fs::create_dir_all(staging.join("linux-x86_64")).unwrap();
        let mut f = std::fs::File::create(staging.join("linux-x86_64/holger")).unwrap();
        f.write_all(b"holger-bytes").unwrap();
        let bundle = dir.path().join("lake.tar.zst");
        TarZstd::new().pack(&staging, "20250505", &bundle).unwrap();
        (dir, bundle)
    }

    /// LAW 1 (inject-assert): a faithfully-packed bundle verifies clean, and the
    /// whole-file SHA matches the out-of-band value.
    #[test]
    fn faithful_bundle_verifies_on_receipt() {
        let (_d, bundle) = make_bundle();
        let eng = TarZstd::new();
        let expected = sha256_file(&bundle).unwrap();
        let report = verify_on_receipt(&bundle, &eng, Some(&expected)).unwrap();
        assert!(report.is_ok(), "{report:?}");
        assert!(report.bundle_hash_ok && report.manifest_root_ok);
        assert!(report.bad_entries.is_empty());
        // fetch_and_verify returns the handle.
        assert_eq!(fetch_and_verify(&bundle, &eng).unwrap().path, bundle);
    }

    /// A tampered sidecar manifest is REJECTED — install refuses to proceed.
    #[test]
    fn tampered_manifest_is_rejected() {
        let (_d, bundle) = make_bundle();
        // Corrupt the recorded digest in the sidecar.
        let mut m = load_manifest(&bundle).unwrap();
        m.entries[0].sha256 = "ff".repeat(32);
        std::fs::write(manifest_path(&bundle), serde_json::to_vec(&m).unwrap()).unwrap();

        let eng = TarZstd::new();
        let report = verify_on_receipt(&bundle, &eng, None).unwrap();
        assert!(!report.is_ok(), "tamper must be caught");
        // Either the root no longer matches or the entry hash mismatches.
        assert!(!report.manifest_root_ok || !report.bad_entries.is_empty());
        assert!(fetch_and_verify(&bundle, &eng).is_err());
    }

    /// A WRONG out-of-band SHA fails the whole-file check (`bundle_hash_ok=false`)
    /// while the internal chain stays intact — so the report is NOT ok. (The
    /// no-OOB `fetch_and_verify` still passes; it only attests the internal chain.)
    #[test]
    fn wrong_out_of_band_sha_is_rejected() {
        let (_d, bundle) = make_bundle();
        let eng = TarZstd::new();
        let report = verify_on_receipt(&bundle, &eng, Some(&"00".repeat(32))).unwrap();
        assert!(!report.bundle_hash_ok, "wrong OOB hash rejected");
        assert!(report.manifest_root_ok, "internal chain still intact");
        assert!(report.bad_entries.is_empty());
        assert!(!report.is_ok(), "a wrong OOB hash makes the report not-ok");
        assert!(
            fetch_and_verify(&bundle, &eng).is_ok(),
            "fetch_and_verify (no OOB) attests only the internal chain"
        );
    }

    /// `VerifyReport::is_ok` is the AND of all three checks — any single failing
    /// field makes it false.
    #[test]
    fn verify_report_is_ok_logic() {
        let ok = VerifyReport {
            bundle_hash_ok: true,
            manifest_root_ok: true,
            bad_entries: vec![],
        };
        assert!(ok.is_ok());
        for bad in [
            VerifyReport { bundle_hash_ok: false, manifest_root_ok: true, bad_entries: vec![] },
            VerifyReport { bundle_hash_ok: true, manifest_root_ok: false, bad_entries: vec![] },
            VerifyReport { bundle_hash_ok: true, manifest_root_ok: true, bad_entries: vec!["e".into()] },
        ] {
            assert!(!bad.is_ok(), "{bad:?}");
        }
    }

    /// SINGLE-PASS verify: a manifest entry that is ABSENT from the bundle is
    /// reported in `bad_entries` (a truncated/partial transfer) rather than
    /// aborting the whole verify — the streaming pass walks the archive once and
    /// flags whatever the manifest claimed but the bundle never delivered.
    #[test]
    fn manifest_entry_absent_from_bundle_is_reported_bad() {
        let (_d, bundle) = make_bundle();
        // Add a phantom entry the bundle does not actually contain.
        let mut m = load_manifest(&bundle).unwrap();
        m.entries.push(EntryDigest {
            path: "linux-x86_64/ghost".into(),
            sha256: "00".repeat(32),
            len: 0,
        });
        std::fs::write(manifest_path(&bundle), serde_json::to_vec(&m).unwrap()).unwrap();

        let eng = TarZstd::new();
        // Single pass returns a report (not Err) that flags exactly the missing path.
        let report = verify_on_receipt(&bundle, &eng, None).unwrap();
        assert!(
            report.bad_entries.contains(&"linux-x86_64/ghost".to_string()),
            "phantom entry flagged: {report:?}"
        );
        assert!(!report.is_ok(), "a missing entry fails the gate");
        assert!(fetch_and_verify(&bundle, &eng).is_err());
    }

    /// A missing sidecar manifest is an honest error — never a silent pass.
    #[test]
    fn missing_manifest_errors() {
        let (_d, bundle) = make_bundle();
        std::fs::remove_file(manifest_path(&bundle)).unwrap();
        let eng = TarZstd::new();
        assert!(verify_on_receipt(&bundle, &eng, None).is_err());
        assert!(fetch_and_verify(&bundle, &eng).is_err());
    }
}
