import { existsSync } from "node:fs";
import { mkdir, readdir, readFile, rename, rm } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

type ReleaseChannel = "latest" | "beta";

type ReleaseCliOptions = {
  readonly dryRun: boolean;
};

type CommandInput = {
  readonly command: string;
  readonly args: ReadonlyArray<string>;
  readonly cwd: string;
  readonly captureOutput?: boolean;
};

type CommandOutput = {
  readonly stdout: string;
  readonly stderr: string;
};

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const cliRoot = resolve(repoRoot, "apps/cli");
const distDir = resolve(cliRoot, "dist");
const releaseDir = resolve(distDir, "release");
const wrapperDir = resolve(distDir, "executor");
const versionPackagePath = resolve(cliRoot, "package.json");
const semverPattern =
  /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;

const parseArgs = (argv: ReadonlyArray<string>): ReleaseCliOptions => {
  let dryRun = false;

  for (const arg of argv) {
    if (arg === "--dry-run") {
      dryRun = true;
      continue;
    }

    throw new Error(`Unknown argument: ${arg}`);
  }

  return { dryRun };
};

const runCommand = async (input: CommandInput): Promise<CommandOutput> => {
  const proc = Bun.spawn([input.command, ...input.args], {
    cwd: input.cwd,
    env: process.env,
    stdio: input.captureOutput ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
  });

  const exitCode = await proc.exited;
  const stdout = input.captureOutput && proc.stdout ? await new Response(proc.stdout).text() : "";
  const stderr = input.captureOutput && proc.stderr ? await new Response(proc.stderr).text() : "";

  if (exitCode !== 0) {
    throw new Error(
      [
        `${input.command} ${input.args.join(" ")} exited with code ${exitCode}`,
        stdout.trim().length > 0 ? `stdout:\n${stdout.trim()}` : null,
        stderr.trim().length > 0 ? `stderr:\n${stderr.trim()}` : null,
      ]
        .filter((part) => part !== null)
        .join("\n\n"),
    );
  }

  return {
    stdout: stdout.trim(),
    stderr: stderr.trim(),
  };
};

const readVersion = async (): Promise<string> => {
  const pkg = (await Bun.file(versionPackagePath).json()) as { version?: string };
  const version = pkg.version?.trim();

  if (!version) {
    throw new Error(`Missing version in ${versionPackagePath}`);
  }

  return version;
};

const validateVersion = (version: string): void => {
  if (!semverPattern.test(version)) {
    throw new Error(`${versionPackagePath} version is not valid semver: ${version}`);
  }
};

const resolveChannel = (version: string): ReleaseChannel =>
  version.includes("-") ? "beta" : "latest";

const resolveTagFromEnvironment = (): string | undefined => {
  const refName = process.env.GITHUB_REF_NAME?.trim();
  if (process.env.GITHUB_REF_TYPE === "tag" && refName) {
    return refName;
  }

  const ref = process.env.GITHUB_REF?.trim();
  if (ref?.startsWith("refs/tags/")) {
    return ref.slice("refs/tags/".length);
  }

  return undefined;
};

const resolveGitHubRepository = (): string => {
  const repository = process.env.GH_REPO?.trim() || process.env.GITHUB_REPOSITORY?.trim();
  if (!repository) {
    throw new Error("Set GH_REPO or GITHUB_REPOSITORY before creating a GitHub release.");
  }

  return repository;
};

const packWrapperPackage = async (): Promise<string> => {
  if (!existsSync(wrapperDir)) {
    throw new Error(`Wrapper package directory not found: ${wrapperDir}`);
  }

  await mkdir(releaseDir, { recursive: true });

  const before = new Set((await readdir(wrapperDir)).filter((entry) => entry.endsWith(".tgz")));
  await runCommand({
    command: "bun",
    args: ["pm", "pack"],
    cwd: wrapperDir,
  });

  const after = (await readdir(wrapperDir)).filter((entry) => entry.endsWith(".tgz"));
  const archiveName = after.find((entry) => !before.has(entry)) ?? after[0];

  if (!archiveName) {
    throw new Error(`bun pm pack did not create a .tgz archive in ${wrapperDir}`);
  }

  const sourcePath = join(wrapperDir, archiveName);
  const destinationPath = join(releaseDir, archiveName);

  await rm(destinationPath, { force: true });
  await rename(sourcePath, destinationPath);

  return destinationPath;
};

const collectReleaseAssetPaths = async (
  wrapperArchivePath: string,
): Promise<ReadonlyArray<string>> => {
  const assetNames = (await readdir(distDir))
    .filter((entry) => /^executor-.*\.(?:tar\.gz|zip)$/.test(entry))
    .sort();

  return [wrapperArchivePath, ...assetNames.map((entry) => join(distDir, entry))];
};

const githubReleaseExists = async (tag: string, repository: string): Promise<boolean> => {
  const proc = Bun.spawn(["gh", "release", "view", tag, "--repo", repository], {
    cwd: repoRoot,
    env: process.env,
    stdio: ["ignore", "ignore", "ignore"],
  });

  return (await proc.exited) === 0;
};

/** The CHANGELOG section Changesets generated for `version` (compiled from
 *  `.changeset/*.md` bodies at Version Packages time) — the GitHub Release
 *  body. Returns null when the section is missing so the caller can fall back
 *  to GitHub's auto-generated notes. */
const changelogSectionForVersion = async (version: string): Promise<string | null> => {
  const changelogPath = resolve(cliRoot, "CHANGELOG.md");
  if (!existsSync(changelogPath)) return null;
  const lines = (await readFile(changelogPath, "utf8")).split("\n");
  const start = lines.findIndex((line) => line.trim() === `## ${version}`);
  if (start === -1) return null;
  let end = lines.length;
  for (let i = start + 1; i < lines.length; i += 1) {
    if (/^## /.test(lines[i] ?? "")) {
      end = i;
      break;
    }
  }
  const body = lines
    .slice(start + 1, end)
    .join("\n")
    .trim();
  return body.length > 0 ? body : null;
};

const syncGitHubRelease = async (input: {
  readonly tag: string;
  readonly channel: ReleaseChannel;
  readonly assetPaths: ReadonlyArray<string>;
}): Promise<void> => {
  if (!process.env.GH_TOKEN?.trim()) {
    throw new Error("GH_TOKEN is required to create or update a GitHub release.");
  }

  const repository = resolveGitHubRepository();

  if (await githubReleaseExists(input.tag, repository)) {
    await runCommand({
      command: "gh",
      args: [
        "release",
        "upload",
        input.tag,
        ...input.assetPaths,
        "--repo",
        repository,
        "--clobber",
      ],
      cwd: repoRoot,
    });
    return;
  }

  const notes = await changelogSectionForVersion(input.tag.replace(/^v/, ""));

  // Draft until publish-desktop.yml finishes uploading installers and flips
  // it; otherwise /releases/latest/download/<desktop-asset> 404s during the
  // ~15-20 min desktop build window.
  const args = [
    "release",
    "create",
    input.tag,
    ...input.assetPaths,
    "--repo",
    repository,
    "--title",
    input.tag,
    ...(notes ? ["--notes", notes] : ["--generate-notes"]),
    "--verify-tag",
    "--draft",
  ];

  if (input.channel === "beta") {
    args.push("--prerelease");
  }

  await runCommand({
    command: "gh",
    args,
    cwd: repoRoot,
  });
};

const main = async () => {
  const options = parseArgs(process.argv.slice(2));
  const version = await readVersion();
  const tag = `v${version}`;
  const refTag = resolveTagFromEnvironment();
  const channel = resolveChannel(version);

  validateVersion(version);

  if (refTag && refTag !== tag) {
    throw new Error(`GitHub tag ${refTag} does not match ${versionPackagePath} version ${version}`);
  }

  await rm(releaseDir, { recursive: true, force: true });
  await mkdir(releaseDir, { recursive: true });

  await runCommand({
    command: "bun",
    args: ["run", "src/build.ts", "binary"],
    cwd: cliRoot,
  });

  await runCommand({
    command: "bun",
    args: ["run", "src/build.ts", "release-assets"],
    cwd: cliRoot,
  });

  const wrapperArchivePath = await packWrapperPackage();
  const assetPaths = await collectReleaseAssetPaths(wrapperArchivePath);

  console.log(`Prepared executor@${version} for ${channel}`);
  for (const assetPath of assetPaths) {
    console.log(`- ${assetPath}`);
  }

  if (options.dryRun) {
    return;
  }

  await syncGitHubRelease({
    tag,
    channel,
    assetPaths,
  });

  await runCommand({
    command: "bun",
    args: ["run", "src/build.ts", "publish", channel],
    cwd: cliRoot,
  });
};

await main();
