#!/usr/bin/env -S deno run -A --lock=tools/deno.lock.json
// Copyright 2018-2026 the Deno authors. MIT license.

// deno-lint-ignore-file no-console

import { $ } from "jsr:@david/dax@0.41.0";
import { gray } from "jsr:@std/fmt@1/colors";
import { patchver } from "jsr:@deno/patchver@0.5.1";

const SUPPORTED_TARGETS = [
  "aarch64-apple-darwin",
  "aarch64-unknown-linux-gnu",
  "x86_64-apple-darwin",
  "x86_64-pc-windows-msvc",
  "x86_64-unknown-linux-gnu",
];

const DENO_BINARIES = [
  "deno",
  "denort",
];

// Target platform filtering
const NON_WINDOWS_TARGETS = SUPPORTED_TARGETS.filter(
  (target) => !target.includes("windows"),
);

const CHANNEL = Deno.args[0];
if (CHANNEL !== "rc" && CHANNEL !== "lts") {
  throw new Error(`Invalid channel: ${CHANNEL}`);
}

const DL_URL = "https://dl.deno.land";

function getSourceBinaryUrl(
  versionOrHash: string,
  binary: string,
  target: string,
): string {
  if (CHANNEL === "lts") {
    // LTS is cut from a release branch that has no canary build, so re-stamp
    // the already-published stable release binaries instead of canary ones.
    const version = versionOrHash.replace(/^v/, "");
    return `${DL_URL}/release/v${version}/${binary}-${target}.zip`;
  }
  return `${DL_URL}/canary/${versionOrHash}/${binary}-${target}.zip`;
}

function getUnzippedFilename(binary: string, target: string) {
  if (target.includes("windows")) {
    return `${binary}.exe`;
  } else {
    return binary;
  }
}

function getBinaryName(binary: string, target: string): string {
  let ext = "";
  if (target.includes("windows")) {
    ext = ".exe";
  }
  return `${binary}-${target}-${CHANNEL}${ext}`;
}

function getArchiveName(binary: string, target: string): string {
  return `${binary}-${target}.zip`;
}

interface CanaryVersion {
  target: string;
  version: string;
}

async function remove(filePath: string) {
  try {
    await Deno.remove(filePath);
  } catch {
    // pass
  }
}

async function fetchSourceBinary(
  versionOrHash: string,
  binary: string,
  target: string,
) {
  const url = getSourceBinaryUrl(versionOrHash, binary, target);
  await $.request(url).showProgress().pipeToPath();
}

async function fetchSourceBinaries(versionOrHash: string) {
  for (const binary of DENO_BINARIES) {
    for (const target of NON_WINDOWS_TARGETS) {
      $.logStep("Download", binary, gray("target:"), target);
      await fetchSourceBinary(versionOrHash, binary, target);
    }
  }
}

async function unzipArchive(archiveName: string, unzippedName: string) {
  await remove(unzippedName);
  const output = await $`unzip ./${archiveName}`;
  if (output.code !== 0) {
    $.logError(`Failed to unzip ${archiveName} (error code ${output.code})`);
    Deno.exit(1);
  }
}

async function createArchive(binaryName: string, archiveName: string) {
  const output = await $`zip -r ./${archiveName} ./${binaryName}`;

  if (output.code !== 0) {
    $.logError(
      `Failed to create archive ${archiveName} (error code ${output.code})`,
    );
    Deno.exit(1);
  }
}

async function runPatchver(
  binary: string,
  target: string,
  binaryName: string,
) {
  const input = await Deno.readFile(binary);
  const output = patchver(input, CHANNEL);

  try {
    await Deno.writeFile(binaryName, output);
  } catch (e) {
    $.logError(
      `Failed to promote to RC ${binary} (${target}), error:`,
      e,
    );
    Deno.exit(1);
  }
}

async function runRcodesign(
  target: string,
  binaryName: string,
  commitHash: string,
) {
  if (!target.includes("apple") || binaryName.includes("denort")) {
    return;
  }
  $.logStep(`Codesign ${binaryName}`);
  const tempFile = $.path("temp.p12");
  let output;
  try {
    await $`echo $APPLE_CODESIGN_KEY | base64 -d`.stdout(tempFile);
    output =
      await $`rcodesign sign ./${binaryName} --binary-identifier=deno-${commitHash} --code-signature-flags=runtime --code-signature-flags=runtime --p12-password="$APPLE_CODESIGN_PASSWORD" --p12-file=${tempFile} --entitlements-xml-file=cli/entitlements.plist`;
  } finally {
    try {
      tempFile.removeSync();
    } catch {
      // pass
    }
  }
  if (output.code !== 0) {
    $.logError(
      `Failed to codesign ${binaryName} (error code ${output.code})`,
    );
    Deno.exit(1);
  }
  await $`codesign -dv --verbose=4 ./deno`;
}

async function removeExistingSignature(target: string, binaryName: string) {
  // Only Intel macOS needs this. patchver runs as a Wasm module: libsui's
  // Intel Mach-O path used to strip the existing code signature by shelling
  // out to `codesign`, which is gated to Apple hosts and so is absent from the
  // Wasm build. Injecting the channel section then leaves stale signature data
  // in __LINKEDIT that rcodesign rejects with "data after signature". Strip it
  // here first with the runner's native codesign so patchver writes into an
  // unsigned binary that can be re-signed cleanly.
  //
  // arm64 must NOT be stripped: its patchver path rebuilds the signature
  // in-memory, and removing the signature first makes that output unrecognizable
  // to rcodesign ("specified path is not of a recognized type").
  if (target !== "x86_64-apple-darwin") {
    return;
  }
  $.logStep("Remove signature", binaryName);
  const output = await $`codesign --remove-signature ./${binaryName}`.noThrow();
  if (output.code !== 0) {
    $.logError(
      `Failed to remove existing signature from ${binaryName} (error code ${output.code})`,
    );
    Deno.exit(1);
  }
}

async function promoteBinaryToRc(
  binary: string,
  target: string,
  commitHash: string,
) {
  const unzippedName = getUnzippedFilename(binary, target);
  const binaryName = getBinaryName(binary, target);
  const archiveName = getArchiveName(binary, target);
  await remove(unzippedName);
  await remove(binaryName);
  $.logStep(
    "Unzip",
    archiveName,
    gray("binary"),
    binary,
    gray("binaryName"),
    binaryName,
  );

  await unzipArchive(archiveName, unzippedName);
  await remove(archiveName);

  await removeExistingSignature(target, unzippedName);

  $.logStep(
    "Patchver",
    unzippedName,
    `(${target})`,
    gray("output to"),
    binaryName,
  );
  await runPatchver(unzippedName, target, binaryName);
  // Remove the unpatched binary and rename patched one.
  await remove(unzippedName);
  await Deno.rename(binaryName, unzippedName);
  await runRcodesign(target, unzippedName, commitHash);
  // Set executable permission
  if (!target.includes("windows")) {
    await Deno.chmod(unzippedName, 0o755);
  }

  await createArchive(unzippedName, archiveName);
  await remove(unzippedName);
}

async function promoteBinariesToRc(commitHash: string) {
  const totalCanaries = NON_WINDOWS_TARGETS.length * DENO_BINARIES.length;

  for (let targetIdx = 0; targetIdx < NON_WINDOWS_TARGETS.length; targetIdx++) {
    const target = NON_WINDOWS_TARGETS[targetIdx];
    for (let binaryIdx = 0; binaryIdx < DENO_BINARIES.length; binaryIdx++) {
      const binaryName = DENO_BINARIES[binaryIdx];
      const currentIdx = (targetIdx * 2) + binaryIdx + 1;
      $.logLight(
        `[${currentIdx}/${totalCanaries}]`,
        "Promote",
        binaryName,
        target,
        `to ${CHANNEL}...`,
      );
      await promoteBinaryToRc(binaryName, target, commitHash);
      $.logLight(
        `[${currentIdx}/${totalCanaries}]`,
        "Promoted",
        binaryName,
        target,
        `to ${CHANNEL}!`,
      );
    }
  }
}

async function dumpRcVersion() {
  $.logStep("Compute version");
  await unzipArchive(getArchiveName("deno", Deno.build.target), "deno");
  const output = await $`./deno -V`.stdout("piped");
  const denoVersion = output.stdout.slice(5).split("+")[0];
  $.logStep("Computed version", denoVersion);
  await Deno.writeTextFile(
    `./release-${CHANNEL}-latest.txt`,
    `v${denoVersion}`,
  );
}

async function main() {
  const commitHash = Deno.args[1];
  if (!commitHash) {
    throw new Error(
      "A canary commit hash (rc) or release version (lts) needs to be provided as an argument",
    );
  }
  const sourceKind = CHANNEL === "lts" ? "release" : "canary";
  $.logStep(`Download ${sourceKind} binaries...`);
  await fetchSourceBinaries(commitHash);
  console.log(`All ${sourceKind} binaries ready`);
  $.logStep(`Promote ${sourceKind} binaries to ${CHANNEL}...`);
  await promoteBinariesToRc(commitHash);

  // Finally dump the version name to a `release.txt` file for uploading to GCP
  await dumpRcVersion();
}

await main();
