#!/usr/bin/env node

import { spawn } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';

import { generatedSymlinkManifest, sourceFingerprint } from './preflight.mjs';

const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');

function run(command, args, options) {
  return new Promise((resolve, reject) => {
    const child = spawn(command, args, options);
    child.once('error', reject);
    child.once('exit', (code, signal) => {
      if (code === 0) resolve();
      else
        reject(
          new Error(signal ? `next build terminated by ${signal}` : `next build exited ${code}`),
        );
    });
  });
}

const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));

async function requireRealDirectory(target, { allowMissing = false } = {}) {
  try {
    const stats = await lstat(target);
    if (!stats.isDirectory() || stats.isSymbolicLink()) {
      throw new Error(`${target} must be a real directory, not a symbolic link.`);
    }
  } catch (error) {
    if (allowMissing && error.code === 'ENOENT') return;
    throw error;
  }
}

async function acquireBuildLock(root) {
  const workRoot = path.join(root, '.mosaic-test-work');
  const lock = path.join(workRoot, 'web-build.lock');
  const nonce = randomUUID();
  const owner = JSON.stringify({ pid: process.pid, nonce });
  const deadline = Date.now() + 120_000;
  await mkdir(workRoot, { recursive: true });

  while (Date.now() < deadline) {
    try {
      await mkdir(lock);
      await writeFile(path.join(lock, 'owner.json'), owner, { mode: 0o600 });
      return async () => {
        const current = await readFile(path.join(lock, 'owner.json'), 'utf8');
        if (current !== owner) throw new Error('Web build lock ownership changed before release.');
        const released = `${lock}.released-${nonce}`;
        await rename(lock, released);
        await rm(released, { recursive: true, force: true });
      };
    } catch (error) {
      if (error.code !== 'EEXIST') throw error;
      let lockOwner;
      try {
        lockOwner = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8'));
      } catch (ownerError) {
        if (ownerError.code === 'ENOENT') {
          await delay(25);
          continue;
        }
        throw new Error(`Web build lock is unreadable at ${lock}.`, { cause: ownerError });
      }
      try {
        process.kill(lockOwner.pid, 0);
      } catch (processError) {
        if (processError.code !== 'ESRCH') throw processError;
        const stale = `${lock}.stale-${nonce}`;
        try {
          await rename(lock, stale);
          await rm(stale, { recursive: true, force: true });
        } catch (renameError) {
          if (renameError.code !== 'ENOENT') throw renameError;
        }
        continue;
      }
      await delay(25);
    }
  }
  throw new Error(`Timed out waiting for the web build lock at ${lock}.`);
}

export async function buildWeb({
  root = scriptRoot,
  fingerprint = sourceFingerprint,
  runBuild = async (webDir) =>
    run(path.join(webDir, 'node_modules', '.bin', 'next'), ['build'], {
      cwd: webDir,
      stdio: 'inherit',
    }),
} = {}) {
  const releaseLock = await acquireBuildLock(root);
  try {
    const webDir = path.join(root, 'apps', 'web');
    const nextDir = path.join(webDir, '.next');
    const certificationMarker = path.join(nextDir, '.mosaic-source-hash');
    const symlinkManifest = path.join(nextDir, '.mosaic-symlink-manifest');
    const certificationTemporary = `${certificationMarker}.${randomUUID()}.tmp`;
    const manifestTemporary = `${symlinkManifest}.${randomUUID()}.tmp`;
    const before = await fingerprint(root);

    await requireRealDirectory(nextDir, { allowMissing: true });
    await Promise.all([
      rm(certificationMarker, { force: true }),
      rm(symlinkManifest, { force: true }),
    ]);
    await runBuild(webDir);
    await requireRealDirectory(nextDir);

    const after = await fingerprint(root);
    if (after !== before) {
      throw new Error(
        'Web build inputs changed during next build; generated output was not certified.',
      );
    }

    const manifestContents = await generatedSymlinkManifest(nextDir);
    const certificationContents = `${JSON.stringify({
      version: 1,
      sourceFingerprint: before,
      symlinkManifestHash: createHash('sha256').update(manifestContents).digest('hex'),
    })}\n`;
    await Promise.all([
      writeFile(certificationTemporary, certificationContents, { mode: 0o600 }),
      writeFile(manifestTemporary, manifestContents, { mode: 0o600 }),
    ]);
    // The certification marker is the commit point. Publishing the manifest first
    // leaves interrupted builds untrusted because the marker remains absent.
    await rename(manifestTemporary, symlinkManifest);
    await rename(certificationTemporary, certificationMarker);
  } finally {
    await releaseLock();
  }
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  await buildWeb();
}
