// Assertions for Bun global install E2E validation.
import { spawn } from "node:child_process";

const DEFAULT_TIMEOUT_KILL_GRACE_MS = 30_000;
const PARENT_TERMINATION_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];

const usage = () => {
  console.error("Usage: assertions.mjs <run-with-timeout|assert-image-providers> [...]");
  process.exit(2);
};

const [mode, ...args] = process.argv.slice(2);

const parsePositiveNumber = (value, label) => {
  const parsed = Number(value);
  if (!Number.isFinite(parsed) || parsed <= 0) {
    throw new Error(`${label} must be a positive number`);
  }
  return parsed;
};

const signalChild = (child, signal) => {
  if (!child.pid) {
    return;
  }
  try {
    if (process.platform === "win32") {
      child.kill(signal);
      return;
    }
    process.kill(-child.pid, signal);
  } catch (error) {
    if (error?.code !== "ESRCH") {
      throw error;
    }
  }
};

const processGroupAlive = (child) => {
  if (process.platform === "win32" || !child.pid) {
    return false;
  }
  try {
    process.kill(-child.pid, 0);
    return true;
  } catch (error) {
    return error?.code === "EPERM";
  }
};

const waitForProcessGroupExit = async (child, timeout) => {
  const deadlineAt = Date.now() + timeout;
  while (Date.now() < deadlineAt) {
    if (!processGroupAlive(child)) {
      return true;
    }
    await new Promise((resolve) => {
      setTimeout(resolve, 25);
    });
  }
  return !processGroupAlive(child);
};

const resolveSignalExitCode = (signal) => {
  switch (signal) {
    case "SIGINT":
      return 130;
    case "SIGHUP":
      return 129;
    default:
      return 143;
  }
};

const runWithTimeout = async (timeout, command, commandArgs) => {
  const killGrace = parsePositiveNumber(
    process.env.OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS ??
      String(DEFAULT_TIMEOUT_KILL_GRACE_MS),
    "OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS",
  );
  const child = spawn(command, commandArgs, {
    detached: process.platform !== "win32",
    env: process.env,
    stdio: ["ignore", "pipe", "pipe"],
  });
  let timedOut = false;
  let parentSignal = null;
  let killTimer;
  let killDeadlineAt = 0;
  const scheduleForceKill = () => {
    killDeadlineAt = Date.now() + killGrace;
    killTimer ??= setTimeout(() => signalChild(child, "SIGKILL"), killGrace);
    killTimer.unref();
  };

  child.stdout.setEncoding("utf8");
  child.stderr.setEncoding("utf8");
  child.stdout.on("data", (chunk) => process.stdout.write(chunk));
  child.stderr.on("data", (chunk) => process.stderr.write(chunk));

  const timeoutTimer = setTimeout(() => {
    timedOut = true;
    signalChild(child, "SIGTERM");
    scheduleForceKill();
  }, timeout);
  timeoutTimer.unref();

  const parentSignalHandlers = new Map(
    PARENT_TERMINATION_SIGNALS.map((signal) => [
      signal,
      () => {
        parentSignal ??= signal;
        signalChild(child, signal);
        scheduleForceKill();
      },
    ]),
  );
  for (const [signal, handler] of parentSignalHandlers) {
    process.on(signal, handler);
  }
  const cleanupParentSignalHandlers = () => {
    for (const [signal, handler] of parentSignalHandlers) {
      process.off(signal, handler);
    }
  };

  let spawnError;
  child.on("error", (error) => {
    spawnError = error;
  });
  const result = await new Promise((resolve) => {
    child.on("close", (status, signal) => resolve({ error: spawnError, signal, status }));
  });

  clearTimeout(timeoutTimer);
  cleanupParentSignalHandlers();
  if (timedOut || parentSignal) {
    const remainingGraceMs = Math.max(0, killDeadlineAt - Date.now());
    if (remainingGraceMs > 0) {
      await waitForProcessGroupExit(child, remainingGraceMs);
    }
    if (processGroupAlive(child)) {
      signalChild(child, "SIGKILL");
      await waitForProcessGroupExit(child, 100);
    }
    clearTimeout(killTimer);
  }
  if (parentSignal) {
    process.exit(resolveSignalExitCode(parentSignal));
  }
  if (timedOut) {
    console.error(`command timed out after ${timeout}ms: ${command}`);
    process.exit(1);
  }
  clearTimeout(killTimer);
  if (result.error) {
    console.error(`command failed: ${command}: ${result.error.message}`);
    process.exit(1);
  }
  if (result.signal) {
    console.error(`command terminated: ${command}: ${result.signal}`);
    process.exit(1);
  }
  process.exit(result.status ?? 0);
};

if (mode === "run-with-timeout") {
  const [timeoutMs, command, ...commandArgs] = args;
  if (!command) {
    usage();
  }
  let timeout;
  try {
    timeout = parsePositiveNumber(timeoutMs, "timeoutMs");
  } catch {
    usage();
  }
  await runWithTimeout(timeout, command, commandArgs);
}

if (mode === "assert-image-providers") {
  const raw = process.env.OPENCLAW_IMAGE_PROVIDERS_JSON ?? "";
  let parsed;
  try {
    parsed = JSON.parse(raw);
  } catch (error) {
    console.error(raw);
    const message = error instanceof Error ? error.message : String(error);
    throw new Error(`image providers output is not JSON: ${message}`, { cause: error });
  }
  if (!Array.isArray(parsed)) {
    throw new Error("image providers output must be a JSON array");
  }
  if (parsed.length === 0) {
    throw new Error("image providers output is empty");
  }
  const ids = new Set(parsed.map((entry) => (typeof entry?.id === "string" ? entry.id : "")));
  for (const expected of ["google", "openai", "xai"]) {
    if (!ids.has(expected)) {
      throw new Error(`image providers output is missing bundled provider '${expected}'`);
    }
  }
  console.log(`bun-global-install-smoke: image providers OK (${parsed.length} providers)`);
  process.exit(0);
}

usage();
