#!/usr/bin/env python3
"""Offline npm ci reproduction using only fictional local package archives.

Run with Python 3, Node.js and npm installed (tested on npm 11.6.2/macOS).
Creates and removes a temporary project and npm cache. No registry downloads,
credentials, existing project paths, global installs or lifecycle execution.
"""
import datetime
import json
import os
import pathlib
import platform
import shutil
import subprocess
import tempfile


def main():
    npm, node = shutil.which("npm"), shutil.which("node")
    if not npm or not node:
        raise SystemExit("Install Node.js and npm before running this reproduction.")
    env = {k: v for k, v in os.environ.items()
           if not k.lower().startswith("npm_config_") and k not in ("NODE_OPTIONS", "NODE_ENV")}
    with tempfile.TemporaryDirectory(prefix="softwareradar-npm-ci-") as scratch:
        base = pathlib.Path(scratch)
        project, archives = base / "project", base / "archives"
        project.mkdir()
        archives.mkdir()
        user_config, global_config = base / "user.npmrc", base / "global.npmrc"
        user_config.write_text("", encoding="utf-8")
        global_config.write_text("", encoding="utf-8")
        flags = ["--offline", "--ignore-scripts", "--no-audit", "--no-fund",
                 "--cache", str(base / "cache"), "--userconfig", str(user_config),
                 "--globalconfig", str(global_config)]

        def run(args, cwd=project, expected=0):
            result = subprocess.run([npm, *args, *flags], cwd=cwd, env=env,
                                    capture_output=True, text=True, timeout=30)
            if result.returncode != expected:
                raise AssertionError({"arguments": args, "returncode": result.returncode,
                                      "stdout": result.stdout, "stderr": result.stderr})
            return result

        npm_version = run(["--version"]).stdout.strip()
        node_version = subprocess.check_output([node, "--version"], env=env, text=True).strip()
        # Pack our own inert fixture files; never fetch or run a third-party package.
        def pack(name, version):
            folder = base / (name + "-" + version)
            folder.mkdir()
            (folder / "package.json").write_text(json.dumps({"name": name, "version": version,
                                                            "main": "index.js"}), encoding="utf-8")
            (folder / "index.js").write_text("module.exports = 'fictional fixture';\n", encoding="utf-8")
            result = run(["pack", "--json", "--pack-destination", str(archives)], cwd=folder)
            filename = json.loads(result.stdout)[0]["filename"]
            assert (archives / filename).is_file()
            return "file:../archives/" + filename

        runtime = pack("sr-demo-runtime", "1.0.0")
        tool_one = pack("sr-demo-tool", "1.0.0")
        tool_two = pack("sr-demo-tool", "2.0.0")
        manifest = {"name": "softwareradar-install-fixture", "version": "1.0.0", "private": True,
                    "dependencies": {"sr-demo-runtime": runtime},
                    "devDependencies": {"sr-demo-tool": tool_one},
                    "scripts": {"postinstall": "node -e \"require('node:fs').writeFileSync('lifecycle-ran.txt','fixture')\""}}
        manifest_path, lock_path = project / "package.json", project / "package-lock.json"
        manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
        run(["install", "--package-lock-only"])
        original_manifest, original_lock = manifest_path.read_bytes(), lock_path.read_bytes()
        modules = project / "node_modules"

        def installed(name):
            file = modules / name / "package.json"
            return json.loads(file.read_text())["version"] if file.is_file() else None

        def state():
            lock = json.loads(lock_path.read_text())
            return {"runtime_installed": installed("sr-demo-runtime"),
                    "dev_tool_installed": installed("sr-demo-tool"),
                    "dev_tool_locked": lock["packages"]["node_modules/sr-demo-tool"]["version"],
                    "stale_marker_exists": (modules / "stale-marker.txt").exists(),
                    "lifecycle_marker_exists": (project / "lifecycle-ran.txt").exists(),
                    "manifest_unchanged": manifest_path.read_bytes() == original_manifest,
                    "lock_unchanged": lock_path.read_bytes() == original_lock}

        modules.mkdir()
        (modules / "stale-marker.txt").write_text("fictional old install", encoding="utf-8")
        normal_result = run(["ci"])
        normal = {"exit_code": normal_result.returncode, **state()}
        assert normal == {"exit_code": 0, "runtime_installed": "1.0.0", "dev_tool_installed": "1.0.0",
                          "dev_tool_locked": "1.0.0", "stale_marker_exists": False,
                          "lifecycle_marker_exists": False, "manifest_unchanged": True, "lock_unchanged": True}

        (modules / "stale-marker.txt").write_text("another fictional old install", encoding="utf-8")
        omit_result = run(["ci", "--omit=dev"])
        omitted = {"exit_code": omit_result.returncode, **state()}
        assert omitted == {**normal, "dev_tool_installed": None}

        manifest["devDependencies"]["sr-demo-tool"] = tool_two
        manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
        changed_manifest = manifest_path.read_bytes()
        failure = run(["ci"], expected=1)
        mismatch = {"exit_code": failure.returncode, "error_code": "EUSAGE" if "EUSAGE" in failure.stderr else "other",
                    "manifest_preserved": manifest_path.read_bytes() == changed_manifest,
                    "lock_preserved": lock_path.read_bytes() == original_lock,
                    "locked_tool_version": json.loads(lock_path.read_text())["packages"]["node_modules/sr-demo-tool"]["version"],
                    "requested_tool_archive": "sr-demo-tool-2.0.0.tgz"}
        assert mismatch["error_code"] == "EUSAGE" and mismatch["manifest_preserved"] and mismatch["lock_preserved"]
        assert mismatch["locked_tool_version"] == "1.0.0"

        # Deliberately reconcile the fictional project, then repeat the install.
        run(["install", "--package-lock-only"])
        updated_lock = lock_path.read_bytes()
        run(["ci", "--omit=dev"])
        reconciled = {"runtime_installed": installed("sr-demo-runtime"),
                      "dev_tool_installed": installed("sr-demo-tool"),
                      "dev_tool_locked": json.loads(lock_path.read_text())["packages"]["node_modules/sr-demo-tool"]["version"],
                      "lock_changed_during_reconciliation": updated_lock != original_lock,
                      "lock_preserved_by_final_ci": lock_path.read_bytes() == updated_lock,
                      "manifest_preserved": manifest_path.read_bytes() == changed_manifest,
                      "lifecycle_marker_exists": (project / "lifecycle-ran.txt").exists()}
        assert reconciled == {"runtime_installed": "1.0.0", "dev_tool_installed": None,
                              "dev_tool_locked": "2.0.0", "lock_changed_during_reconciliation": True,
                              "lock_preserved_by_final_ci": True, "manifest_preserved": True,
                              "lifecycle_marker_exists": False}
        output = {"schema_version": 1, "observed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
                  "npm_version": npm_version, "node_version": node_version, "python_version": platform.python_version(),
                  "fixture": "Two fictional local packages, with a second version of the development tool; no external dependencies.",
                  "commands_use": ["offline", "ignore-scripts", "no-audit", "no-fund", "isolated cache and config"],
                  "aligned_ci": normal, "omit_dev_ci": omitted, "mismatched_ci": mismatch,
                  "reconciled_then_ci": reconciled,
                  "scope": "Temporary local project and cache cleaned at exit. No registry, login, publication, global install or existing project changes."}
    print(json.dumps(output, indent=2))


if __name__ == "__main__":
    main()
