import {
  lstat,
  mkdir,
  mkdtemp,
  readFile,
  readdir,
  rename,
  rm,
  writeFile,
} from "node:fs/promises";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath, pathToFileURL } from "node:url";
import { zipSync } from "fflate";
import { checksumLine, sha256 } from "./checksum-release.mjs";
import { assertNoUnexpectedLocalPaths } from "./release-path-hygiene.mjs";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
const gplVersion3TextSha256 =
  "fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0";
const expectedApplicationVersion = "0.4.0";
const requiredRootFiles = [
  "LICENSE",
  "README.md",
  "CHANGELOG.md",
  "SOURCE.md",
  "THIRD_PARTY_NOTICES.md",
];

function compareText(left, right) {
  return left < right ? -1 : left > right ? 1 : 0;
}

export function safeArchivePath(fileName) {
  if (
    typeof fileName !== "string" ||
    fileName.length === 0 ||
    fileName.includes("\0") ||
    fileName.includes("\\") ||
    fileName.startsWith("/") ||
    /^[A-Za-z]:\//u.test(fileName)
  ) {
    throw new Error(`Invalid release path: ${String(fileName)}`);
  }
  const parts = fileName.split("/");
  if (
    parts.some(
      (component) =>
        component === "" || component === "." || component === "..",
    ) ||
    path.posix.normalize(fileName) !== fileName
  ) {
    throw new Error(`Non-canonical release path: ${fileName}`);
  }
  return fileName;
}

async function collectDirectory(directory, prefix = "") {
  const result = [];
  const visit = async (current) => {
    const children = await readdir(current, { withFileTypes: true });
    children.sort((left, right) => compareText(left.name, right.name));
    for (const child of children) {
      const absolute = path.join(current, child.name);
      const details = await lstat(absolute);
      if (details.isSymbolicLink()) {
        throw new Error(`Release input contains a symlink: ${absolute}`);
      }
      if (details.isDirectory()) {
        await visit(absolute);
      } else if (details.isFile()) {
        const relative = path
          .relative(directory, absolute)
          .split(path.sep)
          .join("/");
        result.push({
          name: safeArchivePath(
            prefix ? path.posix.join(prefix, relative) : relative,
          ),
          data: await readFile(absolute),
        });
      } else {
        throw new Error(`Release input contains a special file: ${absolute}`);
      }
    }
  };
  await visit(directory);
  return result;
}

function safePackageDirectory(name, version) {
  const normalized = `${name.replace(/^@/u, "").replaceAll("/", "__")}-${
    version
  }`;
  if (!/^[A-Za-z0-9._@+-]+$/u.test(normalized)) {
    throw new Error(`Unsafe package identity: ${name}@${version}`);
  }
  return normalized;
}

async function collectRuntimePackages(packageJson) {
  const packages = new Map();
  const visit = async (name, fromDirectory) => {
    const resolver = createRequire(path.join(fromDirectory, "package.json"));
    let packagePath;
    try {
      packagePath = resolver.resolve(`${name}/package.json`);
    } catch {
      const entry = resolver.resolve(name);
      let directory = path.dirname(entry);
      while (directory !== path.dirname(directory)) {
        const candidate = path.join(directory, "package.json");
        const details = await lstat(candidate).catch(() => null);
        if (details?.isFile()) {
          const candidateJson = JSON.parse(await readFile(candidate, "utf8"));
          if (candidateJson.name === name) {
            packagePath = candidate;
            break;
          }
        }
        directory = path.dirname(directory);
      }
    }
    if (!packagePath) {
      throw new Error(`Could not resolve runtime package ${name}.`);
    }
    const details = JSON.parse(await readFile(packagePath, "utf8"));
    const key = `${details.name}@${details.version}`;
    if (packages.has(key)) return;
    const directory = path.dirname(packagePath);
    packages.set(key, { directory, details });
    for (const dependency of Object.keys(details.dependencies ?? {}).sort()) {
      await visit(dependency, directory);
    }
  };
  for (const dependency of Object.keys(packageJson.dependencies ?? {}).sort()) {
    await visit(dependency, root);
  }
  return [...packages.values()].sort((left, right) =>
    compareText(
      `${left.details.name}@${left.details.version}`,
      `${right.details.name}@${right.details.version}`,
    ),
  );
}

async function collectRuntimeLicenceEntries(packageJson) {
  const packages = await collectRuntimePackages(packageJson);
  const entries = [];
  const rows = [
    "# Runtime npm dependency licences",
    "",
    "| Package | Version | Licence | Repository |",
    "| --- | --- | --- | --- |",
  ];
  for (const runtimePackage of packages) {
    const { details, directory } = runtimePackage;
    const legalFiles = (await readdir(directory, { withFileTypes: true }))
      .filter(
        (entry) =>
          entry.isFile() &&
          /^(?:licen[cs]e|copying|notice)(?:\.|$)/iu.test(entry.name),
      )
      .sort((left, right) => compareText(left.name, right.name));
    if (legalFiles.length === 0) {
      throw new Error(
        `Runtime package ${details.name}@${details.version} has no licence file.`,
      );
    }
    const packageDirectory = safePackageDirectory(
      details.name,
      details.version,
    );
    for (const legalFile of legalFiles) {
      entries.push({
        name: safeArchivePath(
          `LICENSES/npm/${packageDirectory}/${legalFile.name}`,
        ),
        data: await readFile(path.join(directory, legalFile.name)),
      });
    }
    const repository =
      typeof details.repository === "string"
        ? details.repository
        : (details.repository?.url ?? "");
    rows.push(
      `| \`${details.name}\` | ${details.version} | ${
        details.license ?? "See packaged licence"
      } | ${String(repository).replaceAll("|", "\\|")} |`,
    );
  }
  entries.push({
    name: "LICENSES/npm/DEPENDENCIES.md",
    data: Buffer.from(`${rows.join("\n")}\n`),
  });
  return entries;
}

async function collectBuildGeneratedLicenceEntries() {
  const resolver = createRequire(path.join(root, "package.json"));
  const dependencies = [
    {
      name: "rolldown",
      version: "1.1.5",
      license: "MIT",
      legalFile: "LICENSE",
    },
    {
      name: "vite",
      version: "8.1.5",
      license: "MIT",
      legalFile: "LICENSE.md",
    },
  ];
  const entries = [];
  for (const dependency of dependencies) {
    const packagePath = resolver.resolve(`${dependency.name}/package.json`);
    const details = JSON.parse(await readFile(packagePath, "utf8"));
    if (
      details.name !== dependency.name ||
      details.version !== dependency.version ||
      details.license !== dependency.license
    ) {
      throw new Error(
        `Build dependency identity drifted: expected ${dependency.name}@${dependency.version} (${dependency.license}).`,
      );
    }
    const legalPath = path.join(
      path.dirname(packagePath),
      dependency.legalFile,
    );
    const legalDetails = await lstat(legalPath);
    if (legalDetails.isSymbolicLink() || !legalDetails.isFile()) {
      throw new Error(
        `Build dependency legal file is not a regular file: ${legalPath}.`,
      );
    }
    entries.push({
      name: safeArchivePath(
        `LICENSES/build/${safePackageDirectory(
          details.name,
          details.version,
        )}/${dependency.legalFile}`,
      ),
      data: await readFile(legalPath),
    });
  }
  return entries;
}

function scanEntry(entry) {
  if (entry.name.endsWith(".map")) {
    throw new Error(`Source map is not approved for release: ${entry.name}`);
  }
  const components = entry.name.toLowerCase().split("/");
  if (
    components.some((component) =>
      [
        ".env",
        ".npmrc",
        ".git",
        "credentials",
        "id_rsa",
        "id_ed25519",
      ].includes(component),
    ) ||
    /\.(?:key|pem|p12|pfx)$/iu.test(entry.name)
  ) {
    throw new Error(`Credential-like release path: ${entry.name}`);
  }
  const knownVirtualHomes =
    /^(?:assets\/php\.worker-[A-Za-z0-9_-]+\.js|engines\/cpp\/cpp-regex\.mjs|engines\/dotnet\/_framework\/dotnet\.native\.[a-z0-9]+\.js|engines\/perl\/emperl\.js|engines\/php\/engine-metadata\.json|engines\/php\/php-loader\.mjs|engines\/python\/pyodide(?:\.asm)?\.mjs)$/u.test(
      entry.name,
    )
      ? ["/home/web_user", "/home/pyodide"]
      : [];
  assertNoUnexpectedLocalPaths(entry.data, entry.name, {
    allowedVirtualHomes: knownVirtualHomes,
  });
  if (!/\.(?:css|html|js|json|md|mjs|pl|rst|svg|txt)$/iu.test(entry.name)) {
    return;
  }
  const text = entry.data.toString("utf8");
  if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/u.test(text)) {
    throw new Error(`Private key material detected in ${entry.name}`);
  }
  if (/https?:\/\/[^/@\s]+:[^/@\s]+@/iu.test(text)) {
    throw new Error(`Credential-bearing URL detected in ${entry.name}`);
  }
}

export function createDeterministicZip(inputEntries) {
  const entries = inputEntries
    .map((entry) => ({
      name: safeArchivePath(entry.name),
      data: Buffer.from(entry.data),
    }))
    .sort((left, right) => compareText(left.name, right.name));
  const seen = new Set();
  const zippable = Object.create(null);
  let totalBytes = 0;
  for (const entry of entries) {
    if (seen.has(entry.name)) {
      throw new Error(`Duplicate release path: ${entry.name}`);
    }
    seen.add(entry.name);
    totalBytes += entry.data.byteLength;
    if (entry.data.byteLength > 128 * 1024 * 1024) {
      throw new Error(`Release entry is too large: ${entry.name}`);
    }
    if (totalBytes > 512 * 1024 * 1024) {
      throw new Error("Release exceeds the 512 MiB uncompressed limit.");
    }
    scanEntry(entry);
    zippable[entry.name] = [
      new Uint8Array(entry.data),
      {
        attrs: (0o100644 << 16) >>> 0,
        level: 9,
        mtime: zipEpoch,
        os: 3,
      },
    ];
  }
  return Buffer.from(
    zipSync(zippable, {
      attrs: (0o100644 << 16) >>> 0,
      level: 9,
      mtime: zipEpoch,
      os: 3,
    }),
  );
}

async function collectReleaseEntries() {
  const packageJson = JSON.parse(
    await readFile(path.join(root, "package.json"), "utf8"),
  );
  if (
    packageJson.version !== expectedApplicationVersion ||
    packageJson.license !== "GPL-3.0-or-later" ||
    packageJson.repository?.url !==
      "git+https://git.add-ideas.de/zemion/regex-tools.git"
  ) {
    throw new Error("Package version, licence or source identity drifted.");
  }
  const sourceIdentity = await readFile(path.join(root, "SOURCE.md"), "utf8");
  if (
    !sourceIdentity.includes(
      `- Release version: \`${expectedApplicationVersion}\``,
    ) ||
    !sourceIdentity.includes(
      `- Release tag: \`v${expectedApplicationVersion}\``,
    ) ||
    !sourceIdentity.includes(
      "- Repository: <https://git.add-ideas.de/zemion/regex-tools>",
    )
  ) {
    throw new Error("SOURCE.md release identity drifted.");
  }
  const licence = await readFile(path.join(root, "LICENSE"), "utf8");
  if (
    !licence.includes("GNU GENERAL PUBLIC LICENSE") ||
    !licence.includes("Version 3, 29 June 2007") ||
    !licence.includes("END OF TERMS AND CONDITIONS") ||
    sha256(Buffer.from(licence)) !== gplVersion3TextSha256
  ) {
    throw new Error(
      "Root LICENSE is not the exact complete GPL version 3 text.",
    );
  }
  const entries = await collectDirectory(path.join(root, "dist"));
  entries.push(
    ...(await collectDirectory(path.join(root, "LICENSES"), "LICENSES")),
  );
  entries.push(...(await collectRuntimeLicenceEntries(packageJson)));
  entries.push(...(await collectBuildGeneratedLicenceEntries()));
  for (const fileName of requiredRootFiles) {
    entries.push({
      name: fileName,
      data: await readFile(path.join(root, fileName)),
    });
  }
  const names = new Set(entries.map((entry) => entry.name));
  for (const required of [
    "index.html",
    "toolbox-app.json",
    "favicon.svg",
    "LICENSE",
    "CHANGELOG.md",
    "SOURCE.md",
    "THIRD_PARTY_NOTICES.md",
    "engines/README.md",
    "engines/java/LICENSE.txt",
    "engines/java/NOTICE.txt",
    "engines/java/SHA256SUMS",
    "engines/java/engine-metadata.json",
    "engines/java/java-regex.mjs",
    "engines/cpp/LICENSE-Emscripten.txt",
    "engines/cpp/LICENSE-compiler-rt.txt",
    "engines/cpp/LICENSE-libcxx.txt",
    "engines/cpp/LICENSE-libcxxabi.txt",
    "engines/cpp/LICENSE-libunwind.txt",
    "engines/cpp/LICENSE-musl.txt",
    "engines/cpp/SHA256SUMS",
    "engines/cpp/SOURCE-MANIFEST.json",
    "engines/cpp/cpp-regex.mjs",
    "engines/cpp/cpp-regex.wasm",
    "engines/cpp/engine-metadata.json",
    "engines/dotnet/LICENSE-dotnet.txt",
    "engines/dotnet/SHA256SUMS",
    "engines/dotnet/THIRD-PARTY-NOTICES.txt",
    "engines/dotnet/_framework/dotnet.js",
    "engines/dotnet/engine-metadata.json",
    "engines/go/LICENSE-Go.txt",
    "engines/go/SHA256SUMS",
    "engines/go/engine-metadata.json",
    "engines/go/go-regex.wasm",
    "engines/go/wasm_exec.mjs",
    "engines/perl/LICENSE-WebPerl-Artistic.txt",
    "engines/perl/LICENSE-WebPerl-GPL.txt",
    "engines/perl/LICENSE-Devel-StackTrace-Artistic-2.0.txt",
    "engines/perl/LICENSE-Emscripten.txt",
    "engines/perl/NOTICE.txt",
    "engines/perl/SHA256SUMS",
    "engines/perl/emperl.data",
    "engines/perl/emperl.js",
    "engines/perl/emperl.wasm",
    "engines/perl/engine-metadata.json",
    "engines/perl/perl-runtime.worker.js",
    "engines/perl/regex_tools_bridge.pl",
    "engines/php/LICENSE.Emscripten-4.0.19.txt",
    "engines/php/LICENSE.Oniguruma-6.9.10.txt",
    "engines/php/LICENSE.OpenSSL-1.1.1t.txt",
    "engines/php/LICENSE.PCRE2-10.44.txt",
    "engines/php/LICENSE.PHP-4.0.txt",
    "engines/php/LICENSE.PHP-CLI-http-parser-MIT.txt",
    "engines/php/LICENSE.PHP-Lexbor-Apache-2.0.txt",
    "engines/php/LICENSE.PHP-Zend-2.0.txt",
    "engines/php/LICENSE.PHP-bcmath-LGPL-2.1.txt",
    "engines/php/LICENSE.PHP-libavifinfo-BSD-2-Clause.txt",
    "engines/php/LICENSE.PHP-libmagic-BSD.txt",
    "engines/php/LICENSE.PHP-libmbfl-LGPL-2.1.txt",
    "engines/php/LICENSE.PHP-timelib-MIT.txt",
    "engines/php/LICENSE.PHP-uriparser-BSD-3-Clause.txt",
    "engines/php/LICENSE.curl-7.69.1.txt",
    "engines/php/LICENSE.ini-ISC.txt",
    "engines/php/LICENSE.libaom-3.12.1.txt",
    "engines/php/LICENSE.libavif-1.3.0.txt",
    "engines/php/LICENSE.libcxx.txt",
    "engines/php/LICENSE.libcxxabi.txt",
    "engines/php/LICENSE.libgd-2.3.3.txt",
    "engines/php/LICENSE.libiconv-1.17-LGPL-2.1.txt",
    "engines/php/LICENSE.libjpeg-turbo-3.0.3.txt",
    "engines/php/LICENSE.libpng-1.6.39.txt",
    "engines/php/LICENSE.libwebp.txt",
    "engines/php/LICENSE.libxml2-2.9.10.txt",
    "engines/php/LICENSE.libyuv.txt",
    "engines/php/LICENSE.libzip-1.9.2.txt",
    "engines/php/LICENSE.llvm-compiler-rt.txt",
    "engines/php/LICENSE.musl.txt",
    "engines/php/LICENSE.php-wasm-GPL-2.0-or-later.txt",
    "engines/php/LICENSE.wasm-feature-detect-Apache-2.0.txt",
    "engines/php/LICENSE.zlib-1.2.13.txt",
    "engines/php/NOTICE.IANA-TZDATA-2026.1.txt",
    "engines/php/NOTICE.PHP-Lexbor.txt",
    "engines/php/NOTICE.PHP-REDIST-BINS.txt",
    "engines/php/NOTICE.PHP-public-domain-hash-code.txt",
    "engines/php/NOTICE.SQLite-3.51.0.txt",
    "engines/php/NOTICE.dlmalloc-2.8.6.txt",
    "engines/php/PATENTS.PHP-libavifinfo.txt",
    "engines/php/PATENTS.libaom-3.12.1.txt",
    "engines/php/PATENTS.libwebp.txt",
    "engines/php/SHA256SUMS",
    "engines/php/engine-metadata.json",
    "engines/php/native-components.json",
    "engines/php/php-loader.mjs",
    "engines/php/php.wasm",
    "engines/pcre2/LICENSE.txt",
    "engines/pcre2/SHA256SUMS",
    "engines/pcre2/engine-metadata.json",
    "engines/pcre2/pcre2.mjs",
    "engines/pcre2/pcre2.wasm",
    "engines/python/LICENSE.cpython.txt",
    "engines/python/LICENSE.bzip2.txt",
    "engines/python/LICENSE.emscripten-mini-lz4.txt",
    "engines/python/LICENSE.emscripten.txt",
    "engines/python/LICENSE.expat.txt",
    "engines/python/LICENSE.hacl-mit.txt",
    "engines/python/LICENSE.hiwire.txt",
    "engines/python/LICENSE.libffi.txt",
    "engines/python/LICENSE.libmpdec.txt",
    "engines/python/LICENSE.llvm-compiler-rt.txt",
    "engines/python/LICENSE.llvm-libcxx.txt",
    "engines/python/LICENSE.llvm-libcxxabi.txt",
    "engines/python/LICENSE.llvm-libunwind.txt",
    "engines/python/LICENSE.pyodide.txt",
    "engines/python/LICENSE.pyodide-stacktrace-vendors.txt",
    "engines/python/LICENSE.sqlite.txt",
    "engines/python/LICENSE.xz.txt",
    "engines/python/LICENSE.zlib.txt",
    "engines/python/LICENSE.zstd.txt",
    "engines/python/NOTICE.cpython-bundled.rst",
    "engines/python/NOTICE.emscripten-dlmalloc.txt",
    "engines/python/NOTICE.emscripten-musl.txt",
    "engines/python/SHA256SUMS",
    "engines/python/SOURCE-MANIFEST.json",
    "engines/python/engine-metadata.json",
    "engines/python/pyodide-lock.json",
    "engines/python/pyodide.asm.mjs",
    "engines/python/pyodide.asm.wasm",
    "engines/python/pyodide.mjs",
    "engines/python/python_stdlib.zip",
    "engines/ruby/LICENSE.browser-wasi-shim-Apache-2.0.txt",
    "engines/ruby/LICENSE.browser-wasi-shim-MIT.txt",
    "engines/ruby/LICENSE.ruby-wasm.txt",
    "engines/ruby/LICENSE.tslib.txt",
    "engines/ruby/NOTICE.txt",
    "engines/ruby/SHA256SUMS",
    "engines/ruby/engine-metadata.json",
    "engines/ruby/ruby.wasm",
    "engines/rust/LICENSE-Apache-2.0.txt",
    "engines/rust/LICENSE-MIT.txt",
    "engines/rust/LICENSE-Unicode-3.0.txt",
    "engines/rust/RUST-STDLIB-COPYRIGHT.html",
    "engines/rust/SHA256SUMS",
    "engines/rust/THIRD_PARTY_LICENSES.txt",
    "engines/rust/engine-metadata.json",
    "engines/rust/rust-regex.mjs",
    "engines/rust/rust-regex_bg.wasm",
    "LICENSES/Emscripten-MIT.txt",
    "LICENSES/PCRE2.txt",
    "LICENSES/build/rolldown-1.1.5/LICENSE",
    "LICENSES/build/vite-8.1.5/LICENSE.md",
  ]) {
    if (!names.has(required)) {
      throw new Error(`Required release file is missing: ${required}`);
    }
  }
  if (![...names].some((name) => name.startsWith("assets/"))) {
    throw new Error("Release has no production assets.");
  }
  for (const requiredDotNetPattern of [
    /^engines\/dotnet\/_framework\/RegexTools\.DotNet\..+\.wasm$/u,
    /^engines\/dotnet\/_framework\/System\.Text\.RegularExpressions\..+\.wasm$/u,
    /^engines\/dotnet\/_framework\/dotnet\.native\..+\.wasm$/u,
    /^engines\/dotnet\/_framework\/dotnet\.runtime\..+\.js$/u,
  ]) {
    if (![...names].some((name) => requiredDotNetPattern.test(name))) {
      throw new Error(
        `Required .NET runtime asset is missing: ${requiredDotNetPattern}`,
      );
    }
  }
  if (![...names].some((name) => name.startsWith("LICENSES/"))) {
    throw new Error("Release has no third-party licence directory.");
  }
  const manifest = JSON.parse(
    entries
      .find((entry) => entry.name === "toolbox-app.json")
      .data.toString("utf8"),
  );
  if (
    manifest.id !== "de.add-ideas.regex-tools" ||
    manifest.version !== packageJson.version ||
    manifest.source?.repository !==
      "https://git.add-ideas.de/zemion/regex-tools" ||
    manifest.source?.license !== "GPL-3.0-or-later"
  ) {
    throw new Error("Packaged manifest identity is inconsistent.");
  }
  const indexHtml = entries
    .find((entry) => entry.name === "index.html")
    .data.toString("utf8");
  if (/(?:src|href)\s*=\s*["']\/(?!\/)/iu.test(indexHtml)) {
    throw new Error("index.html contains a root-absolute asset path.");
  }
  return { entries, packageJson };
}

export function parseReleaseArguments(argumentsList) {
  const allowed = new Set(["--force"]);
  const unknown = argumentsList.filter((argument) => !allowed.has(argument));
  if (unknown.length > 0) {
    throw new Error(`Unknown release argument: ${unknown.join(", ")}`);
  }
  return { force: argumentsList.includes("--force") };
}

async function assertWritableTarget(target, force) {
  try {
    const details = await lstat(target);
    if (details.isSymbolicLink() || !details.isFile()) {
      throw new Error(`Release target is not a regular file: ${target}`);
    }
    if (!force) {
      throw new Error(
        `${path.relative(root, target)} exists; pass --force to replace this exact version.`,
      );
    }
    return true;
  } catch (error) {
    if (error && typeof error === "object" && error.code === "ENOENT") {
      return false;
    }
    throw error;
  }
}

async function publishReleasePair({
  archive,
  archivePath,
  checksum,
  checksumPath,
  archiveExists,
  checksumExists,
}) {
  const releaseDirectory = path.dirname(archivePath);
  const stage = await mkdtemp(
    path.join(releaseDirectory, ".regex-tools-release-"),
  );
  const stagedArchive = path.join(stage, path.basename(archivePath));
  const stagedChecksum = path.join(stage, path.basename(checksumPath));
  const previousArchive = path.join(stage, "previous-archive");
  const previousChecksum = path.join(stage, "previous-checksum");
  let archiveBackedUp = false;
  let checksumBackedUp = false;
  let archivePublished = false;
  let checksumPublished = false;
  try {
    await writeFile(stagedArchive, archive, { flag: "wx", mode: 0o644 });
    await writeFile(stagedChecksum, checksum, {
      encoding: "utf8",
      flag: "wx",
      mode: 0o644,
    });
    if (archiveExists) {
      await rename(archivePath, previousArchive);
      archiveBackedUp = true;
    }
    if (checksumExists) {
      await rename(checksumPath, previousChecksum);
      checksumBackedUp = true;
    }
    await rename(stagedArchive, archivePath);
    archivePublished = true;
    await rename(stagedChecksum, checksumPath);
    checksumPublished = true;
  } catch (error) {
    if (checksumPublished) await rm(checksumPath, { force: true });
    if (archivePublished) await rm(archivePath, { force: true });
    if (checksumBackedUp) await rename(previousChecksum, checksumPath);
    if (archiveBackedUp) await rename(previousArchive, archivePath);
    throw error;
  } finally {
    await rm(stage, { recursive: true, force: true });
  }
}

async function main() {
  const options = parseReleaseArguments(process.argv.slice(2));
  const { entries, packageJson } = await collectReleaseEntries();
  const archive = createDeterministicZip(entries);
  const fileName = `regex-tools-${packageJson.version}.zip`;
  const digest = sha256(archive);
  const releaseDirectory = path.join(root, "release");
  await mkdir(releaseDirectory, { recursive: true });
  const releaseDirectoryDetails = await lstat(releaseDirectory);
  if (
    releaseDirectoryDetails.isSymbolicLink() ||
    !releaseDirectoryDetails.isDirectory()
  ) {
    throw new Error("release/ must be a real directory.");
  }
  const archivePath = path.join(releaseDirectory, fileName);
  const checksumPath = `${archivePath}.sha256`;
  const archiveExists = await assertWritableTarget(archivePath, options.force);
  const checksumExists = await assertWritableTarget(
    checksumPath,
    options.force,
  );
  await publishReleasePair({
    archive,
    archivePath,
    checksum: checksumLine(digest, fileName),
    checksumPath,
    archiveExists,
    checksumExists,
  });
  console.log(
    `Created release/${fileName} (${archive.byteLength} bytes, SHA-256 ${digest})`,
  );
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
  await main();
}
