/** Independent private releases for changed @wrnexus packages. */
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";

const root = resolve(import.meta.dir, "..");
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const mode = process.argv[2] ?? "plan";
const requested = process.argv.slice(3).filter((arg) => !arg.startsWith("--"));

interface PackageInfo {
  dir: string;
  name: string;
  file: string;
  manifest: Record<string, any>;
}

function run(command: string, args: string[], capture = false, allowFailure = false) {
  const result = spawnSync(command, args, {
    cwd: root,
    encoding: capture ? "utf8" : undefined,
    stdio: capture ? "pipe" : "inherit",
  });
  if (result.error) throw result.error;
  if (result.status !== 0 && !allowFailure) {
    throw new Error(`${command} ${args.join(" ")} failed with ${result.status}`);
  }
  return result;
}

function allPackages(): PackageInfo[] {
  return readdirSync(join(root, "packages"), { withFileTypes: true }).flatMap((entry) => {
    const file = join(root, "packages", entry.name, "package.json");
    if (!entry.isDirectory() || !existsSync(file)) return [];
    const manifest = JSON.parse(readFileSync(file, "utf8"));
    return manifest.name?.startsWith("@wrnexus/")
      ? [{ dir: entry.name, name: manifest.name, file, manifest }]
      : [];
  });
}

function changedPaths(): string[] {
  const tracked = String(run("git", ["diff", "HEAD", "--name-only"], true).stdout ?? "");
  const untracked = String(
    run("git", ["ls-files", "--others", "--exclude-standard"], true).stdout ?? "",
  );
  return `${tracked}\n${untracked}`
    .split(/\r?\n/)
    .filter(Boolean)
    .map((path) => path.replace(/\\/g, "/"));
}

function selectChanged(all: PackageInfo[]): PackageInfo[] {
  if (requested.length) {
    return all.filter((pkg) => requested.includes(pkg.dir) || requested.includes(pkg.name));
  }
  const paths = changedPaths();
  return all.filter((pkg) => paths.some((path) => path.startsWith(`packages/${pkg.dir}/`)));
}

function patch(version: string): string {
  const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
  if (!match) throw new Error(`Cannot patch-bump invalid package version ${version}`);
  return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
}

function syncWorkspaceLock(pkgs: PackageInfo[]): void {
  const file = join(root, "bun.lock");
  let source = readFileSync(file, "utf8");
  for (const pkg of pkgs) {
    const escaped = pkg.dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    const pattern = new RegExp(
      `("packages/${escaped}": \\{\\r?\\n\\s+"name": "[^"]+",\\r?\\n\\s+"version": ")[^"]+(")`,
    );
    if (!pattern.test(source)) throw new Error(`bun.lock is missing workspace package ${pkg.name}`);
    source = source.replace(pattern, `$1${pkg.manifest.version}$2`);
  }
  writeFileSync(file, source);
}

function registryHas(pkg: PackageInfo): boolean {
  return (
    run(npm, ["view", `${pkg.name}@${pkg.manifest.version}`, "version", "--json"], true, true)
      .status === 0
  );
}

function unpublished(all: PackageInfo[]): PackageInfo[] {
  return all.filter((pkg) => !registryHas(pkg));
}

function print(pkgs: PackageInfo[], action: string): void {
  if (!pkgs.length) {
    console.log(`No packages need ${action}.`);
    return;
  }
  console.log(`Packages selected for ${action}:`);
  for (const pkg of pkgs) console.log(`  ${pkg.name}@${pkg.manifest.version}`);
}

const all = allPackages();
if (mode === "plan") {
  const selected = selectChanged(all);
  print(selected, "a patch release");
  for (const pkg of selected) console.log(`    -> ${patch(pkg.manifest.version)}`);
} else if (mode === "prepare") {
  const selected = selectChanged(all);
  if (!selected.length)
    throw new Error("No changed packages found. Pass package names explicitly if needed.");
  for (const pkg of selected) {
    pkg.manifest.version = patch(String(pkg.manifest.version));
    writeFileSync(pkg.file, `${JSON.stringify(pkg.manifest, null, 2)}\n`);
  }
  syncWorkspaceLock(selected);
  // Bun currently leaves workspace manifest versions stale when only
  // --lockfile-only is used. The workspace entries are synchronized above;
  // this install refreshes dependency resolution without changing versions.
  run(process.execPath, ["install"]);
  run(process.execPath, ["run", "scripts/publish-packages.ts", ...selected.map((pkg) => pkg.name)]);
  print(selected, "release preparation");
  console.log(
    "Review and commit the version and lockfile changes, then run bun run release:changed:publish.",
  );
} else if (mode === "publish") {
  const selected = requested.length ? selectChanged(all) : unpublished(all);
  if (!selected.length) throw new Error("Every local package version is already published.");
  run(process.execPath, ["run", "check:component-imports"]);
  run(process.execPath, ["test", ...selected.map((pkg) => `packages/${pkg.dir}`)]);
  run(process.execPath, ["run", "scripts/publish-packages.ts", ...selected.map((pkg) => pkg.name)]);
  run(npm, ["whoami"]);
  for (const pkg of selected) {
    const stage = join(root, ".publish", pkg.dir);
    if (!registryHas(pkg)) run(npm, ["publish", stage, "--access", "restricted"]);
    console.log(`  ✓ ${pkg.name}@${pkg.manifest.version}`);
  }
} else {
  throw new Error("Usage: bun scripts/release-changed.ts plan | prepare | publish [package ...]");
}
