import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
  access,
  constants as fsConstants,
  copyFile,
  lstat,
  mkdir,
  mkdtemp,
  readFile,
  readdir,
  realpath,
  rename,
  rm,
  stat,
  writeFile,
} from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

export const JAVA_LOCK = Object.freeze({
  bridgeAbi: 1,
  engineIdentity: "TeaVM java.util.regex 0.15.0",
  engineVersion: "0.15.0",
  sourceDateEpoch: 1_780_759_630,
  maximumPatternUtf16: 65_536,
  maximumSubjectBytes: 16_777_216,
  maximumReplacementUtf16: 65_536,
  maximumMatches: 10_000,
  maximumCaptureRows: 100_000,
  maximumCaptureGroups: 1_000,
  maximumOutputBytes: 67_108_864,
  source: Object.freeze({
    repository: "https://github.com/konsoletyper/teavm.git",
    tag: "0.15.0",
    commit: "ee91b03e616c4b45401cd11fb0cd7eb0daf6649b",
    tree: "1cdc5c332d5809828c8952f96d73f9e7504a0cec",
    license: "Apache-2.0",
    licenseSha256:
      "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
    noticeSha256:
      "6c2b3372a6beee7fbaad482248ac0f5ad2b099d92ff2a528c04261cc9c0636f4",
    packagedNoticeSha256:
      "71bbe79ff78d0e5e64096acdd418589abfe886b8f827048dc655df438a57e477",
  }),
  toolchain: Object.freeze({
    mavenVersion: "3.9.15",
    javaVersion: "25.0.3",
    artifacts: Object.freeze([
      Object.freeze({
        coordinate: "org.teavm:teavm-classlib:0.15.0",
        relativePath:
          "org/teavm/teavm-classlib/0.15.0/teavm-classlib-0.15.0.jar",
        sha256:
          "7d805be4670a892a34c89d9d750a8afd65e2f8201b7b3a10343634ea19d1c410",
      }),
      Object.freeze({
        coordinate: "org.teavm:teavm-jso:0.15.0",
        relativePath: "org/teavm/teavm-jso/0.15.0/teavm-jso-0.15.0.jar",
        sha256:
          "58b337b93924106bf819c45890c7c231a0c740145afe77795f1e0f099f621d71",
      }),
      Object.freeze({
        coordinate: "org.teavm:teavm-maven-plugin:0.15.0",
        relativePath:
          "org/teavm/teavm-maven-plugin/0.15.0/teavm-maven-plugin-0.15.0.jar",
        sha256:
          "1133e16e45f1fd3cb93129fd96d142e1f5ef3608891d83b2273442d5446a3879",
      }),
    ]),
  }),
});

const PACK_FILES = Object.freeze([
  "LICENSE.txt",
  "NOTICE.txt",
  "SHA256SUMS",
  "engine-metadata.json",
  "java-regex.mjs",
]);
const CHECKSUM_FILES = Object.freeze([
  "LICENSE.txt",
  "NOTICE.txt",
  "engine-metadata.json",
  "java-regex.mjs",
]);
const BRIDGE_SOURCE_FILES = Object.freeze([
  "engines/java/pom.xml",
  "engines/java/README.md",
  "engines/java/LICENSE-TeaVM.txt",
  "engines/java/NOTICE-TeaVM.txt",
  "engines/java/src/main/java/de/addideas/regextools/javaengine/RegexBridge.java",
]);
const EXPORTED_FUNCTIONS = Object.freeze([
  "bridgeAbiVersion",
  "engineIdentity",
  "execute",
  "replace",
  "selfTest",
]);

function sha256(data) {
  return createHash("sha256").update(data).digest("hex");
}

async function sha256File(file) {
  return sha256(await readFile(file));
}

async function assertRealDirectory(candidate, label) {
  const details = await lstat(candidate).catch(() => null);
  if (!details?.isDirectory() || details.isSymbolicLink()) {
    throw new Error(`${label} must be a real directory: ${candidate}`);
  }
  return realpath(candidate);
}

async function assertRegularFile(candidate, label) {
  const details = await lstat(candidate).catch(() => null);
  if (!details?.isFile() || details.isSymbolicLink()) {
    throw new Error(
      `${label} must be a regular, non-symlink file: ${candidate}`,
    );
  }
  return realpath(candidate);
}

async function resolveExecutable(name, environment = process.env) {
  for (const directory of (environment.PATH ?? "").split(path.delimiter)) {
    if (!directory) continue;
    const candidate = path.join(directory, name);
    try {
      await access(candidate, fsConstants.X_OK);
      if ((await stat(candidate)).isFile()) return realpath(candidate);
    } catch {
      // Continue through PATH.
    }
  }
  throw new Error(`${name} is required on PATH for the TeaVM Java build.`);
}

function commandLabel(command, arguments_) {
  return [command, ...arguments_]
    .map((part) =>
      /^[A-Za-z0-9_./:=,+-]+$/u.test(part) ? part : JSON.stringify(part),
    )
    .join(" ");
}

async function run(command, arguments_, options = {}) {
  const child = spawn(command, arguments_, {
    cwd: options.cwd,
    env: options.env,
    stdio: ["ignore", "pipe", "pipe"],
  });
  const stdout = [];
  const stderr = [];
  child.stdout.on("data", (chunk) => stdout.push(chunk));
  child.stderr.on("data", (chunk) => stderr.push(chunk));
  const result = await new Promise((resolve, reject) => {
    child.once("error", reject);
    child.once("close", (code, signal) => resolve({ code, signal }));
  });
  const output = Buffer.concat(stdout).toString("utf8");
  const errorOutput = Buffer.concat(stderr).toString("utf8");
  if (result.code !== 0) {
    const detail = [output, errorOutput].filter(Boolean).join("\n").trim();
    throw new Error(
      `${commandLabel(command, arguments_)} failed with ${
        result.signal ? `signal ${result.signal}` : `exit code ${result.code}`
      }${detail ? `:\n${detail}` : "."}`,
    );
  }
  return { stdout: output, stderr: errorOutput };
}

async function verifyToolchain() {
  const maven = await resolveExecutable("mvn");
  const java = await resolveExecutable("java");
  const mavenVersion = await run(maven, ["--version"]);
  const javaVersion = await run(java, ["-version"]);
  if (
    !mavenVersion.stdout.startsWith(
      `Apache Maven ${JAVA_LOCK.toolchain.mavenVersion} `,
    )
  ) {
    throw new Error(
      `The Java pack requires Maven ${JAVA_LOCK.toolchain.mavenVersion}.`,
    );
  }
  const javaText = `${javaVersion.stdout}\n${javaVersion.stderr}`;
  if (
    !javaText.includes(`openjdk version "${JAVA_LOCK.toolchain.javaVersion}`)
  ) {
    throw new Error(
      `The Java pack requires OpenJDK ${JAVA_LOCK.toolchain.javaVersion}.`,
    );
  }

  const repository = path.join(homedir(), ".m2", "repository");
  for (const artifact of JAVA_LOCK.toolchain.artifacts) {
    const file = await assertRegularFile(
      path.join(repository, artifact.relativePath),
      artifact.coordinate,
    );
    const actual = await sha256File(file);
    if (actual !== artifact.sha256) {
      throw new Error(
        `${artifact.coordinate} SHA-256 mismatch: expected ${artifact.sha256}, got ${actual}.`,
      );
    }
  }
  return { maven };
}

function cleanBuildEnvironment() {
  const environment = { ...process.env };
  for (const variable of [
    "CLASSPATH",
    "JAVA_TOOL_OPTIONS",
    "MAVEN_ARGS",
    "MAVEN_OPTS",
    "_JAVA_OPTIONS",
  ]) {
    delete environment[variable];
  }
  environment.LANG = "C";
  environment.LC_ALL = "C";
  environment.SOURCE_DATE_EPOCH = String(JAVA_LOCK.sourceDateEpoch);
  environment.TZ = "UTC";
  return environment;
}

async function sourceFiles(root) {
  return Promise.all(
    BRIDGE_SOURCE_FILES.map(async (relativePath) => {
      const file = await assertRegularFile(
        path.join(root, relativePath),
        `Java bridge source ${relativePath}`,
      );
      const contents = await readFile(file);
      return {
        path: relativePath,
        sha256: sha256(contents),
        bytes: contents.byteLength,
      };
    }),
  );
}

async function fileMetadata(pack, name) {
  const contents = await readFile(path.join(pack, name));
  return { path: name, sha256: sha256(contents), bytes: contents.byteLength };
}

async function expectedMetadata(root, pack) {
  return {
    schemaVersion: 1,
    engine: "java",
    engineName: "TeaVM java.util.regex",
    engineVersion: JAVA_LOCK.engineVersion,
    semanticIdentity:
      "TeaVM 0.15.0 classlib java.util.regex; Apache Harmony-derived; not OpenJDK",
    status: "production-compatibility-runtime",
    bridge: {
      abiVersion: JAVA_LOCK.bridgeAbi,
      maximumPatternUtf16: JAVA_LOCK.maximumPatternUtf16,
      maximumSubjectBytes: JAVA_LOCK.maximumSubjectBytes,
      maximumReplacementUtf16: JAVA_LOCK.maximumReplacementUtf16,
      maximumMatches: JAVA_LOCK.maximumMatches,
      maximumCaptureRows: JAVA_LOCK.maximumCaptureRows,
      maximumCaptureGroups: JAVA_LOCK.maximumCaptureGroups,
      maximumOutputBytes: JAVA_LOCK.maximumOutputBytes,
      offsetUnit: "utf16",
      exports: [...EXPORTED_FUNCTIONS],
      sourceFiles: await sourceFiles(root),
    },
    source: {
      repository: JAVA_LOCK.source.repository,
      tag: JAVA_LOCK.source.tag,
      commitSha1: JAVA_LOCK.source.commit,
      treeSha1: JAVA_LOCK.source.tree,
      license: JAVA_LOCK.source.license,
      upstreamLicenseSha256: JAVA_LOCK.source.licenseSha256,
      upstreamNoticeSha256: JAVA_LOCK.source.noticeSha256,
      packagedNoticeSha256: JAVA_LOCK.source.packagedNoticeSha256,
      noticeNormalization:
        "The packaged notice removes one redundant blank line and adds a final LF; its attribution text is unchanged.",
    },
    toolchain: {
      mavenVersion: JAVA_LOCK.toolchain.mavenVersion,
      javaVersion: JAVA_LOCK.toolchain.javaVersion,
      teaVmArtifacts: JAVA_LOCK.toolchain.artifacts.map(
        ({ coordinate, sha256: digest }) => ({
          coordinate,
          sha256: digest,
        }),
      ),
    },
    configuration: {
      target: "JavaScript ES2015 module",
      minified: true,
      optimizationLevel: "ADVANCED",
      sourceMaps: false,
      filesystem: false,
      supportedApplicationFlags: ["g", "i", "d", "m", "s", "u", "x", "U"],
      unixLines: "d maps to TeaVM Pattern.UNIX_LINES.",
      unicodeCharacterClassCompatibility:
        "TeaVM 0.15.0 lacks OpenJDK UNICODE_CHARACTER_CLASS; U implies Unicode case folding only.",
      replacement:
        "TeaVM 0.15.0 Matcher appendReplacement/appendTail semantics: only single-digit $n references; ${name} is rejected; not OpenJDK Matcher parity.",
    },
    sourceDateEpoch: JAVA_LOCK.sourceDateEpoch,
    files: await Promise.all(
      ["LICENSE.txt", "NOTICE.txt", "java-regex.mjs"].map((name) =>
        fileMetadata(pack, name),
      ),
    ),
  };
}

async function expectedChecksums(pack) {
  const lines = await Promise.all(
    CHECKSUM_FILES.map(
      async (name) => `${await sha256File(path.join(pack, name))}  ${name}`,
    ),
  );
  return `${lines.join("\n")}\n`;
}

function equalJson(left, right) {
  return JSON.stringify(left) === JSON.stringify(right);
}

function readField(payload, state) {
  const colon = payload.indexOf(":", state.cursor);
  if (colon < 0) throw new Error("Malformed Java smoke-test payload.");
  const length = Number.parseInt(payload.slice(state.cursor, colon), 10);
  if (!Number.isSafeInteger(length) || length < 0) {
    throw new Error("Malformed Java smoke-test field length.");
  }
  const start = colon + 1;
  const end = start + length;
  if (end > payload.length) {
    throw new Error("Truncated Java smoke-test payload.");
  }
  state.cursor = end;
  return payload.slice(start, end);
}

async function smokeJavaPack(pack) {
  const moduleFile = path.join(pack, "java-regex.mjs");
  const imported = await import(
    `${pathToFileURL(moduleFile).href}?verify=${await sha256File(moduleFile)}`
  );
  for (const name of EXPORTED_FUNCTIONS) {
    if (typeof imported[name] !== "function") {
      throw new Error(`The Java module is missing export ${name}.`);
    }
  }
  if (
    imported.bridgeAbiVersion() !== JAVA_LOCK.bridgeAbi ||
    imported.engineIdentity() !== JAVA_LOCK.engineIdentity ||
    imported.selfTest() !== 0
  ) {
    throw new Error("The TeaVM Java bridge identity or self-test failed.");
  }

  const execution = imported.execute("(?<=a)(b)\\1", "abb", 0, true, 10, 10);
  const executionState = { cursor: 0 };
  const executionFields = Array.from({ length: 9 }, () =>
    readField(execution, executionState),
  );
  if (
    executionState.cursor !== execution.length ||
    JSON.stringify(executionFields) !==
      JSON.stringify(["1", "ok", "1", "0", "1", "1", "3", "1", "2"])
  ) {
    throw new Error("The TeaVM Java matching smoke test failed.");
  }

  const replacement = imported.replace("(a)", "a a", "<$1>", 0, true, 10, 10);
  const replacementState = { cursor: 0 };
  const replacementFields = [];
  while (replacementState.cursor < replacement.length) {
    replacementFields.push(readField(replacement, replacementState));
  }
  if (
    replacementFields.at(-2) !== "0" ||
    replacementFields.at(-1) !== "<a> <a>"
  ) {
    throw new Error("The TeaVM Java replacement smoke test failed.");
  }
}

export async function verifyJavaPack(packDirectory, root) {
  const repositoryRoot = await assertRealDirectory(
    root,
    "Regex Tools repository",
  );
  const pack = await assertRealDirectory(packDirectory, "Java engine pack");
  const entries = (await readdir(pack, { withFileTypes: true })).sort(
    (left, right) =>
      left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
  );
  if (
    entries.length !== PACK_FILES.length ||
    entries.some(
      (entry, index) =>
        entry.name !== PACK_FILES[index] ||
        !entry.isFile() ||
        entry.isSymbolicLink(),
    )
  ) {
    throw new Error(
      `The staged Java pack must contain only: ${PACK_FILES.join(", ")}.`,
    );
  }
  if (
    (await sha256File(path.join(pack, "LICENSE.txt"))) !==
    JAVA_LOCK.source.licenseSha256
  ) {
    throw new Error("The staged TeaVM licence does not match tag 0.15.0.");
  }
  if (
    (await sha256File(path.join(pack, "NOTICE.txt"))) !==
    JAVA_LOCK.source.packagedNoticeSha256
  ) {
    throw new Error(
      "The staged TeaVM notice does not match the documented normalized notice.",
    );
  }
  const moduleText = await readFile(path.join(pack, "java-regex.mjs"), "utf8");
  if (
    moduleText.includes("sourceMappingURL") ||
    moduleText.includes("/mnt/") ||
    moduleText.includes("/home/") ||
    !moduleText.includes("as bridgeAbiVersion") ||
    !moduleText.includes("as engineIdentity") ||
    !moduleText.includes("as execute") ||
    !moduleText.includes("as replace") ||
    !moduleText.includes("as selfTest")
  ) {
    throw new Error(
      "java-regex.mjs leaks a build path, has a source map, or lacks bridge exports.",
    );
  }

  let metadata;
  try {
    metadata = JSON.parse(
      await readFile(path.join(pack, "engine-metadata.json"), "utf8"),
    );
  } catch (error) {
    throw new Error("Java engine-metadata.json is not valid JSON.", {
      cause: error,
    });
  }
  const expected = await expectedMetadata(repositoryRoot, pack);
  if (!equalJson(metadata, expected)) {
    throw new Error(
      "The staged Java metadata does not match the pinned build contract.",
    );
  }
  if (
    (await readFile(path.join(pack, "SHA256SUMS"), "utf8")) !==
    (await expectedChecksums(pack))
  ) {
    throw new Error("The staged Java SHA256SUMS file is incorrect.");
  }
  await smokeJavaPack(pack);
  return metadata;
}

async function replaceDirectory(stage, target, label) {
  const targetDetails = await lstat(target).catch(() => null);
  if (
    targetDetails &&
    (!targetDetails.isDirectory() || targetDetails.isSymbolicLink())
  ) {
    throw new Error(`${label} must be a real directory.`);
  }
  if (!targetDetails) {
    await rename(stage, target);
    return;
  }
  const backup = `${target}.replaced-${process.pid}`;
  await rename(target, backup);
  try {
    await rename(stage, target);
  } catch (error) {
    await rename(backup, target);
    throw error;
  }
  await rm(backup, { recursive: true });
}

export async function buildJavaPack(root, outputDirectory) {
  const repositoryRoot = await assertRealDirectory(
    root,
    "Regex Tools repository",
  );
  const engine = await assertRealDirectory(
    path.join(repositoryRoot, "engines", "java"),
    "Java engine source",
  );
  const { maven } = await verifyToolchain();
  const environment = cleanBuildEnvironment();
  const buildArguments = [
    "--batch-mode",
    "--no-transfer-progress",
    "--offline",
    "clean",
    "package",
  ];
  await run(maven, buildArguments, { cwd: engine, env: environment });
  const generated = await assertRegularFile(
    path.join(engine, "target", "generated", "java-regex.mjs"),
    "generated TeaVM module",
  );
  const firstBuild = await readFile(generated);
  await run(maven, buildArguments, { cwd: engine, env: environment });
  const secondBuild = await readFile(generated);
  if (sha256(firstBuild) !== sha256(secondBuild)) {
    throw new Error(
      "Two clean TeaVM builds produced different Java module hashes.",
    );
  }

  const output = path.resolve(
    repositoryRoot,
    outputDirectory ?? path.join(".engine-build", "java"),
  );
  await mkdir(path.dirname(output), { recursive: true });
  const stage = await mkdtemp(path.join(path.dirname(output), ".java-build-"));
  try {
    await copyFile(
      path.join(engine, "LICENSE-TeaVM.txt"),
      path.join(stage, "LICENSE.txt"),
      fsConstants.COPYFILE_EXCL,
    );
    await copyFile(
      path.join(engine, "NOTICE-TeaVM.txt"),
      path.join(stage, "NOTICE.txt"),
      fsConstants.COPYFILE_EXCL,
    );
    await writeFile(path.join(stage, "java-regex.mjs"), secondBuild, {
      flag: "wx",
    });
    const metadata = await expectedMetadata(repositoryRoot, stage);
    await writeFile(
      path.join(stage, "engine-metadata.json"),
      `${JSON.stringify(metadata, null, 2)}\n`,
      { encoding: "utf8", flag: "wx" },
    );
    await writeFile(
      path.join(stage, "SHA256SUMS"),
      await expectedChecksums(stage),
      { encoding: "utf8", flag: "wx" },
    );
    await verifyJavaPack(stage, repositoryRoot);
    await replaceDirectory(stage, output, "Java build output");
    return {
      output,
      metadata,
      moduleSha256: sha256(secondBuild),
      moduleBytes: secondBuild.byteLength,
    };
  } finally {
    await rm(stage, { recursive: true, force: true });
  }
}

export async function installJavaPack(packDirectory, root) {
  const repositoryRoot = await assertRealDirectory(
    root,
    "Regex Tools repository",
  );
  const source = await assertRealDirectory(packDirectory, "verified Java pack");
  const metadata = await verifyJavaPack(source, repositoryRoot);
  const engineRoot = await assertRealDirectory(
    path.join(repositoryRoot, "public", "engines"),
    "public engine directory",
  );
  const target = path.join(engineRoot, "java");
  const stage = await mkdtemp(path.join(engineRoot, ".java-install-"));
  try {
    for (const name of PACK_FILES) {
      await copyFile(
        path.join(source, name),
        path.join(stage, name),
        fsConstants.COPYFILE_EXCL,
      );
    }
    await verifyJavaPack(stage, repositoryRoot);
    await replaceDirectory(stage, target, "public/engines/java");
    return { output: target, metadata };
  } finally {
    await rm(stage, { recursive: true, force: true });
  }
}

const invokedFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
if (invokedFile === fileURLToPath(import.meta.url)) {
  if (process.argv.length > 3) {
    throw new Error(
      "Usage: node scripts/java-engine-pack.mjs [.engine-build/java]",
    );
  }
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
  const result = await buildJavaPack(root, process.argv[2]);
  console.log(
    `Built deterministic ${result.metadata.semanticIdentity} module: ${result.moduleSha256} (${result.moduleBytes.toLocaleString()} bytes).`,
  );
}
