// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 vertexclique

//! End-to-end coverage for the NATIVE npm installer against a mock npm
//! registry (httpmock) - NO `npm` binary, NO process spawn, NO real
//! network. Proves the real install path: packument → version pick →
//! tarball download → integrity check → extract → transitive deps →
//! native rejection.

use afterburner_cloud::lock::Lockfile;
use afterburner_cloud::npm::NpmClient;
use flate2::write::GzEncoder;
use httpmock::prelude::*;
use sha1::{Digest, Sha1};
use std::collections::BTreeMap;
use std::io::Write;

/// Build a gzipped npm-style tarball (`package/<rel>` entries) and return
/// (bytes, sha1-hex) - the shasum the registry would advertise.
fn make_tarball(files: &[(&str, &[u8])]) -> (Vec<u8>, String) {
    let mut tar_buf = Vec::new();
    {
        let mut b = tar::Builder::new(&mut tar_buf);
        for (rel, body) in files {
            let mut h = tar::Header::new_gnu();
            h.set_size(body.len() as u64);
            h.set_mode(0o644);
            h.set_cksum();
            b.append_data(&mut h, format!("package/{rel}"), &body[..])
                .unwrap();
        }
        b.finish().unwrap();
    }
    let mut gz = Vec::new();
    {
        let mut e = GzEncoder::new(&mut gz, flate2::Compression::default());
        e.write_all(&tar_buf).unwrap();
        e.finish().unwrap();
    }
    let sha = hex(&Sha1::digest(&gz));
    (gz, sha)
}

fn hex(b: &[u8]) -> String {
    b.iter().map(|x| format!("{x:02x}")).collect()
}

#[test]
fn installs_a_package_with_transitive_dep_from_mock_registry() {
    let server = MockServer::start();

    // dependency `dep@1.2.0` (a leaf)
    let (dep_tar, dep_sha) = make_tarball(&[
        (
            "package.json",
            br#"{"name":"dep","version":"1.2.0","main":"index.js"}"#,
        ),
        ("index.js", b"module.exports = 7;"),
    ]);
    let dep_tarball_url = server.url("/dep/-/dep-1.2.0.tgz");
    server.mock(|when, then| {
        when.method(GET).path("/dep");
        then.status(200).json_body(serde_json::json!({
            "name": "dep",
            "versions": {
                "1.2.0": {
                    "name": "dep", "version": "1.2.0",
                    "dist": { "tarball": dep_tarball_url, "shasum": dep_sha }
                }
            }
        }));
    });
    server.mock(|when, then| {
        when.method(GET).path("/dep/-/dep-1.2.0.tgz");
        then.status(200).body(dep_tar);
    });

    // root `widget@2.0.1` depends on dep ^1.0.0
    let (w_tar, w_sha) = make_tarball(&[
        (
            "package.json",
            br#"{"name":"widget","version":"2.0.1","main":"index.js"}"#,
        ),
        ("index.js", b"module.exports = require('dep') + 1;"),
    ]);
    let w_tarball_url = server.url("/widget/-/widget-2.0.1.tgz");
    server.mock(|when, then| {
        when.method(GET).path("/widget");
        then.status(200).json_body(serde_json::json!({
            "name": "widget",
            "versions": {
                "1.5.0": {
                    "name": "widget", "version": "1.5.0",
                    "dist": { "tarball": "http://unused", "shasum": "00" },
                    "dependencies": { "dep": "^1.0.0" }
                },
                "2.0.1": {
                    "name": "widget", "version": "2.0.1",
                    "dist": { "tarball": w_tarball_url, "shasum": w_sha },
                    "dependencies": { "dep": "^1.0.0" }
                }
            }
        }));
    });
    server.mock(|when, then| {
        when.method(GET).path("/widget/-/widget-2.0.1.tgz");
        then.status(200).body(w_tar);
    });

    let client = NpmClient::new(server.base_url());
    let mut roots = BTreeMap::new();
    roots.insert("widget".to_string(), "^2.0.0".to_string()); // picks 2.0.1, not 1.5.0
    let res = client.resolve_all(&roots).expect("resolve");

    // both the root and its transitive dep are resolved + extracted
    let widget = res.by_name("widget").expect("widget resolved");
    assert_eq!(widget.version, "2.0.1");
    assert_eq!(
        widget.files.get("index.js").map(|v| v.as_slice()),
        Some(&b"module.exports = require('dep') + 1;"[..])
    );
    let dep = res.by_name("dep").expect("transitive dep resolved");
    assert_eq!(dep.version, "1.2.0");
    assert_eq!(
        dep.files.get("index.js").map(|v| v.as_slice()),
        Some(&b"module.exports = 7;"[..])
    );
}

#[test]
fn corrupt_tarball_fails_integrity_check() {
    let server = MockServer::start();
    let (_tar, real_sha) = make_tarball(&[("index.js", b"x")]);
    // advertise the real sha but serve different bytes
    let bad_tar = make_tarball(&[("index.js", b"TAMPERED")]).0;
    let url = server.url("/evil/-/evil-1.0.0.tgz");
    server.mock(|when, then| {
        when.method(GET).path("/evil");
        then.status(200).json_body(serde_json::json!({
            "name": "evil",
            "versions": { "1.0.0": { "name":"evil","version":"1.0.0",
                "dist": { "tarball": url, "shasum": real_sha } } }
        }));
    });
    server.mock(|when, then| {
        when.method(GET).path("/evil/-/evil-1.0.0.tgz");
        then.status(200).body(bad_tar);
    });

    let client = NpmClient::new(server.base_url());
    let mut roots = BTreeMap::new();
    roots.insert("evil".to_string(), "1.0.0".to_string());
    let err = client.resolve_all(&roots).unwrap_err();
    assert!(
        format!("{err}").contains("integrity"),
        "tampered tarball must fail integrity: {err}"
    );
}

#[test]
fn native_addon_in_npm_package_is_rejected() {
    let server = MockServer::start();
    let (tar, sha) = make_tarball(&[
        ("package.json", br#"{"name":"bcrypt","version":"5.0.0"}"#),
        ("index.js", b"module.exports = 1;"),
        ("build/Release/bcrypt.node", b"\0\0native"),
    ]);
    let url = server.url("/bcrypt/-/bcrypt-5.0.0.tgz");
    server.mock(|when, then| {
        when.method(GET).path("/bcrypt");
        then.status(200).json_body(serde_json::json!({
            "name": "bcrypt",
            "versions": { "5.0.0": { "name":"bcrypt","version":"5.0.0",
                "dist": { "tarball": url, "shasum": sha } } }
        }));
    });
    server.mock(|when, then| {
        when.method(GET).path("/bcrypt/-/bcrypt-5.0.0.tgz");
        then.status(200).body(tar);
    });

    let client = NpmClient::new(server.base_url());
    let mut roots = BTreeMap::new();
    roots.insert("bcrypt".to_string(), "^5.0.0".to_string());
    let err = client.resolve_all(&roots).unwrap_err();
    assert!(
        format!("{err}").to_lowercase().contains("native"),
        "native addon must be rejected: {err}"
    );
}

#[test]
fn missing_package_is_a_clear_error() {
    let server = MockServer::start();
    server.mock(|when, then| {
        when.method(GET).path("/nope");
        then.status(404).body("not found");
    });
    let client = NpmClient::new(server.base_url());
    let mut roots = BTreeMap::new();
    roots.insert("nope".to_string(), "*".to_string());
    let err = client.resolve_all(&roots).unwrap_err();
    assert!(format!("{err}").contains("not found"), "got: {err}");
}

#[test]
fn conflicting_dep_ranges_resolve_as_two_versions_with_nested_override() {
    let server = MockServer::start();

    // c@1.0.0 and c@2.0.0 - the contested dependency.
    let (c1_tar, c1_sha) = make_tarball(&[
        ("package.json", br#"{"name":"c","version":"1.0.0"}"#),
        ("index.js", b"module.exports = 1;"),
    ]);
    let (c2_tar, c2_sha) = make_tarball(&[
        ("package.json", br#"{"name":"c","version":"2.0.0"}"#),
        ("index.js", b"module.exports = 2;"),
    ]);
    let c1_url = server.url("/c/-/c-1.0.0.tgz");
    let c2_url = server.url("/c/-/c-2.0.0.tgz");
    server.mock(|when, then| {
        when.method(GET).path("/c");
        then.status(200).json_body(serde_json::json!({
            "name": "c",
            "versions": {
                "1.0.0": { "name":"c","version":"1.0.0",
                    "dist": { "tarball": c1_url, "shasum": c1_sha } },
                "2.0.0": { "name":"c","version":"2.0.0",
                    "dist": { "tarball": c2_url, "shasum": c2_sha } },
            }
        }));
    });
    server.mock(|when, then| {
        when.method(GET).path("/c/-/c-1.0.0.tgz");
        then.status(200).body(c1_tar);
    });
    server.mock(|when, then| {
        when.method(GET).path("/c/-/c-2.0.0.tgz");
        then.status(200).body(c2_tar);
    });

    // a → c@^1, b → c@^2 (the flat-resolver conflict case).
    for (name, range) in [("a", "^1.0.0"), ("b", "^2.0.0")] {
        let (tar, sha) = make_tarball(&[
            (
                "package.json",
                format!(
                    r#"{{"name":"{name}","version":"1.0.0","dependencies":{{"c":"{range}"}}}}"#
                )
                .as_bytes(),
            ),
            ("index.js", b"module.exports = require('c');"),
        ]);
        let url = server.url(format!("/{name}/-/{name}-1.0.0.tgz"));
        let path = format!("/{name}");
        let tgz_path = format!("/{name}/-/{name}-1.0.0.tgz");
        let body = serde_json::json!({
            "name": name,
            "versions": { "1.0.0": { "name": name, "version": "1.0.0",
                "dependencies": { "c": range },
                "dist": { "tarball": url, "shasum": sha } } }
        });
        server.mock(move |when, then| {
            when.method(GET).path(path);
            then.status(200).json_body(body);
        });
        server.mock(move |when, then| {
            when.method(GET).path(tgz_path);
            then.status(200).body(tar);
        });
    }

    let client = NpmClient::new(server.base_url());
    let mut roots = BTreeMap::new();
    roots.insert("a".to_string(), "^1.0.0".to_string());
    roots.insert("b".to_string(), "^1.0.0".to_string());
    let res = client
        .resolve_all(&roots)
        .expect("conflicting ranges must coexist");

    // Both versions of c are in the closure; a's pick is hoisted (BFS,
    // roots first, "a" before "b" in BTreeMap order).
    let c_versions: Vec<&str> = res
        .packages
        .iter()
        .filter(|p| p.name == "c")
        .map(|p| p.version.as_str())
        .collect();
    assert_eq!(c_versions, ["1.0.0", "2.0.0"]);
    assert_eq!(res.hoisted.get("c").map(String::as_str), Some("1.0.0"));
    assert_eq!(
        res.edges
            .get("b@1.0.0")
            .and_then(|d| d.get("c"))
            .map(String::as_str),
        Some("2.0.0")
    );

    // Materialize: b becomes a real dir owning a nested override of c.
    let dir = std::env::temp_dir().join(format!("burn-npm-nest-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    for pkg in &res.packages {
        afterburner_cloud::npm::store_npm(pkg).unwrap();
    }
    afterburner_cloud::npm::link_node_modules(&res, &dir).unwrap();

    let nm = dir.join("node_modules");
    assert!(nm.join("a").exists(), "a placed at top level");
    assert_eq!(
        std::fs::read(nm.join("c/index.js")).unwrap(),
        b"module.exports = 1;",
        "hoisted c is 1.0.0"
    );
    let nested = nm.join("b/node_modules/c/index.js");
    assert_eq!(
        std::fs::read(&nested).unwrap(),
        b"module.exports = 2;",
        "b's override is nested under b"
    );
    assert!(
        !nm.join("b").is_symlink(),
        "a package owning overrides is a copy, not a cache symlink"
    );
    assert!(
        nm.join("a").is_symlink(),
        "an unconflicted package stays a cache symlink"
    );

    let _ = std::fs::remove_dir_all(&dir);
}

// ---- G1: resolution carries integrity into lock pins -------------------------

/// Verify that the resolved packages carry their SRI integrity string so the
/// caller can record them in `burn.lock` `[[npm]]` for reproducible installs.
#[test]
fn resolved_packages_carry_integrity_for_lock() {
    let server = MockServer::start();
    let (tar, sha) = make_tarball(&[("index.js", b"module.exports = 1;")]);
    let url = server.url("/widget/-/widget-1.0.0.tgz");
    server.mock(|when, then| {
        when.method(GET).path("/widget");
        then.status(200).json_body(serde_json::json!({
            "name": "widget",
            "versions": {
                "1.0.0": {
                    "name": "widget", "version": "1.0.0",
                    "dist": { "tarball": url, "shasum": &sha }
                }
            }
        }));
    });
    server.mock(|when, then| {
        when.method(GET).path("/widget/-/widget-1.0.0.tgz");
        then.status(200).body(tar);
    });

    let client = NpmClient::new(server.base_url());
    let mut roots = BTreeMap::new();
    roots.insert("widget".to_string(), "^1.0.0".to_string());
    let res = client.resolve_all(&roots).expect("resolve");

    let pkg = res.by_name("widget").expect("widget resolved");
    assert!(
        !pkg.integrity.is_empty(),
        "resolved package must carry a non-empty integrity string"
    );
    // sha1 hex for a legacy shasum (no dist.integrity in the mock above).
    assert_eq!(
        &pkg.integrity, &sha,
        "integrity must match the registry shasum"
    );

    // Verify npm_pins_from_resolution records it.
    let pins = Lockfile::npm_pins_from_resolution(&res);
    assert_eq!(pins.len(), 1);
    assert_eq!(pins[0].name, "widget");
    assert_eq!(pins[0].version, "1.0.0");
    assert_eq!(pins[0].integrity, sha);
}

/// When a package carries `dist.integrity` (sha512 SRI), that is stored in the
/// lock - not the sha1 shasum - so the lock uses the stronger hash (closes G1 + G2).
#[test]
fn sri_integrity_is_preferred_over_shasum_in_lock() {
    use sha2::{Digest as _, Sha512};
    let server = MockServer::start();
    let (tar, sha1) = make_tarball(&[("index.js", b"module.exports = 42;")]);
    // Compute a real sha512 SRI for the tarball.
    let sha512_b64 = {
        let hash = Sha512::digest(&tar);
        let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
        let mut out = String::new();
        for chunk in hash.chunks(3) {
            let (a, b, c) = (
                chunk[0],
                chunk.get(1).copied().unwrap_or(0),
                chunk.get(2).copied().unwrap_or(0),
            );
            let n = (u32::from(a) << 16) | (u32::from(b) << 8) | u32::from(c);
            out.push(alphabet[((n >> 18) & 0x3F) as usize] as char);
            out.push(alphabet[((n >> 12) & 0x3F) as usize] as char);
            if chunk.len() > 1 {
                out.push(alphabet[((n >> 6) & 0x3F) as usize] as char);
            } else {
                out.push('=');
            }
            if chunk.len() > 2 {
                out.push(alphabet[(n & 0x3F) as usize] as char);
            } else {
                out.push('=');
            }
        }
        out
    };
    let sri = format!("sha512-{sha512_b64}");
    let url = server.url("/sri-pkg/-/sri-pkg-2.0.0.tgz");
    server.mock(|when, then| {
        when.method(GET).path("/sri-pkg");
        then.status(200).json_body(serde_json::json!({
            "name": "sri-pkg",
            "versions": {
                "2.0.0": {
                    "name": "sri-pkg", "version": "2.0.0",
                    "dist": { "tarball": url, "shasum": &sha1, "integrity": &sri }
                }
            }
        }));
    });
    server.mock(|when, then| {
        when.method(GET).path("/sri-pkg/-/sri-pkg-2.0.0.tgz");
        then.status(200).body(tar);
    });

    let client = NpmClient::new(server.base_url());
    let mut roots = BTreeMap::new();
    roots.insert("sri-pkg".to_string(), "^2.0.0".to_string());
    let res = client.resolve_all(&roots).expect("resolve");
    let pkg = res.by_name("sri-pkg").expect("resolved");

    // The SRI (sha512) field must win over the sha1 shasum.
    assert_eq!(
        pkg.integrity, sri,
        "sha512 SRI must be preferred over sha1 shasum"
    );
    let pins = Lockfile::npm_pins_from_resolution(&res);
    assert_eq!(pins[0].integrity, sri, "lock must record sha512 SRI");
}
