import { createHash } from "node:crypto";
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import {
  PLUGIN_EDITOR_EXTENSION_EXTERNAL_PACKAGES,
  validateInstalledPluginPayload,
  validatePluginEditorExtensionBuildProof,
  validatePluginManifestV2,
} from "@thortiq/plugin-api";
import { build } from "esbuild";

const projectDirectory = dirname(fileURLToPath(new URL("../manifest.json", import.meta.url)));
const outputDirectory = join(projectDirectory, "dist");
const STATIC_IMPORT = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)["']([^"']+)["']/gu;

async function sourceFiles(directory) {
  const files = [];
  for (const entry of await readdir(directory, { withFileTypes: true })) {
    const path = join(directory, entry.name);
    if (entry.isDirectory()) files.push(...(await sourceFiles(path)));
    else if (/\.[cm]?[jt]sx?$/u.test(entry.name)) files.push(path);
  }
  return files.sort();
}

async function assertNoInternalImports() {
  for (const path of await sourceFiles(join(projectDirectory, "src"))) {
    const source = await readFile(path, "utf8");
    for (const match of source.matchAll(STATIC_IMPORT)) {
      const specifier = match[1];
      const allowed = specifier === "@thortiq/plugin-api"
        || specifier.startsWith("@thortiq/plugin-api/")
        || specifier === "@thortiq/plugin-sdk"
        || specifier.startsWith("@thortiq/plugin-sdk/")
        || specifier === "@thortiq/plugin-react"
        || specifier === "react"
        || specifier === "react-dom"
        || specifier.startsWith("react-dom/")
        || specifier === "react/jsx-runtime"
        || (specifier.startsWith("./") && !specifier.split("/").includes(".."));
      if (!allowed) {
        throw new Error(`${relative(projectDirectory, path)} imports forbidden module "${specifier}".`);
      }
    }
  }
}

const manifest = JSON.parse(await readFile(join(projectDirectory, "manifest.json"), "utf8"));
const validation = validatePluginManifestV2(manifest);
if (!validation.ok) {
  throw new Error(`manifest.json is invalid: ${validation.issues.map(i => i.code).join(", ")}`);
}
if (manifest.runtime !== "native") throw new Error("This build script targets a native manifest.");
await assertNoInternalImports();

await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
const entryPath = join(outputDirectory, manifest.entry);
const result = await build({
  entryPoints: [join(projectDirectory, "src", "native.ts")],
  outfile: entryPath,
  bundle: true,
  format: "esm",
  platform: "browser",
  target: "es2022",
  legalComments: "none",
  treeShaking: true,
  jsx: "automatic",
  metafile: true,
  ...(manifest.editorExtensionBuild
    ? { external: [...PLUGIN_EDITOR_EXTENSION_EXTERNAL_PACKAGES] }
    : {}),
});
const emittedEntry = Object.values(result.metafile.outputs).find(output =>
  typeof output.entryPoint === "string");
if (!emittedEntry || emittedEntry.imports.length > 0) {
  throw new Error("The native entry must be a self-contained import-free ESM bundle.");
}

const outputs = new Map();
const entryBytes = await readFile(entryPath);
outputs.set(manifest.entry, entryBytes);
if (manifest.editorExtensionBuild) {
  const normalizeBuildPath = value => {
    const normalized = relative(projectDirectory, resolve(value)).split(sep).join("/");
    if (!normalized || normalized === ".." || normalized.startsWith("../")) {
      throw new Error("Editor-extension metafile paths must stay inside the project.");
    }
    return normalized;
  };
  const inputRecords = Object.entries(result.metafile.inputs)
    .map(([path, input]) => ({
      path: normalizeBuildPath(path),
      bytes: input.bytes,
      imports: input.imports.map(item => ({
        path: item.path,
        kind: item.kind,
        external: item.external === true,
      })),
    }))
    .sort((left, right) => left.path.localeCompare(right.path));
  const entryDigest = `sha256:${createHash("sha256").update(entryBytes).digest("hex")}`;
  const proof = {
    version: manifest.editorExtensionBuild.proofVersion,
    builder: "esbuild",
    entry: manifest.entry,
    entryDigest,
    externalPackages: [...PLUGIN_EDITOR_EXTENSION_EXTERNAL_PACKAGES],
    inputs: inputRecords,
    outputs: [{
      path: manifest.entry,
      bytes: emittedEntry.bytes,
      entryPoint: normalizeBuildPath(emittedEntry.entryPoint),
      imports: emittedEntry.imports.map(item => ({
        path: item.path,
        kind: item.kind,
        external: item.external === true,
      })),
    }],
  };
  const proofBytes = Buffer.from(stableManifest(proof), "utf8");
  const proofCheck = validatePluginEditorExtensionBuildProof(
    { ...manifest, integrity: { ...manifest.integrity, [manifest.entry]: entryDigest } },
    proofBytes,
    { entryBytes: entryBytes.byteLength },
  );
  if (!proofCheck.ok) {
    throw new Error(`Editor-extension build proof is invalid: ${proofCheck.issues.map(i => i.code).join(", ")}`);
  }
  await writeFile(join(outputDirectory, manifest.editorExtensionBuild.path), proofBytes);
  outputs.set(manifest.editorExtensionBuild.path, proofBytes);
}
for (const style of manifest.styles ?? []) {
  const bytes = await readFile(join(projectDirectory, style));
  await writeFile(join(outputDirectory, style), bytes);
  outputs.set(style, bytes);
}
for (const asset of manifest.assets ?? []) {
  const bytes = await readFile(join(projectDirectory, asset));
  await mkdir(dirname(join(outputDirectory, asset)), { recursive: true });
  await writeFile(join(outputDirectory, asset), bytes);
  outputs.set(asset, bytes);
}

// Integrity map is generated after every declared output.
const integrity = {};
const payloadFiles = [];
for (const [path, bytes] of [...outputs.entries()].sort(([a], [b]) => (a < b ? -1 : 1))) {
  const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
  integrity[path] = digest;
  payloadFiles.push({ path, kind: "file", sizeBytes: bytes.byteLength, sha256: digest });
}
const closed = { ...manifest, integrity };
const manifestBytes = Buffer.from(stableManifest(closed), "utf8");
const payload = validateInstalledPluginPayload(closed, [
  { path: "manifest.json", kind: "file", sizeBytes: manifestBytes.byteLength },
  ...payloadFiles,
]);
if (!payload.ok) {
  throw new Error(`Closed payload is invalid: ${payload.issues.map(i => i.code).join(", ")}`);
}

// The manifest is written last so its presence marks a complete generation.
await writeFile(join(outputDirectory, "manifest.json"), manifestBytes);
console.log(`Built ${manifest.id}: ${payloadFiles.length + 1} closed files under dist/.`);

function stableManifest(value) {
  return `${JSON.stringify(value, null, 2)}\n`;
}
