#!/usr/bin/env bun
/**
 * Build the native safety worker, ABI v2 (docs/adr/0001-stable-native-safety-launcher.md
 * sections 3.3 and 3.4), into bin/native-safety-worker.cjs.
 *
 * The launcher's loader evaluates the verified bytes as a CommonJS function body,
 * `new Function("module", "exports", "require", source)`, and passes a `require`
 * that accepts only `node:` builtins. A bundle that needs anything else fails at
 * every guarded call, so this build refuses it instead. Each check below fails
 * the build, and nothing is written:
 * - a `require(...)` whose specifier is not a literal `node:` builtin;
 * - any `bun:` specifier, static import or export, dynamic `import()` or
 *   `import.meta`, none of which the loader's CommonJS shape can serve;
 * - a top-level `await` (including `for await`);
 * - a `require` used other than as a direct call, which would hide a specifier;
 * - the version placeholder left unreplaced by the define;
 * - a bundle that does not compile in the loader's function shape.
 *
 * Usage: bun scripts/build-native-worker.ts [--outfile <path>]
 */
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { builtinModules } from "node:module";
import { dirname, join, resolve } from "node:path";
import ts from "typescript";

export const WORKER_ENTRY = "hooks/native-safety-worker.ts";
export const WORKER_OUTFILE = "bin/native-safety-worker.cjs";
/** The identifier hooks/native-safety-worker.ts declares; the build replaces it with package.json's version. */
export const WORKER_VERSION_DEFINE = "HASNA_HOOKS_NATIVE_SAFETY_WORKER_VERSION";

const BUILTINS = new Set(builtinModules);

/** Whether `specifier` is exactly `node:<builtin>`, the only form the loader's require accepts. */
export function isNodeBuiltinSpecifier(specifier: string): boolean {
  if (!specifier.startsWith("node:")) return false;
  const name = specifier.slice("node:".length);
  return /^[a-z][a-z0-9_]*(?:\/[a-z][a-z0-9_]*)*$/.test(name) && BUILTINS.has(name);
}

function literalText(node: ts.Node | undefined): string | undefined {
  return node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : undefined;
}

/** Whether an identifier named `require` sits in a property-name position rather than referring to a binding. */
function isPropertyName(node: ts.Identifier): boolean {
  const parent = node.parent;
  return (ts.isPropertyAccessExpression(parent) && parent.name === node)
    || ((ts.isPropertyAssignment(parent) || ts.isMethodDeclaration(parent) || ts.isPropertyDeclaration(parent)
      || ts.isGetAccessorDeclaration(parent) || ts.isSetAccessorDeclaration(parent)) && parent.name === node);
}

/**
 * Every reason `source` cannot run under the v2 loader. Empty means the bundle
 * passed. Parsed, not pattern-matched: a `require(` or `await` inside a string
 * or a comment is data and is not reported.
 */
export function workerBundleProblems(source: string): string[] {
  const problems: string[] = [];
  const file = ts.createSourceFile("native-safety-worker.cjs", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
  const at = (node: ts.Node) => {
    const { line, character } = file.getLineAndCharacterOfPosition(node.getStart(file));
    return `${line + 1}:${character + 1}`;
  };
  const specifier = (node: ts.Node, text: string, how: string) => {
    if (text.startsWith("bun:")) problems.push(`bun: import ${JSON.stringify(text)} via ${how} at ${at(node)}`);
    else if (how !== "require") problems.push(`${how} of ${JSON.stringify(text)}; the loader serves only require at ${at(node)}`);
    else if (!isNodeBuiltinSpecifier(text)) problems.push(`require of non-node: specifier ${JSON.stringify(text)} at ${at(node)}`);
  };
  const visit = (node: ts.Node, inFunction: boolean): void => {
    if (!inFunction && ts.isAwaitExpression(node)) problems.push(`top-level await at ${at(node)}`);
    if (!inFunction && ts.isForOfStatement(node) && node.awaitModifier) problems.push(`top-level for await at ${at(node)}`);
    if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) {
      specifier(node, literalText(node.moduleSpecifier) ?? "<non-literal>", ts.isImportDeclaration(node) ? "static import" : "re-export");
    } else if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node) || ts.isExportAssignment(node)
      || (ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword))) {
      problems.push(`ESM syntax at ${at(node)}`);
    }
    if (ts.isMetaProperty(node) && node.keywordToken === ts.SyntaxKind.ImportKeyword) problems.push(`import.meta at ${at(node)}`);
    if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
      const text = node.arguments.length === 1 ? literalText(node.arguments[0]) : undefined;
      specifier(node, text ?? "<non-literal>", "dynamic import()");
    }
    if (ts.isIdentifier(node) && node.text === WORKER_VERSION_DEFINE) problems.push(`version define not applied (${WORKER_VERSION_DEFINE}) at ${at(node)}`);
    if (ts.isIdentifier(node) && node.text === "require" && !isPropertyName(node)) {
      const call = node.parent;
      if (ts.isCallExpression(call) && call.expression === node) {
        const text = call.arguments.length === 1 ? literalText(call.arguments[0]) : undefined;
        if (text === undefined) problems.push(`require with a non-literal specifier at ${at(call)}`);
        else specifier(call, text, "require");
      } else {
        problems.push(`require used other than as a direct call at ${at(node)}`);
      }
    }
    const scope = inFunction || ts.isFunctionLike(node) || ts.isClassStaticBlockDeclaration(node);
    ts.forEachChild(node, (child) => visit(child, scope));
  };
  visit(file, false);
  try {
    // Compile, never call: the loader's exact CommonJS function shape.
    new Function("module", "exports", "require", source);
  } catch (error) {
    problems.push(`does not compile as the loader's CommonJS function body: ${error instanceof Error ? error.message : String(error)}`);
  }
  return problems;
}

export interface NativeSafetyWorkerBuild { outfile: string; bytes: number; sha256: string; version: string }

/** Bundle, check and write the worker. Writes nothing when a check fails. */
export async function buildNativeSafetyWorker(options: { packageDir?: string; outfile?: string } = {}): Promise<NativeSafetyWorkerBuild> {
  const packageDir = resolve(options.packageDir ?? join(import.meta.dir, ".."));
  const outfile = resolve(packageDir, options.outfile ?? WORKER_OUTFILE);
  const version: unknown = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")).version;
  if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) throw new Error("package.json has no valid version for the native safety worker");
  const result = await Bun.build({
    entrypoints: [join(packageDir, WORKER_ENTRY)],
    target: "node",
    format: "cjs",
    minify: true,
    define: { "import.meta.main": "false", [WORKER_VERSION_DEFINE]: JSON.stringify(version) },
    // Report the bundler's own messages (a parse error such as a top-level
    // await in CommonJS) instead of a bare "Bundle failed".
    throw: false,
  });
  if (!result.success) throw new Error(`native safety worker build failed:\n${result.logs.map(String).join("\n")}`);
  if (result.outputs.length !== 1) throw new Error(`native safety worker build produced ${result.outputs.length} outputs, expected 1`);
  const source = await result.outputs[0]!.text();
  const problems = workerBundleProblems(source);
  if (problems.length > 0) throw new Error(`native safety worker bundle refused:\n${problems.map((problem) => `  - ${problem}`).join("\n")}`);
  mkdirSync(dirname(outfile), { recursive: true });
  // A new file is never group- or world-writable, whatever the umask: the launcher's pin check refuses such a worker.
  writeFileSync(outfile, source, { mode: 0o644 });
  return { outfile, bytes: Buffer.byteLength(source), sha256: createHash("sha256").update(source).digest("hex"), version };
}

if (import.meta.main) {
  const args = process.argv.slice(2);
  let outfile: string | undefined;
  if (args.length === 2 && args[0] === "--outfile" && args[1]) outfile = resolve(args[1]);
  else if (args.length !== 0) {
    console.error("usage: bun scripts/build-native-worker.ts [--outfile <path>]");
    process.exit(2);
  }
  try {
    const built = await buildNativeSafetyWorker({ outfile });
    console.log(`native-safety-worker.cjs ${built.version}: ${built.bytes} bytes, sha256 ${built.sha256} -> ${built.outfile}`);
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}
