// tests/m7_10_acceptance.rs --- T M7.10 three-package end-to-end test.
//
//! Acceptance suite for T M7.10 ("Three-package end-to-end test").
//! Spec acceptance bullets (`pmacs-tasks.tex:3502-3510`):
//!
//! 1. Three test packages published to a Git host (we use local
//!    bare repos, per the spec's allowance: "the test can use a
//!    local bare repo or a real host").
//! 2. User-A install produces a lockfile; user-B install with the
//!    same lockfile produces identical behavior.
//! 3. All three packages pass the audit lint with zero findings.
//! 4. Test runs in CI as part of the M7 acceptance pipeline.
//!
//! Bullet 4 is satisfied implicitly: this file is a `cargo test`
//! integration test under `tests/`, so any CI that runs
//! `cargo test --features luajit` (or `... --features lua54
//! --no-default-features`) executes it. The TRANSITION-M7.md M7.10
//! section names this contract.

use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;

use pmacs::audit::{AuditEngine, Severity};
use pmacs::lua::LuaHost;
use pmacs::lua_bindings::PackageInstallOverride;
use pmacs::packages::{
    Address, Fetcher, InstallPin, InstallScope, InstallSpec, Installer, Lockfile, ResolveRequest,
    Resolver, UpdatePolicy,
};
use semver::VersionReq;
use sha2::{Digest, Sha256};
use tempfile::TempDir;

// ---------------------------------------------------------------------------
// Fixture: three trivial packages
// ---------------------------------------------------------------------------
//
// Each package is published as a local bare git repo. The bodies
// are deliberately trivial *and* audit-clean (no fs writes, no
// process spawns, no FFI, no debug-table calls, no rawget/rawset
// against `_G`, no setfenv/getfenv). The acceptance lint check
// expects zero Error/Warning findings against all three.

const HELLO_WORLD_BODY: &str = r#"return {
  name = "hello-world",
  version = "1.0.0",
  greet = function()
    return "hello, world"
  end,
}
"#;

const FORTUNE_COOKIE_BODY: &str = r#"local fortunes = {
  "you will write good code today",
  "the next test you write will pass",
  "your future self will thank you",
}

return {
  name = "fortune-cookie",
  version = "1.0.0",
  tell = function(i)
    return fortunes[((i or 1) - 1) % #fortunes + 1]
  end,
}
"#;

// Date is hardcoded so the package stays deterministic (and audit-
// clean). `os.date` is allowed by the audit rules; we keep the
// fixture trivial regardless.
const DATE_PRINTER_BODY: &str = r#"return {
  name = "date-printer",
  version = "1.0.0",
  today = function()
    return "1970-01-01"
  end,
}
"#;

#[derive(Clone, Copy)]
struct PkgFixture {
    name: &'static str,
    body: &'static str,
}

const PKGS: &[PkgFixture] = &[
    PkgFixture {
        name: "hello-world",
        body: HELLO_WORLD_BODY,
    },
    PkgFixture {
        name: "fortune-cookie",
        body: FORTUNE_COOKIE_BODY,
    },
    PkgFixture {
        name: "date-printer",
        body: DATE_PRINTER_BODY,
    },
];

/// Build a single-package bare repo and return (`tmpdir-keepalive`,
/// path-to-bare-repo, path-to-working-tree). The working tree is
/// kept around because the audit lint runs against it directly
/// (the bare repo has no checked-out files).
fn make_pkg(pkg: &PkgFixture) -> (TempDir, PathBuf, PathBuf) {
    let td = tempfile::tempdir().expect("tempdir");
    let work = td.path().join("work");
    let bare = td.path().join("upstream.git");

    run_git(&[
        OsStr::new("init"),
        OsStr::new("--initial-branch=main"),
        work.as_os_str(),
    ]);
    run_git(&[
        OsStr::new("-C"),
        work.as_os_str(),
        OsStr::new("config"),
        OsStr::new("user.email"),
        OsStr::new("test@example.com"),
    ]);
    run_git(&[
        OsStr::new("-C"),
        work.as_os_str(),
        OsStr::new("config"),
        OsStr::new("user.name"),
        OsStr::new("Tester"),
    ]);

    let manifest = format!(
        "name = \"{name}\"\n\
         version = \"1.0.0\"\n\
         summary = \"M7.10 fixture: {name}\"\n\
         pmacs_required = \">= 0.1.0\"\n\
         entry = \"init.lua\"\n\
         exports = [\"{name}\"]\n",
        name = pkg.name,
    );
    std::fs::write(work.join("pmacs.toml"), manifest).expect("write manifest");
    std::fs::write(work.join("init.lua"), pkg.body).expect("write entry");

    run_git(&[
        OsStr::new("-C"),
        work.as_os_str(),
        OsStr::new("add"),
        OsStr::new("."),
    ]);
    run_git(&[
        OsStr::new("-C"),
        work.as_os_str(),
        OsStr::new("commit"),
        OsStr::new("-m"),
        OsStr::new("v1.0.0"),
    ]);
    run_git(&[
        OsStr::new("-C"),
        work.as_os_str(),
        OsStr::new("tag"),
        OsStr::new("v1.0.0"),
    ]);
    run_git(&[
        OsStr::new("clone"),
        OsStr::new("--bare"),
        work.as_os_str(),
        bare.as_os_str(),
    ]);
    (td, bare, work)
}

fn run_git(args: &[&OsStr]) {
    let mut cmd = Command::new("git");
    for a in args {
        cmd.arg(a);
    }
    cmd.env("GIT_TERMINAL_PROMPT", "0");
    cmd.env("LC_ALL", "C");
    let out = cmd.output().expect("git spawn");
    assert!(
        out.status.success(),
        "git {args:?} failed: {}",
        String::from_utf8_lossy(&out.stderr),
    );
}

fn file_url(p: &Path) -> String {
    format!("file://{}", p.display())
}

/// Install every package in `plan` through `installer`, pinning each
/// install to the plan's recorded commit. Returns the installed
/// roster in plan order. Panics on any install failure (acceptance
/// tests treat install failure as fatal).
fn install_plan(
    installer: &Installer,
    plan: &pmacs::packages::ResolvePlan,
) -> Vec<pmacs::packages::InstalledPackage> {
    let mut installed = Vec::with_capacity(plan.packages.len());
    for rp in &plan.packages {
        let spec = InstallSpec {
            address: rp.address.clone(),
            pin: InstallPin::Commit(rp.revision.clone()),
        };
        installed.push(installer.install(&spec).expect("install"));
    }
    installed
}

fn sha256_of_file(p: &Path) -> String {
    let bytes = std::fs::read(p).expect("read for hashing");
    let mut h = Sha256::new();
    h.update(&bytes);
    let digest = h.finalize();
    digest.iter().fold(String::new(), |mut s, b| {
        use std::fmt::Write;
        write!(&mut s, "{b:02x}").expect("hex");
        s
    })
}

// ---------------------------------------------------------------------------
// Bullet 1: three packages published; full pipeline succeeds
// ---------------------------------------------------------------------------

#[test]
fn three_packages_published_resolve_and_install() {
    let fixtures: Vec<_> = PKGS.iter().map(make_pkg).collect();

    let cache = tempfile::tempdir().expect("cache");
    let install_root = tempfile::tempdir().expect("install-root");
    let fetcher = Fetcher::with_cache_dir(cache.path().to_path_buf());

    let requests: Vec<ResolveRequest> = fixtures
        .iter()
        .map(|(_td, bare, _work)| ResolveRequest {
            address: Address::parse(&format!("git:{}", file_url(bare))).expect("address"),
            pin: InstallPin::Version(VersionReq::parse("^1.0.0").expect("req")),
        })
        .collect();

    let resolver = Resolver::new(fetcher);
    let plan = resolver.resolve(&requests).expect("resolve");
    assert_eq!(plan.packages.len(), 3, "expected three resolved packages");

    let names: Vec<&str> = plan.packages.iter().map(|p| p.name.as_str()).collect();
    for pkg in PKGS {
        assert!(
            names.contains(&pkg.name),
            "missing {} in plan: {names:?}",
            pkg.name
        );
    }

    let installer = Installer::new(
        Fetcher::with_cache_dir(cache.path().to_path_buf()),
        InstallScope::User,
    )
    .with_install_root_override(install_root.path().to_path_buf());

    for rp in &plan.packages {
        let spec = InstallSpec {
            address: rp.address.clone(),
            pin: InstallPin::Version(VersionReq::parse(&format!("={}", rp.version)).unwrap()),
        };
        let installed = installer.install(&spec).expect("install");
        assert_eq!(installed.manifest.name.as_str(), rp.name.as_str());
        assert!(
            installed.entry_path().exists(),
            "entry {:?} not on disk after install",
            installed.entry_path()
        );
    }
}

// ---------------------------------------------------------------------------
// Bullet 2: User-A install → lockfile → User-B install with that lockfile
// produces identical state
// ---------------------------------------------------------------------------

#[test]
fn user_a_lockfile_drives_user_b_to_identical_install_state() {
    // Both users see the same upstreams (bare repos in a third
    // tempdir we keep alive for the duration of the test).
    let fixtures: Vec<_> = PKGS.iter().map(make_pkg).collect();

    let requests: Vec<ResolveRequest> = fixtures
        .iter()
        .map(|(_td, bare, _work)| ResolveRequest {
            address: Address::parse(&format!("git:{}", file_url(bare))).expect("address"),
            pin: InstallPin::Version(VersionReq::parse("^1.0.0").expect("req")),
        })
        .collect();

    // ----- User-A: fresh install on machine A ------------------------------
    let cache_a = tempfile::tempdir().expect("cache-a");
    let install_a = tempfile::tempdir().expect("install-a");
    let project_a = tempfile::tempdir().expect("project-a");

    let resolver_a = Resolver::new(Fetcher::with_cache_dir(cache_a.path().to_path_buf()));
    let plan_a = resolver_a.resolve(&requests).expect("user-A resolve");
    let lock_a = Lockfile::from_plan(&plan_a, resolver_a.fetcher()).expect("user-A lockfile");
    let lock_a_path = project_a.path().join("pmacs.lock");
    lock_a
        .write_to(&lock_a_path)
        .expect("write user-A lockfile");

    let installer_a = Installer::new(
        Fetcher::with_cache_dir(cache_a.path().to_path_buf()),
        InstallScope::User,
    )
    .with_install_root_override(install_a.path().to_path_buf());

    let installed_a = install_plan(&installer_a, &plan_a);

    // ----- User-B: simulate a different machine ----------------------------
    // Distinct cache dir + distinct install root, BUT: the same
    // lockfile bytes copied verbatim from project-A. This is the
    // moral of "user-B opens user-A's repo and runs install".
    let cache_b = tempfile::tempdir().expect("cache-b");
    let install_b = tempfile::tempdir().expect("install-b");
    let project_b = tempfile::tempdir().expect("project-b");

    let lock_b_path = project_b.path().join("pmacs.lock");
    std::fs::copy(&lock_a_path, &lock_b_path).expect("copy lockfile A → B");

    // Read from disk on user-B's side, then drive a Frozen-policy
    // resolve. This exercises the same code path a real second user
    // would run: open their cwd, find pmacs.lock, install per it.
    let lock_b = Lockfile::read_from(&lock_b_path).expect("read user-B lockfile");
    let resolver_b = Resolver::new(Fetcher::with_cache_dir(cache_b.path().to_path_buf()));
    let plan_b = resolver_b
        .resolve_with_policy(&requests, Some(&lock_b), &UpdatePolicy::Frozen)
        .expect("user-B frozen resolve");

    let installer_b = Installer::new(
        Fetcher::with_cache_dir(cache_b.path().to_path_buf()),
        InstallScope::User,
    )
    .with_install_root_override(install_b.path().to_path_buf());

    let installed_b = install_plan(&installer_b, &plan_b);

    // ----- Identity assertions --------------------------------------------
    // 1. Lockfile bytes round-trip identically.
    let bytes_a = std::fs::read(&lock_a_path).expect("re-read user-A lockfile");
    let bytes_b = std::fs::read(&lock_b_path).expect("re-read user-B lockfile");
    assert_eq!(
        bytes_a, bytes_b,
        "user-A and user-B lockfile bytes must be identical"
    );

    // 2. Same set of packages, each pinned to the same commit and
    //    content hash.
    assert_eq!(installed_a.len(), 3);
    assert_eq!(installed_b.len(), 3);

    let by_name_a: HashMap<&str, &pmacs::packages::InstalledPackage> = installed_a
        .iter()
        .map(|p| (p.manifest.name.as_str(), p))
        .collect();
    let by_name_b: HashMap<&str, &pmacs::packages::InstalledPackage> = installed_b
        .iter()
        .map(|p| (p.manifest.name.as_str(), p))
        .collect();
    for pkg in PKGS {
        let a = by_name_a
            .get(pkg.name)
            .unwrap_or_else(|| panic!("user-A missing {}", pkg.name));
        let b = by_name_b
            .get(pkg.name)
            .unwrap_or_else(|| panic!("user-B missing {}", pkg.name));
        assert_eq!(a.commit, b.commit, "{} commit drift A→B", pkg.name);
        assert_eq!(
            a.manifest.version, b.manifest.version,
            "{} version drift A→B",
            pkg.name
        );

        // 3. The installed entry file (init.lua on disk) is byte-
        //    identical between A and B. Stronger than the lockfile's
        //    own content_hash check, because it asserts the
        //    *checked-out* tree on each machine matches.
        let hash_a = sha256_of_file(&a.entry_path());
        let hash_b = sha256_of_file(&b.entry_path());
        assert_eq!(
            hash_a,
            hash_b,
            "{} entry file hash drift A→B (paths: {:?} vs {:?})",
            pkg.name,
            a.entry_path(),
            b.entry_path()
        );
    }

    // 4. Lockfile entries match per-package commit and content hash.
    for pkg in PKGS {
        let a_entry = lock_a
            .packages
            .iter()
            .find(|e| e.name.as_str() == pkg.name)
            .unwrap_or_else(|| panic!("lockfile missing {}", pkg.name));
        let b_entry = lock_b
            .packages
            .iter()
            .find(|e| e.name.as_str() == pkg.name)
            .unwrap_or_else(|| panic!("lockfile B missing {}", pkg.name));
        assert_eq!(a_entry.commit, b_entry.commit);
        assert_eq!(a_entry.content_hash, b_entry.content_hash);
    }
}

// ---------------------------------------------------------------------------
// Bullet 3: three packages pass the audit lint with zero findings
// ---------------------------------------------------------------------------

#[test]
fn three_packages_pass_audit_lint_with_zero_findings() {
    let fixtures: Vec<_> = PKGS.iter().map(make_pkg).collect();

    let engine = AuditEngine::new().expect("audit engine");
    for ((pkg, _td, _bare, work), pkg_fixture) in fixtures
        .iter()
        .map(|(td, bare, work)| (td, td, bare, work))
        .zip(PKGS.iter())
    {
        let _ = pkg;
        // Lint the working tree (the bare repo has no checked-out
        // files; the package source the audit cares about is the
        // working tree at the tagged commit).
        let findings = engine
            .audit_dir(work)
            .unwrap_or_else(|e| panic!("audit_dir({work:?}) failed: {e}"));
        let bad: Vec<_> = findings
            .iter()
            .filter(|f| matches!(f.severity, Severity::Error | Severity::Warning))
            .collect();
        assert!(
            bad.is_empty(),
            "package `{}` should be audit-clean; got {:#?}",
            pkg_fixture.name,
            bad
        );
    }
}

// ---------------------------------------------------------------------------
// "Identical behavior" — exercised through the Lua loader.
// Both users observe the same return value when they require()
// each package and call the entry function. The Rust-side test
// above proved on-disk identity; this one proves the loader and
// Lua VM agree on what those bytes mean.
// ---------------------------------------------------------------------------

#[test]
fn three_packages_load_via_lua_require_and_return_expected_values() {
    let fixtures: Vec<_> = PKGS.iter().map(make_pkg).collect();

    let cache = tempfile::tempdir().expect("cache");
    let user_root = tempfile::tempdir().expect("user-root");
    let mut host = LuaHost::new().expect("LuaHost::new");
    host.set_package_install_override(
        PackageInstallOverride::new()
            .with_cache_dir(cache.path().to_path_buf())
            .with_user_install_root(user_root.path().to_path_buf()),
    );

    let urls: Vec<String> = fixtures
        .iter()
        .map(|(_td, bare, _work)| file_url(bare))
        .collect();

    let script = format!(
        r#"
        pmacs.packages.install {{ "git:{u_hello}", version = "^1.0.0" }}
        pmacs.packages.install {{ "git:{u_fortune}", version = "^1.0.0" }}
        pmacs.packages.install {{ "git:{u_date}", version = "^1.0.0" }}

        local hello = require("hello-world")
        assert(hello.greet() == "hello, world",
            "hello-world.greet() returned " .. tostring(hello.greet()))

        local fortune = require("fortune-cookie")
        assert(fortune.tell(1) == "you will write good code today",
            "fortune-cookie.tell(1) returned " .. tostring(fortune.tell(1)))

        local date = require("date-printer")
        assert(date.today() == "1970-01-01",
            "date-printer.today() returned " .. tostring(date.today()))

        return "ok"
    "#,
        u_hello = urls[0],
        u_fortune = urls[1],
        u_date = urls[2],
    );
    host.eval(Some("m7_10_acceptance"), &script)
        .unwrap_or_else(|e| panic!("three-package require/run failed: {e}"));
    assert!(host.errors().is_empty(), "errors: {:?}", host.errors());
}
