import { createHash } from 'node:crypto';
import {
  chmod,
  copyFile,
  lstat,
  mkdir,
  mkdtemp,
  readFile,
  rename,
  rm,
  writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import { createReadStream } from 'node:fs';

const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const lockPath = join(repositoryRoot, 'scripts', 'ffmpeg-source-lock.json');
const reviewedLockPath = join(
  repositoryRoot,
  'scripts',
  'reviewed-ffmpeg-core-lock.json'
);
const readmePath = join(repositoryRoot, 'scripts', 'ffmpeg-source-README.md');
const releaseDirectory = join(repositoryRoot, 'release');
const force = process.argv.includes('--force');

for (const argument of process.argv.slice(2)) {
  if (argument !== '--force') {
    throw new Error(`Unknown argument: ${argument}`);
  }
}

function assertLock(lock) {
  if (
    lock?.schemaVersion !== 1 ||
    !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(
      lock.coreVersion
    ) ||
    !/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(lock.wrapperRelease) ||
    !Number.isSafeInteger(lock.sourceDateEpoch) ||
    lock.sourceDateEpoch < 315532800 ||
    lock.sourceDateEpoch > 4102444799 ||
    !Array.isArray(lock.sources) ||
    lock.sources.length === 0
  ) {
    throw new TypeError('Invalid FFmpeg source lock header.');
  }
  const directories = new Set();
  for (const [index, source] of lock.sources.entries()) {
    if (
      typeof source?.name !== 'string' ||
      !/^[a-zA-Z0-9][a-zA-Z0-9 .()_+-]*$/u.test(source.name) ||
      typeof source.directory !== 'string' ||
      !/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/u.test(source.directory) ||
      source.directory
        .split('/')
        .some((part) => part === '' || part === '.' || part === '..') ||
      directories.has(source.directory) ||
      typeof source.repository !== 'string' ||
      !source.repository.startsWith('https://') ||
      !/^[a-f0-9]{40}$/u.test(source.revision)
    ) {
      throw new TypeError(`Invalid FFmpeg source lock entry ${index}.`);
    }
    directories.add(source.directory);
  }
}

async function run(command, argumentsList, options = {}) {
  await new Promise((resolvePromise, rejectPromise) => {
    const child = spawn(command, argumentsList, {
      stdio: 'inherit',
      ...options,
    });
    child.once('error', rejectPromise);
    child.once('exit', (code, signal) => {
      if (code === 0) {
        resolvePromise();
      } else {
        rejectPromise(
          new Error(
            `${command} exited with ${code ?? `signal ${signal ?? 'unknown'}`}`
          )
        );
      }
    });
  });
}

async function output(command, argumentsList, options = {}) {
  return await new Promise((resolvePromise, rejectPromise) => {
    let stdout = '';
    let stderr = '';
    const child = spawn(command, argumentsList, {
      stdio: ['ignore', 'pipe', 'pipe'],
      ...options,
    });
    child.stdout.setEncoding('utf8');
    child.stderr.setEncoding('utf8');
    child.stdout.on('data', (chunk) => {
      stdout += chunk;
    });
    child.stderr.on('data', (chunk) => {
      stderr += chunk;
    });
    child.once('error', rejectPromise);
    child.once('exit', (code, signal) => {
      if (code === 0) {
        resolvePromise(stdout.trim());
      } else {
        rejectPromise(
          new Error(
            `${command} exited with ${code ?? `signal ${signal ?? 'unknown'}`}: ${stderr.trim()}`
          )
        );
      }
    });
  });
}

async function sha256(file) {
  const hash = createHash('sha256');
  await new Promise((resolvePromise, rejectPromise) => {
    const stream = createReadStream(file);
    stream.on('data', (chunk) => hash.update(chunk));
    stream.once('error', rejectPromise);
    stream.once('end', resolvePromise);
  });
  return hash.digest('hex');
}

async function optionalDetails(path) {
  try {
    return await lstat(path);
  } catch (error) {
    if (error && typeof error === 'object' && error.code === 'ENOENT') {
      return undefined;
    }
    throw error;
  }
}

async function ensureReleaseDirectory() {
  let details = await optionalDetails(releaseDirectory);
  if (!details) {
    await mkdir(releaseDirectory, { recursive: true });
    details = await lstat(releaseDirectory);
  }
  if (details.isSymbolicLink() || !details.isDirectory()) {
    throw new Error(
      `Release path must be a real directory: ${releaseDirectory}`
    );
  }
}

async function assertReplaceableOutput(path) {
  const details = await optionalDetails(path);
  if (!details) return false;
  if (!force) {
    throw new Error(
      `${path} already exists; pass --force to replace this exact artifact.`
    );
  }
  if (details.isSymbolicLink() || !details.isFile()) {
    throw new Error(`Refusing to replace non-file output ${path}.`);
  }
  return true;
}

async function publishSourcePair({
  archiveExists,
  archivePath,
  checksumExists,
  checksumPath,
  stagedArchivePath,
  stagedChecksumPath,
  stagingRoot,
}) {
  const previousArchive = join(stagingRoot, 'previous-archive');
  const previousChecksum = join(stagingRoot, 'previous-checksum');
  let archiveBackedUp = false;
  let checksumBackedUp = false;
  let archivePublished = false;
  let checksumPublished = false;
  try {
    if (archiveExists) {
      await rename(archivePath, previousArchive);
      archiveBackedUp = true;
    }
    if (checksumExists) {
      await rename(checksumPath, previousChecksum);
      checksumBackedUp = true;
    }
    await rename(stagedArchivePath, archivePath);
    archivePublished = true;
    await rename(stagedChecksumPath, checksumPath);
    checksumPublished = true;
  } catch (error) {
    if (checksumPublished) await rm(checksumPath, { force: true });
    if (archivePublished) await rm(archivePath, { force: true });
    if (checksumBackedUp) await rename(previousChecksum, checksumPath);
    if (archiveBackedUp) await rename(previousArchive, archivePath);
    throw error;
  }
}

const lock = JSON.parse(await readFile(lockPath, 'utf8'));
assertLock(lock);
const reviewedLock = JSON.parse(await readFile(reviewedLockPath, 'utf8'));
if (
  reviewedLock?.schemaVersion !== 1 ||
  reviewedLock.coreVersion !== lock.coreVersion ||
  typeof reviewedLock.buildId !== 'string' ||
  !/^[a-zA-Z0-9._-]+$/u.test(reviewedLock.buildId) ||
  !reviewedLock.buildId.startsWith(`${lock.coreVersion}-`)
) {
  throw new Error(
    'The reviewed-core lock does not identify a build-qualified source bundle.'
  );
}
const archiveName = `ffmpeg-core-${reviewedLock.buildId}-corresponding-source.tar.xz`;
const archivePath = join(releaseDirectory, archiveName);
const checksumPath = `${archivePath}.sha256`;
await ensureReleaseDirectory();
const archiveExists = await assertReplaceableOutput(archivePath);
const checksumExists = await assertReplaceableOutput(checksumPath);

const stagingRoot = await mkdtemp(
  join(releaseDirectory, '.ffmpeg-source-package-')
);
const stagedArchivePath = join(stagingRoot, archiveName);
const stagedChecksumPath = `${stagedArchivePath}.sha256`;
let temporaryRoot;
const archiveRootName = archiveName.replace(/\.tar\.xz$/u, '');

try {
  temporaryRoot = await mkdtemp(
    join(tmpdir(), 'av-tools-corresponding-source-')
  );
  const archiveRoot = join(temporaryRoot, archiveRootName);
  const sourcesRoot = join(archiveRoot, 'sources');
  await mkdir(sourcesRoot, { recursive: true });
  const archiveReadmePath = join(archiveRoot, 'README.md');
  const archiveLockPath = join(archiveRoot, 'SOURCE_LOCK.json');
  await copyFile(readmePath, archiveReadmePath);
  await copyFile(lockPath, archiveLockPath);
  await chmod(archiveReadmePath, 0o644);
  await chmod(archiveLockPath, 0o644);

  for (const [index, source] of lock.sources.entries()) {
    const checkout = join(temporaryRoot, `checkout-${index}`);
    const sourceTar = join(temporaryRoot, `source-${index}.tar`);
    await mkdir(checkout);
    await run('git', ['init', '--quiet'], { cwd: checkout });
    await run('git', ['remote', 'add', 'origin', source.repository], {
      cwd: checkout,
    });
    await run(
      'git',
      ['fetch', '--quiet', '--depth', '1', 'origin', source.revision],
      { cwd: checkout }
    );
    const revision = await output('git', ['rev-parse', 'FETCH_HEAD'], {
      cwd: checkout,
    });
    if (revision !== source.revision) {
      throw new Error(
        `${source.name} resolved to ${revision}, expected ${source.revision}.`
      );
    }
    await run(
      'git',
      [
        'archive',
        '--format=tar',
        `--prefix=sources/${source.directory}/`,
        `--output=${sourceTar}`,
        source.revision,
      ],
      { cwd: checkout }
    );
    await run('tar', [
      '--same-permissions',
      '-xf',
      sourceTar,
      '-C',
      archiveRoot,
    ]);
    process.stdout.write(`Archived ${source.name} at ${source.revision}\n`);
  }

  const timestamp = `@${lock.sourceDateEpoch}`;
  await run('find', [
    archiveRoot,
    '-type',
    'd',
    '-exec',
    'chmod',
    '0755',
    '{}',
    '+',
  ]);
  await run('find', [
    archiveRoot,
    '-type',
    'f',
    '-perm',
    '/111',
    '-exec',
    'chmod',
    '0755',
    '{}',
    '+',
  ]);
  await run('find', [
    archiveRoot,
    '-type',
    'f',
    '!',
    '-perm',
    '/111',
    '-exec',
    'chmod',
    '0644',
    '{}',
    '+',
  ]);
  await run('find', [
    archiveRoot,
    '-exec',
    'touch',
    '-h',
    '-d',
    timestamp,
    '{}',
    '+',
  ]);
  await run('tar', [
    '--sort=name',
    '--format=posix',
    '--owner=0',
    '--group=0',
    '--numeric-owner',
    `--mtime=${timestamp}`,
    '--pax-option=delete=atime,delete=ctime',
    '-C',
    temporaryRoot,
    '-cJf',
    stagedArchivePath,
    archiveRootName,
  ]);

  const digest = await sha256(stagedArchivePath);
  await writeFile(stagedChecksumPath, `${digest}  ${basename(archivePath)}\n`, {
    mode: 0o644,
  });
  await chmod(stagedArchivePath, 0o644);
  await chmod(stagedChecksumPath, 0o644);
  await publishSourcePair({
    archiveExists,
    archivePath,
    checksumExists,
    checksumPath,
    stagedArchivePath,
    stagedChecksumPath,
    stagingRoot,
  });
  process.stdout.write(
    `Corresponding Source: ${archivePath}\nSHA-256: ${digest}\n`
  );
} finally {
  if (temporaryRoot) {
    await rm(temporaryRoot, { recursive: true, force: true });
  }
  await rm(stagingRoot, { recursive: true, force: true });
}
