import { spawn } from 'node:child_process';
import {
  copyFile,
  lstat,
  mkdir,
  mkdtemp,
  readFile,
  rm,
  writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { sha256File, verifyChecksum } from './checksum-release.mjs';

const repositoryRoot = path.resolve(
  path.dirname(fileURLToPath(import.meta.url)),
  '..'
);
const reviewedCoreBuildId = '0.12.10-reviewed-stack5m.1';

function consumeValue(argumentsList, index, option) {
  const value = argumentsList[index + 1];
  if (!value || value.startsWith('--')) {
    throw new Error(`${option} requires a path value.`);
  }
  return value;
}

export function parsePortalArguments(argumentsList, root = repositoryRoot) {
  const packageDefaults = {
    artifact: undefined,
    portalRoot: path.join(path.dirname(root), 'references', 'toolbox-portal'),
    allowUnlicensedDevelopmentSmoke: false,
    allowKnownHeaderGap: false,
    keepTemp: false,
  };
  for (let index = 0; index < argumentsList.length; index += 1) {
    const argument = argumentsList[index];
    if (argument === '--artifact') {
      packageDefaults.artifact = path.resolve(
        root,
        consumeValue(argumentsList, index, argument)
      );
      index += 1;
    } else if (argument === '--portal-root') {
      packageDefaults.portalRoot = path.resolve(
        root,
        consumeValue(argumentsList, index, argument)
      );
      index += 1;
    } else if (argument === '--allow-unlicensed-development-smoke') {
      packageDefaults.allowUnlicensedDevelopmentSmoke = true;
    } else if (argument === '--allow-known-header-gap') {
      packageDefaults.allowKnownHeaderGap = true;
    } else if (argument === '--keep-temp') {
      packageDefaults.keepTemp = true;
    } else {
      throw new Error(`Unknown portal-smoke argument: ${argument}`);
    }
  }
  return packageDefaults;
}

function run(command, argumentsList, options = {}) {
  console.log(`\n> ${command} ${argumentsList.join(' ')}`);
  return new Promise((resolve, reject) => {
    const child = spawn(command, argumentsList, {
      cwd: options.cwd,
      env: {
        ...process.env,
        npm_config_audit: 'false',
        npm_config_fund: 'false',
      },
      stdio: 'inherit',
    });
    child.once('error', reject);
    child.once('exit', (code, signal) => {
      if (code === 0) resolve();
      else {
        reject(
          new Error(
            `${command} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}.`
          )
        );
      }
    });
  });
}

async function capture(command, argumentsList, cwd) {
  return await new Promise((resolve, reject) => {
    const child = spawn(command, argumentsList, {
      cwd,
      env: process.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));
    child.once('error', reject);
    child.once('exit', (code) => {
      if (code === 0) {
        resolve(Buffer.concat(stdout).toString('utf8').trim());
      } else {
        reject(
          new Error(
            `${command} exited ${code}: ${Buffer.concat(stderr).toString('utf8').trim()}`
          )
        );
      }
    });
  });
}

async function requireRegularFile(file, label) {
  const details = await lstat(file);
  if (details.isSymbolicLink() || !details.isFile()) {
    throw new Error(`${label} is not a regular file: ${file}`);
  }
}

function includesDevelopmentMarker(archive) {
  return archive.includes(
    Buffer.from('DEVELOPMENT-ONLY-NOT-FOR-DISTRIBUTION.txt', 'utf8')
  );
}

function cspDirectives(policy) {
  return new Map(
    policy
      .split(';')
      .map((directive) => directive.trim().split(/\s+/))
      .filter((parts) => parts[0])
      .map(([name, ...values]) => [name, new Set(values)])
  );
}

export function inspectPortalHeaders(configuration) {
  const errors = [];
  const warnings = [];
  const requiredHeaders = [
    ['Cross-Origin-Opener-Policy', 'same-origin'],
    ['Cross-Origin-Embedder-Policy', 'require-corp'],
    ['Cross-Origin-Resource-Policy', 'same-origin'],
  ];
  for (const [header, value] of requiredHeaders) {
    const expression = new RegExp(
      `add_header\\s+${header}\\s+"?${value}"?\\s+always`,
      'i'
    );
    if (!expression.test(configuration)) {
      errors.push(`Missing ${header}: ${value}`);
    }
  }

  const cspMatch =
    /add_header\s+Content-Security-Policy\s+"([^"]+)"\s+always/i.exec(
      configuration
    );
  if (!cspMatch) {
    errors.push('Missing Content-Security-Policy header');
  } else {
    const directives = cspDirectives(cspMatch[1]);
    const requires = (directive, value) =>
      directives.get(directive)?.has(value) === true;
    if (!requires('script-src', "'wasm-unsafe-eval'")) {
      errors.push("CSP script-src lacks 'wasm-unsafe-eval'");
    }
    if (!requires('worker-src', "'self'") || !requires('worker-src', 'blob:')) {
      errors.push("CSP worker-src must contain 'self' and blob:");
    }
    if (!requires('media-src', "'self'") || !requires('media-src', 'blob:')) {
      errors.push("CSP media-src must contain 'self' and blob:");
    }
  }

  if (!/application\/wasm\s+wasm(?:\s|;)/i.test(configuration)) {
    warnings.push(
      'The config does not explicitly map `.wasm` to application/wasm; verify the pinned image mime.types response.'
    );
  }
  const immutableCoreRule = configuration
    .split(/\r?\n/u)
    .find(
      (line) =>
        /vendor\/ffmpeg\//iu.test(line) &&
        /ffmpeg-core/iu.test(line) &&
        /max-age=31536000/iu.test(line) &&
        /immutable/iu.test(line)
    );
  const supportsReviewedCore =
    immutableCoreRule !== undefined &&
    immutableCoreRule.replaceAll('\\', '').includes(reviewedCoreBuildId);
  if (!supportsReviewedCore) {
    warnings.push(
      `The config does not give reviewed FFmpeg build ${reviewedCoreBuildId} an explicit immutable cache rule.`
    );
  }
  return { errors, warnings };
}

async function verifyAssembly(output, appVersion, digest) {
  const catalogue = JSON.parse(
    await readFile(path.join(output, 'toolbox.catalog.json'), 'utf8')
  );
  if (
    catalogue.apps?.length !== 1 ||
    catalogue.apps[0]?.manifest !== './apps/av/toolbox-app.json'
  ) {
    throw new Error('Generated catalogue does not contain the locked av app.');
  }
  const release = JSON.parse(
    await readFile(path.join(output, 'toolbox.release.json'), 'utf8')
  );
  const provenance = release.apps?.[0];
  if (
    release.apps?.length !== 1 ||
    provenance.id !== 'de.add-ideas.av-tools' ||
    provenance.version !== appVersion ||
    provenance.sha256 !== digest ||
    provenance.target !== 'av'
  ) {
    throw new Error('Generated release provenance does not match the AV lock.');
  }

  const appRoot = path.join(output, 'apps', 'av');
  const manifest = JSON.parse(
    await readFile(path.join(appRoot, 'toolbox-app.json'), 'utf8')
  );
  if (
    manifest.id !== 'de.add-ideas.av-tools' ||
    manifest.version !== appVersion ||
    manifest.requirements?.crossOriginIsolated !== false
  ) {
    throw new Error(
      'Assembled AV manifest identity/fallback contract changed.'
    );
  }
  for (const reference of [
    './index.html',
    './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js',
    './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm',
    './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
    './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
    './vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
  ]) {
    const relative = reference.replace(/^\.\//, '');
    await requireRegularFile(
      path.join(appRoot, ...relative.split('/')),
      `Assembled ${reference}`
    );
  }
  const indexHtml = await readFile(path.join(appRoot, 'index.html'), 'utf8');
  if (/(?:src|href)\s*=\s*["']\/(?!\/)/i.test(indexHtml)) {
    throw new Error('Assembled app contains a root-absolute entry asset URL.');
  }
}

async function main() {
  const options = parsePortalArguments(process.argv.slice(2));
  const packageJson = JSON.parse(
    await readFile(path.join(repositoryRoot, 'package.json'), 'utf8')
  );
  const artifact =
    options.artifact ??
    path.join(repositoryRoot, 'release', `av-tools-${packageJson.version}.zip`);
  const sidecar = `${artifact}.sha256`;
  await requireRegularFile(artifact, 'AV release artifact');
  await requireRegularFile(sidecar, 'AV release checksum');
  const digest = await verifyChecksum(artifact, sidecar);
  const artifactBytes = await readFile(artifact);
  const markedUnlicensed = includesDevelopmentMarker(artifactBytes);
  if (markedUnlicensed && !options.allowUnlicensedDevelopmentSmoke) {
    throw new Error(
      'The ZIP is marked unlicensed development-only. Pass `--allow-unlicensed-development-smoke` for local assembly testing.'
    );
  }

  const portalRootDetails = await lstat(options.portalRoot);
  if (portalRootDetails.isSymbolicLink() || !portalRootDetails.isDirectory()) {
    throw new Error(
      `Portal reference must be a real Git directory: ${options.portalRoot}`
    );
  }
  const portalRevision = await capture(
    'git',
    ['rev-parse', 'HEAD'],
    options.portalRoot
  );
  const temporaryRoot = await mkdtemp('/tmp/av-tools-portal-smoke-');
  if (!temporaryRoot.startsWith('/tmp/av-tools-portal-smoke-')) {
    throw new Error(`Unexpected temporary path: ${temporaryRoot}`);
  }
  const portalClone = path.join(temporaryRoot, 'toolbox-portal');
  let successful = false;
  try {
    await run(
      'git',
      [
        'clone',
        '--no-local',
        '--quiet',
        '--no-checkout',
        options.portalRoot,
        portalClone,
      ],
      { cwd: temporaryRoot }
    );
    await run('git', ['checkout', '--quiet', '--detach', portalRevision], {
      cwd: portalClone,
    });
    const clonedRevision = await capture(
      'git',
      ['rev-parse', 'HEAD'],
      portalClone
    );
    if (clonedRevision !== portalRevision) {
      throw new Error('Temporary Portal clone revision changed unexpectedly.');
    }

    const smokeArtifacts = path.join(portalClone, 'smoke-artifacts');
    await mkdir(smokeArtifacts);
    const copiedArtifact = path.join(smokeArtifacts, path.basename(artifact));
    await copyFile(artifact, copiedArtifact);
    await copyFile(sidecar, `${copiedArtifact}.sha256`);

    const templatePath = path.join(
      portalClone,
      'release',
      'toolbox.lock.example.json'
    );
    const lock = JSON.parse(await readFile(templatePath, 'utf8'));
    const portalPackage = JSON.parse(
      await readFile(path.join(portalClone, 'package.json'), 'utf8')
    );
    lock.releaseVersion = `${packageJson.version}-smoke.0`;
    lock.portalVersion = portalPackage.version;
    lock.apps = [
      {
        id: 'de.add-ideas.av-tools',
        version: packageJson.version,
        artifact: `../smoke-artifacts/${path.basename(artifact)}`,
        sha256: digest,
        target: 'av',
      },
    ];
    const lockPath = path.join(
      portalClone,
      'release',
      'av-tools.smoke.lock.json'
    );
    await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`, {
      flag: 'wx',
    });

    await run('npm', ['ci'], { cwd: portalClone });
    await run('npm', ['test'], { cwd: portalClone });
    await run('npm', ['run', 'build'], { cwd: portalClone });
    const output = path.join(portalClone, 'build', 'toolbox-av-smoke');
    await run(
      'npm',
      [
        'run',
        'assemble',
        '--',
        '--lock',
        'release/av-tools.smoke.lock.json',
        '--portal-dist',
        'dist',
        '--output',
        'build/toolbox-av-smoke',
        '--archive',
        'build/add-ideas-toolbox-av-smoke.zip',
      ],
      { cwd: portalClone }
    );
    await verifyAssembly(output, packageJson.version, digest);

    const headerInspection = inspectPortalHeaders(
      await readFile(path.join(portalClone, 'deploy', 'nginx.conf'), 'utf8')
    );
    for (const warning of headerInspection.warnings) {
      console.warn(`Portal header warning: ${warning}`);
    }
    if (headerInspection.errors.length > 0) {
      const message = `Portal header gaps: ${headerInspection.errors.join('; ')}`;
      if (!options.allowKnownHeaderGap) throw new Error(message);
      console.warn(
        `KNOWN HEADER GAP ACCEPTED FOR DEVELOPMENT SMOKE: ${message}`
      );
    }

    successful = true;
    console.log('\nPortal assembly smoke passed');
    console.log(`Portal revision: ${portalRevision}`);
    console.log(`AV artifact SHA-256: ${digest}`);
    console.log('Assembled target: apps/av/');
    console.log(
      'Validated: checksum, extraction, manifest identity/version, catalogue, ST/MT assets, relative entry URLs and ST fallback contract.'
    );
  } finally {
    if (options.keepTemp) {
      console.log(
        `${successful ? 'Kept' : 'Failed; kept'} temporary Portal smoke directory: ${temporaryRoot}`
      );
    } else {
      await rm(temporaryRoot, { force: true, recursive: true });
    }
  }
}

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