'use strict';

const common = require('../common');
if (!common.hasCrypto) common.skip('missing crypto');

const { debuglog } = require('util');
const debug = debuglog('test');
const tmpdir = require('../common/tmpdir');
const assert = require('assert');
const { spawnSync, spawn } = require('child_process');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');

function hash(algo, body) {
  const values = [];
  {
    const h = crypto.createHash(algo);
    h.update(body);
    values.push(`${algo}-${h.digest('base64')}`);
  }
  {
    const h = crypto.createHash(algo);
    h.update(body.replace('\n', '\r\n'));
    values.push(`${algo}-${h.digest('base64')}`);
  }
  return values;
}

const policyPath = './policy.json';
const parentBody = {
  commonjs: `
    if (!process.env.DEP_FILE) {
      console.error(
        'missing required DEP_FILE env to determine dependency'
      );
      process.exit(33);
    }
    require(process.env.DEP_FILE)
  `,
  module: `
    if (!process.env.DEP_FILE) {
      console.error(
        'missing required DEP_FILE env to determine dependency'
      );
      process.exit(33);
    }
    import(process.env.DEP_FILE)
  `,
};
const workerSpawningBody = `
  const path = require('path');
  const { Worker } = require('worker_threads');
  if (!process.env.PARENT_FILE) {
    console.error(
      'missing required PARENT_FILE env to determine worker entry point'
    );
    process.exit(33);
  }
  if (!process.env.DELETABLE_POLICY_FILE) {
    console.error(
      'missing required DELETABLE_POLICY_FILE env to check reloading'
    );
    process.exit(33);
  }
  const w = new Worker(path.resolve(process.env.PARENT_FILE));
  w.on('exit', (status) => process.exit(status === 0 ? 0 : 1));
`;

let nextTestId = 1;
function newTestId() {
  return nextTestId++;
}
tmpdir.refresh();

let spawned = 0;
const toSpawn = [];
function queueSpawn(opts) {
  toSpawn.push(opts);
  drainQueue();
}

function drainQueue() {
  if (spawned > 50) {
    return;
  }
  if (toSpawn.length) {
    const config = toSpawn.shift();
    const {
      shouldSucceed, // = (() => { throw new Error('required')})(),
      preloads, // = (() =>{ throw new Error('required')})(),
      entryPath, // = (() => { throw new Error('required')})(),
      willDeletePolicy, // = (() => { throw new Error('required')})(),
      onError, // = (() => { throw new Error('required')})(),
      resources, // = (() => { throw new Error('required')})(),
      parentPath,
      depPath,
    } = config;
    const testId = newTestId();
    const configDirPath = path.join(
      tmpdir.path,
      `test-policy-integrity-permutation-${testId}`
    );
    const tmpPolicyPath = path.join(
      tmpdir.path,
      `deletable-policy-${testId}.json`
    );
    const cliPolicy = willDeletePolicy ? tmpPolicyPath : policyPath;
    fs.rmdirSync(configDirPath, { maxRetries: 3, recursive: true });
    fs.mkdirSync(configDirPath, { recursive: true });
    const manifest = {
      onerror: onError,
      resources: {},
    };
    const manifestPath = path.join(configDirPath, policyPath);
    for (const [resourcePath, { body, integrities }] of Object.entries(
      resources
    )) {
      const filePath = path.join(configDirPath, resourcePath);
      if (integrities !== null) {
        manifest.resources[pathToFileURL(filePath).href] = {
          integrity: integrities.join(' '),
          dependencies: true,
        };
      }
      fs.writeFileSync(filePath, body, 'utf8');
    }
    const manifestBody = JSON.stringify(manifest);
    fs.writeFileSync(manifestPath, manifestBody);
    if (cliPolicy === tmpPolicyPath) {
      fs.writeFileSync(tmpPolicyPath, manifestBody);
    }
    const spawnArgs = [
      process.execPath,
      [
        '--unhandled-rejections=strict',
        '--experimental-policy',
        cliPolicy,
        ...preloads.flatMap((m) => ['-r', m]),
        entryPath,
        '--',
        testId,
        configDirPath,
      ],
      {
        env: {
          ...process.env,
          DELETABLE_POLICY_FILE: tmpPolicyPath,
          PARENT_FILE: parentPath,
          DEP_FILE: depPath,
        },
        cwd: configDirPath,
        stdio: 'pipe',
      },
    ];
    spawned++;
    const stdout = [];
    const stderr = [];
    const child = spawn(...spawnArgs);
    child.stdout.on('data', (d) => stdout.push(d));
    child.stderr.on('data', (d) => stderr.push(d));
    child.on('exit', (status, signal) => {
      spawned--;
      try {
        if (shouldSucceed) {
          assert.strictEqual(status, 0);
        } else {
          assert.notStrictEqual(status, 0);
        }
      } catch (e) {
        console.log(
          'permutation',
          testId,
          'failed'
        );
        console.dir(
          { config, manifest },
          { depth: null }
        );
        console.log('exit code:', status, 'signal:', signal);
        console.log(`stdout: ${Buffer.concat(stdout)}`);
        console.log(`stderr: ${Buffer.concat(stderr)}`);
        throw e;
      }
      fs.rmdirSync(configDirPath, { maxRetries: 3, recursive: true });
      drainQueue();
    });
  }
}

{
  const { status } = spawnSync(
    process.execPath,
    ['--experimental-policy', policyPath, '--experimental-policy', policyPath],
    {
      stdio: 'pipe',
    }
  );
  assert.notStrictEqual(status, 0, 'Should not allow multiple policies');
}
{
  const enoentFilepath = path.join(tmpdir.path, 'enoent');
  try {
    fs.unlinkSync(enoentFilepath);
  } catch { }
  const { status } = spawnSync(
    process.execPath,
    ['--experimental-policy', enoentFilepath, '-e', ''],
    {
      stdio: 'pipe',
    }
  );
  assert.notStrictEqual(status, 0, 'Should not allow missing policies');
}

/**
 * @template {Record<string, Array<string | string[] | boolean>>} T
 * @param {T} configurations
 * @param {object} path
 * @returns {Array<{[key: keyof T]: T[keyof configurations]}>}
 */
function permutations(configurations, path = {}) {
  const keys = Object.keys(configurations);
  if (keys.length === 0) {
    return path;
  }
  const config = keys[0];
  const { [config]: values, ...otherConfigs } = configurations;
  return values.flatMap((value) => {
    return permutations(otherConfigs, { ...path, [config]: value });
  });
}
const tests = new Set();
function fileExtensionFormat(extension, packageType) {
  if (extension === '.js') {
    return packageType === 'module' ? 'module' : 'commonjs';
  } else if (extension === '.mjs') {
    return 'module';
  } else if (extension === '.cjs') {
    return 'commonjs';
  }
  throw new Error('unknown format ' + extension);
}
for (const permutation of permutations({
  entry: ['worker', 'parent', 'dep'],
  preloads: [[], ['parent'], ['dep']],
  onError: ['log', 'exit'],
  parentExtension: ['.js', '.mjs', '.cjs'],
  parentIntegrity: ['match', 'invalid', 'missing'],
  depExtension: ['.js', '.mjs', '.cjs'],
  depIntegrity: ['match', 'invalid', 'missing'],
  packageType: ['no-package-json', 'module', 'commonjs'],
  packageIntegrity: ['match', 'invalid', 'missing'],
})) {
  let shouldSucceed = true;
  const parentPath = `./parent${permutation.parentExtension}`;
  const effectivePackageType =
    permutation.packageType === 'module' ? 'module' : 'commonjs';
  const parentFormat = fileExtensionFormat(
    permutation.parentExtension,
    effectivePackageType
  );
  const depFormat = fileExtensionFormat(
    permutation.depExtension,
    effectivePackageType
  );
  // non-sensical attempt to require ESM
  if (depFormat === 'module' && parentFormat === 'commonjs') {
    continue;
  }
  const depPath = `./dep${permutation.depExtension}`;
  const workerSpawnerPath = './worker-spawner.cjs';
  const entryPath = {
    dep: depPath,
    parent: parentPath,
    worker: workerSpawnerPath,
  }[permutation.entry];
  const packageJSON = {
    main: entryPath,
    type: permutation.packageType,
  };
  if (permutation.packageType === 'no-field') {
    delete packageJSON.type;
  }
  const resources = {
    [depPath]: {
      body: '',
      integrities: hash('sha256', ''),
    },
  };
  if (permutation.depIntegrity === 'invalid') {
    resources[depPath].body += '\n// INVALID INTEGRITY';
    shouldSucceed = false;
  } else if (permutation.depIntegrity === 'missing') {
    resources[depPath].integrities = null;
    shouldSucceed = false;
  } else if (permutation.depIntegrity === 'match') {
  } else {
    throw new Error('unreachable');
  }
  if (parentFormat !== 'commonjs') {
    permutation.preloads = permutation.preloads.filter((_) => _ !== 'parent');
  }
  const hasParent =
    permutation.entry !== 'dep' || permutation.preloads.includes('parent');
  if (hasParent) {
    resources[parentPath] = {
      body: parentBody[parentFormat],
      integrities: hash('sha256', parentBody[parentFormat]),
    };
    if (permutation.parentIntegrity === 'invalid') {
      resources[parentPath].body += '\n// INVALID INTEGRITY';
      shouldSucceed = false;
    } else if (permutation.parentIntegrity === 'missing') {
      resources[parentPath].integrities = null;
      shouldSucceed = false;
    } else if (permutation.parentIntegrity === 'match') {
    } else {
      throw new Error('unreachable');
    }
  }
  if (permutation.entry === 'worker') {
    resources[workerSpawnerPath] = {
      body: workerSpawningBody,
      integrities: hash('sha256', workerSpawningBody),
    };
  }
  if (permutation.packageType !== 'no-package-json') {
    let packageBody = JSON.stringify(packageJSON, null, 2);
    let packageIntegrities = hash('sha256', packageBody);
    if (
      permutation.parentExtension !== '.js' ||
      permutation.depExtension !== '.js'
    ) {
      // NO PACKAGE LOOKUP
      continue;
    }
    if (permutation.packageIntegrity === 'invalid') {
      packageJSON['//'] = 'INVALID INTEGRITY';
      packageBody = JSON.stringify(packageJSON, null, 2);
      shouldSucceed = false;
    } else if (permutation.packageIntegrity === 'missing') {
      packageIntegrities = [];
      shouldSucceed = false;
    } else if (permutation.packageIntegrity === 'match') {
    } else {
      throw new Error('unreachable');
    }
    resources['./package.json'] = {
      body: packageBody,
      integrities: packageIntegrities,
    };
  }
  const willDeletePolicy = permutation.entry === 'worker';
  if (permutation.onError === 'log') {
    shouldSucceed = true;
  }
  tests.add(
    JSON.stringify({
      // hasParent,
      // original: permutation,
      onError: permutation.onError,
      shouldSucceed,
      entryPath,
      willDeletePolicy,
      preloads: permutation.preloads
        .map((_) => {
          return {
            '': '',
            'parent': parentFormat === 'commonjs' ? parentPath : '',
            'dep': depFormat === 'commonjs' ? depPath : '',
          }[_];
        })
        .filter(Boolean),
      parentPath,
      depPath,
      resources,
    })
  );
}
debug(`spawning ${tests.size} policy integrity permutations`);
debug(
  'use NODE_DEBUG=test:policy-integrity:NUMBER to log a specific permutation'
);
for (const config of tests) {
  const parsed = JSON.parse(config);
  tests.delete(config);
  queueSpawn(parsed);
}
