#!/usr/bin/env node

import { spawn } from "node:child_process";
import { access, readdir, realpath, stat } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

const require = createRequire(import.meta.url);
const minimumNodeMajorVersion = 24;

function createBootstrapOptions(overrides = {}) {
  const packageRoot =
    overrides.packageRoot ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");

  return {
    cliEntrypointPath:
      overrides.cliEntrypointPath ?? resolve(packageRoot, "dist", "src", "cli", "run.js"),
    packageRoot,
    postBuildScriptPaths: overrides.postBuildScriptPaths ?? [
      resolve(packageRoot, "scripts", "copy-compiled-assets.mjs"),
      resolve(packageRoot, "scripts", "copy-docs.mjs"),
      resolve(packageRoot, "scripts", "stamp-version-tokens.mjs"),
    ],
    tsgoCliPath: overrides.tsgoCliPath,
  };
}

async function fileExists(path) {
  try {
    await access(path);
    return true;
  } catch {
    return false;
  }
}

async function getPathMtimeMs(path) {
  try {
    return (await stat(path)).mtimeMs;
  } catch {
    return undefined;
  }
}

async function getLatestDirectoryMtimeMs(path) {
  const stats = await stat(path);
  let latestMtimeMs = stats.mtimeMs;

  if (!stats.isDirectory()) {
    return latestMtimeMs;
  }

  const entries = await readdir(path, {
    withFileTypes: true,
  });

  for (const entry of entries) {
    const entryPath = resolve(path, entry.name);
    const entryLatestMtimeMs = await getLatestDirectoryMtimeMs(entryPath);

    if (entryLatestMtimeMs > latestMtimeMs) {
      latestMtimeMs = entryLatestMtimeMs;
    }
  }

  return latestMtimeMs;
}

async function getLatestBuildInputMtimeMs({ packageRoot }) {
  let latestMtimeMs = 0;

  for (const relativePath of ["src", "bin", "scripts"]) {
    const path = resolve(packageRoot, relativePath);
    const pathMtimeMs = await getPathMtimeMs(path);

    if (pathMtimeMs === undefined) {
      continue;
    }

    const latestPathMtimeMs = await getLatestDirectoryMtimeMs(path);

    if (latestPathMtimeMs > latestMtimeMs) {
      latestMtimeMs = latestPathMtimeMs;
    }
  }

  return latestMtimeMs;
}

async function canBuildWorkspaceCli({ exists, packageRoot, postBuildScriptPaths }) {
  for (const requiredPath of [
    resolve(packageRoot, "bin"),
    inputTsconfigPath({
      packageRoot,
    }),
    resolve(packageRoot, "src"),
    ...postBuildScriptPaths,
  ]) {
    if (!(await exists(requiredPath))) {
      return false;
    }
  }

  return true;
}

function inputTsconfigPath({ packageRoot }) {
  return resolve(packageRoot, "tsconfig.json");
}

function parseNodeMajorVersion(version) {
  const match = /^v?(\d+)/.exec(version);
  return match ? Number(match[1]) : undefined;
}

function assertSupportedNodeVersion(version = process.version) {
  const majorVersion = parseNodeMajorVersion(version);

  if (majorVersion !== undefined && majorVersion >= minimumNodeMajorVersion) {
    return;
  }

  throw new Error(
    [
      `Eve requires Node.js ${minimumNodeMajorVersion} or newer.`,
      `You are running ${version}.`,
      "Please upgrade Node.js and try again.",
    ].join(" "),
  );
}

function resolveTsgoCliPath({ tsgoCliPath }) {
  if (tsgoCliPath) {
    return tsgoCliPath;
  }

  // The `@typescript/native-preview` package only exports `./package.json`
  // in its `exports` map, so `require.resolve("@typescript/native-preview/bin/tsgo.js")`
  // fails with ERR_PACKAGE_PATH_NOT_EXPORTED. Resolve the package.json
  // (which is exported) and join to the binary path manually.
  const packageJsonPath = require.resolve("@typescript/native-preview/package.json");
  return resolve(dirname(packageJsonPath), "bin", "tsgo.js");
}

async function runCommand(command, args, options) {
  await new Promise((resolveCommand, rejectCommand) => {
    const child = spawn(command, args, {
      cwd: options.cwd,
      stdio: "inherit",
    });

    child.once("error", rejectCommand);
    child.once("exit", (code, signal) => {
      if (code === 0) {
        resolveCommand();
        return;
      }

      if (signal) {
        rejectCommand(new Error(`Command "${command}" exited due to signal ${signal}.`));
        return;
      }

      rejectCommand(new Error(`Command "${command}" exited with code ${code ?? "unknown"}.`));
    });
  });
}

/**
 * Ensures the compiled CLI entrypoint exists before the workspace bin is executed.
 */
export async function ensureBuiltCli(overrides = {}, dependencies = {}) {
  const options = createBootstrapOptions(overrides);
  const exists = dependencies.exists ?? fileExists;
  const getBuildInputMtimeMs =
    dependencies.getLatestBuildInputMtimeMs ?? getLatestBuildInputMtimeMs;
  const getEntrypointMtimeMs = dependencies.getPathMtimeMs ?? getPathMtimeMs;
  const executeCommand = dependencies.runCommand ?? runCommand;
  const packageCanBuildCli = await canBuildWorkspaceCli({
    exists,
    packageRoot: options.packageRoot,
    postBuildScriptPaths: options.postBuildScriptPaths,
  });

  if (await exists(options.cliEntrypointPath)) {
    if (!packageCanBuildCli) {
      return options.cliEntrypointPath;
    }

    const [latestBuildInputMtimeMs, cliEntrypointMtimeMs] = await Promise.all([
      getBuildInputMtimeMs({
        packageRoot: options.packageRoot,
      }),
      getEntrypointMtimeMs(options.cliEntrypointPath),
    ]);

    if (cliEntrypointMtimeMs !== undefined && cliEntrypointMtimeMs >= latestBuildInputMtimeMs) {
      return options.cliEntrypointPath;
    }
  }

  if (!packageCanBuildCli) {
    throw new Error(
      `Eve package at ${options.packageRoot} does not include the sources required to rebuild the CLI.`,
    );
  }

  await executeCommand(
    process.execPath,
    [resolveTsgoCliPath({ tsgoCliPath: options.tsgoCliPath }), "-p", "tsconfig.json"],
    {
      cwd: options.packageRoot,
    },
  );
  for (const scriptPath of options.postBuildScriptPaths) {
    await executeCommand(process.execPath, [scriptPath], {
      cwd: options.packageRoot,
    });
  }

  if (await exists(options.cliEntrypointPath)) {
    return options.cliEntrypointPath;
  }

  throw new Error(`Building eve did not produce ${options.cliEntrypointPath}.`);
}

/**
 * Runs the compiled Eve CLI, building the workspace package on demand when needed.
 */
export async function runEveCli(argv = process.argv.slice(2), overrides = {}, dependencies = {}) {
  assertSupportedNodeVersion(dependencies.nodeVersion);

  const cliEntrypointPath = await ensureBuiltCli(overrides, dependencies);
  const importModule = dependencies.importModule ?? ((specifier) => import(specifier));
  const cliModule = await importModule(pathToFileURL(cliEntrypointPath).href);

  if (typeof cliModule.runCli !== "function") {
    throw new Error(`The Eve CLI module at ${cliEntrypointPath} does not export runCli().`);
  }

  await cliModule.runCli(argv);
}

async function isDirectExecution() {
  if (!process.argv[1]) {
    return false;
  }

  try {
    const [currentPath, invokedPath] = await Promise.all([
      realpath(fileURLToPath(import.meta.url)),
      realpath(process.argv[1]),
    ]);

    return currentPath === invokedPath;
  } catch {
    return false;
  }
}

if (await isDirectExecution()) {
  try {
    await runEveCli();
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.error(message);
    // Walk the `cause` chain so wrapped errors (sandbox prewarm wraps the
    // underlying Vercel SDK failure with remediation hints, etc.) still
    // surface the original SDK message a user can search for.
    let cause = error instanceof Error ? error.cause : undefined;
    while (cause instanceof Error) {
      console.error(`  Caused by: ${cause.message}`);
      cause = cause.cause;
    }
    process.exitCode = 1;
  } finally {
    // The CLI bootstraps build/dev toolchains that can leave native service
    // handles alive after the command has completed. Once the top-level
    // command resolves, terminate the bin process explicitly so commands like
    // `eve dev` + `/exit` do not hang on leaked implementation details.
    process.exit(process.exitCode ?? 0);
  }
}
