import { createHash } from "node:crypto";
import {
  lstat,
  readFile,
  readdir,
  realpath,
  writeFile,
} from "node:fs/promises";
import path from "node:path";

function compareText(left, right) {
  return left < right ? -1 : left > right ? 1 : 0;
}

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

async function regularFiles(directory, relative = "") {
  const current = path.join(directory, relative);
  const entries = (await readdir(current, { withFileTypes: true })).sort(
    (left, right) => compareText(left.name, right.name),
  );
  const result = [];
  for (const entry of entries) {
    const entryRelative = relative ? `${relative}/${entry.name}` : entry.name;
    const candidate = path.join(directory, entryRelative);
    const details = await lstat(candidate);
    if (details.isSymbolicLink()) {
      throw new Error(`Engine packs may not contain symlinks: ${candidate}`);
    }
    if (entry.isDirectory()) {
      result.push(...(await regularFiles(directory, entryRelative)));
    } else if (entry.isFile()) {
      result.push(entryRelative);
    } else {
      throw new Error(
        `Engine packs may contain only directories and regular files: ${candidate}`,
      );
    }
  }
  return result;
}

export async function writeEngineChecksums(directory) {
  const pack = await realpath(directory);
  const details = await lstat(pack);
  if (!details.isDirectory() || details.isSymbolicLink()) {
    throw new Error(`Engine pack must be a real directory: ${directory}`);
  }
  const files = (await regularFiles(pack))
    .filter((name) => name !== "SHA256SUMS")
    .sort(compareText);
  const records = await Promise.all(
    files.map(async (name) => ({
      name,
      digest: sha256(await readFile(path.join(pack, name))),
    })),
  );
  const text = `${records
    .map(({ digest, name }) => `${digest}  ${name}`)
    .join("\n")}\n`;
  await writeFile(path.join(pack, "SHA256SUMS"), text, {
    encoding: "utf8",
    mode: 0o644,
  });
  return records;
}

export async function verifyChecksummedEnginePack(directory, expectedEngine) {
  const packDetails = await lstat(directory).catch(() => null);
  if (!packDetails?.isDirectory() || packDetails.isSymbolicLink()) {
    throw new Error(
      `${expectedEngine} engine pack must be a real directory: ${directory}`,
    );
  }
  const pack = await realpath(directory);
  const files = (await regularFiles(pack)).sort(compareText);
  if (
    !files.includes("SHA256SUMS") ||
    !files.includes("engine-metadata.json")
  ) {
    throw new Error(
      `${expectedEngine} engine pack requires SHA256SUMS and engine-metadata.json.`,
    );
  }
  const checksumText = await readFile(path.join(pack, "SHA256SUMS"), "utf8");
  const lines = checksumText.endsWith("\n")
    ? checksumText.slice(0, -1).split("\n")
    : [];
  const records = lines.map((line) => {
    const match = /^([0-9a-f]{64})  ([A-Za-z0-9._+/@-]+)$/u.exec(line);
    if (
      !match ||
      match[2] === "SHA256SUMS" ||
      match[2].startsWith("/") ||
      match[2].split("/").includes("..")
    ) {
      throw new Error(
        `${expectedEngine} SHA256SUMS contains a malformed entry.`,
      );
    }
    return { digest: match[1], name: match[2] };
  });
  const names = records.map(({ name }) => name);
  if (
    new Set(names).size !== names.length ||
    [...names].sort(compareText).some((name, index) => name !== names[index])
  ) {
    throw new Error(`${expectedEngine} SHA256SUMS must be unique and sorted.`);
  }
  const expectedFiles = files
    .filter((name) => name !== "SHA256SUMS")
    .sort(compareText);
  if (
    expectedFiles.length !== names.length ||
    expectedFiles.some((name, index) => name !== names[index])
  ) {
    throw new Error(
      `${expectedEngine} engine pack closed file set does not match SHA256SUMS.`,
    );
  }
  for (const record of records) {
    const actual = sha256(await readFile(path.join(pack, record.name)));
    if (actual !== record.digest) {
      throw new Error(
        `${expectedEngine} engine asset ${record.name} SHA-256 mismatch.`,
      );
    }
  }
  const metadata = JSON.parse(
    await readFile(path.join(pack, "engine-metadata.json"), "utf8"),
  );
  if (
    metadata.schemaVersion !== 1 ||
    metadata.engine !== expectedEngine ||
    !Array.isArray(metadata.files)
  ) {
    throw new Error(
      `${expectedEngine} engine metadata has an unexpected identity.`,
    );
  }
  for (const record of metadata.files) {
    if (
      !record ||
      typeof record.path !== "string" ||
      typeof record.sha256 !== "string" ||
      !Number.isSafeInteger(record.bytes) ||
      !names.includes(record.path)
    ) {
      throw new Error(
        `${expectedEngine} engine metadata contains an invalid file record.`,
      );
    }
    const value = await readFile(path.join(pack, record.path));
    if (value.byteLength !== record.bytes || sha256(value) !== record.sha256) {
      throw new Error(
        `${expectedEngine} engine metadata does not match ${record.path}.`,
      );
    }
  }
  return metadata;
}
