/* eslint-disable no-console */
import assert from 'assert';
import path from 'path';
import { writeFileSync, unlinkSync } from 'fs';
import { fork } from 'child_process';
import { fileURLToPath } from 'url';
import * as openpgp from 'openpgp';

/**
 * Benchmark max memory usage recorded during execution of the given function.
 * This spawns a new v8 instance and runs the code there in isolation, to avoid interference between tests.
 * @param {Funtion} function to benchmark (can be async)
 * @returns {NodeJS.MemoryUsage} memory usage snapshot with max RSS (sizes in bytes)
 */
const benchmark = async function(fn) {
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
  const tmpFileName = path.join(__dirname, 'tmp.cjs');
  // the code to execute must be written to a file
  writeFileSync(tmpFileName, `
const assert = require('assert');
const openpgp = require('openpgp');
let maxMemoryComsumption;
let activeSampling = false;

function sampleOnce() {
  const memUsage = process.memoryUsage();
  if (!maxMemoryComsumption || memUsage.rss > maxMemoryComsumption.rss) {
    maxMemoryComsumption = memUsage;
  }
}

function samplePeriodically() {
  setImmediate(() => {
    sampleOnce();
    activeSampling && samplePeriodically();
  });
}

// main body
(async () => {
  maxMemoryComsumption = null;
  activeSampling = true;
  samplePeriodically();
  await (${fn.toString()})();
  // setImmediate is run at the end of the event loop, so we need to manually collect the latest sample
  sampleOnce();
  process.send(maxMemoryComsumption);
  process.exit(); // child process doesn't exit otherwise
})();
`);

  const maxMemoryComsumption = await new Promise((resolve, reject) => {
    const child = fork(tmpFileName);
    child.on('message', function (message) {
      resolve(message);
    });
    child.on('error', function (err) {
      reject(err);
    });
  });

  unlinkSync(tmpFileName);
  return maxMemoryComsumption;
};

const onError = err => {
  console.error('The memory benchmark tests failed by throwing the following error:');
  console.error(err);
  // eslint-disable-next-line no-process-exit
  process.exit(1);
};

class MemoryBenchamrkSuite {
  constructor() {
    this.tests = [];
  }

  add(name, fn) {
    this.tests.push({ name, fn });
  }

  async run() {
    const stats = [];
    for (const { name, fn } of this.tests) {
      const memoryUsage = await benchmark(fn).catch(onError);
      // convert values to MB
      Object.entries(memoryUsage).forEach(([name, value]) => {
        memoryUsage[name] = (value / 1024 / 1024).toFixed(2);
      });
      const { rss, ...usageDetails } = memoryUsage;
      // raw entry format accepted by github-action-pull-request-benchmark
      stats.push({
        name,
        value: rss,
        range: Object.entries(usageDetails).map(([name, value]) => `${name}: ${value}`).join(', '),
        unit: 'MB',
        biggerIsBetter: false
      });
    }
    return stats;
  }
}

/**
 * Memory usage tests.
 * All the necessary variables must be declared inside the test function.
 */
(async () => {
  const suite = new MemoryBenchamrkSuite();

  suite.add('empty test (baseline)', () => {});

  suite.add('openpgp.encrypt/decrypt (CFB, binary)', async () => {
    const ONE_MEGABYTE = 1000000;
    const passwords = 'password';
    const config = { aeadProtect: false, preferredCompressionAlgorithm: openpgp.enums.compression.uncompressed };
    const plaintextMessage = await openpgp.createMessage({ binary: new Uint8Array(ONE_MEGABYTE) });

    const armoredEncryptedMessage = await openpgp.encrypt({ message: plaintextMessage, passwords, config });
    const encryptedMessage = await openpgp.readMessage({ armoredMessage: armoredEncryptedMessage });
    assert.ok(encryptedMessage.packets[1] instanceof openpgp.SymEncryptedIntegrityProtectedDataPacket);
    assert.ok(encryptedMessage.packets[1].version === 1);
    await openpgp.decrypt({ message: encryptedMessage, passwords, config });
  });

  suite.add('openpgp.encrypt/decrypt (CFB, text)', async () => {
    const ONE_MEGABYTE = 1000000;
    const passwords = 'password';
    const config = { aeadProtect: false, preferredCompressionAlgorithm: openpgp.enums.compression.uncompressed };
    const plaintextMessage = await openpgp.createMessage({ text: 'a'.repeat(ONE_MEGABYTE / 2) }); // two bytes per character

    const armoredEncryptedMessage = await openpgp.encrypt({ message: plaintextMessage, passwords, config });
    const encryptedMessage = await openpgp.readMessage({ armoredMessage: armoredEncryptedMessage });
    assert.ok(encryptedMessage.packets[1] instanceof openpgp.SymEncryptedIntegrityProtectedDataPacket);
    assert.ok(encryptedMessage.packets[1].version === 1);
    await openpgp.decrypt({ message: encryptedMessage, passwords, config });
  });

  suite.add('openpgp.encrypt/decrypt (AEAD, binary)', async () => {
    const ONE_MEGABYTE = 1000000;
    const passwords = 'password';
    const config = { aeadProtect: true, preferredCompressionAlgorithm: openpgp.enums.compression.uncompressed };
    const plaintextMessage = await openpgp.createMessage({ binary: new Uint8Array(ONE_MEGABYTE) });

    const armoredEncryptedMessage = await openpgp.encrypt({ message: plaintextMessage, passwords, config });
    const encryptedMessage = await openpgp.readMessage({ armoredMessage: armoredEncryptedMessage });
    assert.ok(encryptedMessage.packets[1] instanceof openpgp.SymEncryptedIntegrityProtectedDataPacket);
    assert.ok(encryptedMessage.packets[1].version === 2);
    await openpgp.decrypt({ message: encryptedMessage, passwords, config });
  });

  suite.add('openpgp.encrypt/decrypt (AEAD, text)', async () => {
    const ONE_MEGABYTE = 1000000;
    const passwords = 'password';
    const config = { aeadProtect: true, preferredCompressionAlgorithm: openpgp.enums.compression.uncompressed };
    const plaintextMessage = await openpgp.createMessage({ text: 'a'.repeat(ONE_MEGABYTE / 2) }); // two bytes per character

    const armoredEncryptedMessage = await openpgp.encrypt({ message: plaintextMessage, passwords, config });
    const encryptedMessage = await openpgp.readMessage({ armoredMessage: armoredEncryptedMessage });
    assert.ok(encryptedMessage.packets[1] instanceof openpgp.SymEncryptedIntegrityProtectedDataPacket);
    assert.ok(encryptedMessage.packets[1].version === 2);
    await openpgp.decrypt({ message: encryptedMessage, passwords, config });
  });

  // streaming tests
  suite.add('openpgp.encrypt/decrypt (CFB, binary, with streaming)', async () => {
    const ONE_MEGABYTE = 1000000;
    function* largeDataGenerator({ chunk, numberOfChunks }) {
      for (let chunkNumber = 0; chunkNumber < numberOfChunks; chunkNumber++) {
        yield chunk;
      }
    }

    const passwords = 'password';
    const config = { aeadProtect: false, preferredCompressionAlgorithm: openpgp.enums.compression.uncompressed };
    const inputStream = require('stream/web').ReadableStream.from(largeDataGenerator({ chunk: new Uint8Array(ONE_MEGABYTE), numberOfChunks: 1 }));
    const plaintextMessage = await openpgp.createMessage({ binary: inputStream });
    assert(plaintextMessage.fromStream);

    const armoredEncryptedMessage = await openpgp.encrypt({ message: plaintextMessage, passwords, config });
    const encryptedMessage = await openpgp.readMessage({ armoredMessage: armoredEncryptedMessage });
    assert.ok(encryptedMessage.packets[1] instanceof openpgp.SymEncryptedIntegrityProtectedDataPacket);
    assert.ok(encryptedMessage.packets[1].version === 1);
    const { data: decryptedData } = await openpgp.decrypt({ message: encryptedMessage, passwords, config });
    // read out output stream to trigger decryption
    const sink = require('stream').Writable.toWeb(require('fs').createWriteStream('/dev/null'));
    await decryptedData.pipeTo(sink);
  });

  suite.add('openpgp.encrypt/decrypt (CFB, text, with streaming)', async () => {
    const ONE_MEGABYTE = 1000000;
    function* largeDataGenerator({ chunk, numberOfChunks }) {
      for (let chunkNumber = 0; chunkNumber < numberOfChunks; chunkNumber++) {
        yield chunk;
      }
    }

    const passwords = 'password';
    const config = { aeadProtect: false, preferredCompressionAlgorithm: openpgp.enums.compression.uncompressed };
    const inputStream = require('stream/web').ReadableStream.from(largeDataGenerator({ chunk: 'a'.repeat(ONE_MEGABYTE / 2), numberOfChunks: 1 }));
    const plaintextMessage = await openpgp.createMessage({ text: inputStream });
    assert(plaintextMessage.fromStream);

    const armoredEncryptedMessage = await openpgp.encrypt({ message: plaintextMessage, passwords, config });
    const encryptedMessage = await openpgp.readMessage({ armoredMessage: armoredEncryptedMessage });
    assert.ok(encryptedMessage.packets[1] instanceof openpgp.SymEncryptedIntegrityProtectedDataPacket);
    assert.ok(encryptedMessage.packets[1].version === 1);
    const { data: decryptedData } = await openpgp.decrypt({ message: encryptedMessage, passwords, config });
    // read out output stream to trigger decryption
    const sink = require('stream').Writable.toWeb(require('fs').createWriteStream('/dev/null'));
    await decryptedData.pipeTo(sink);
  });

  suite.add('openpgp.encrypt/decrypt (AEAD, binary, with streaming)', async () => {
    const ONE_MEGABYTE = 1000000;
    function* largeDataGenerator({ chunk, numberOfChunks }) {
      for (let chunkNumber = 0; chunkNumber < numberOfChunks; chunkNumber++) {
        yield chunk;
      }
    }

    const passwords = 'password';
    const config = { aeadProtect: true, preferredCompressionAlgorithm: openpgp.enums.compression.uncompressed };
    const inputStream = require('stream/web').ReadableStream.from(largeDataGenerator({ chunk: new Uint8Array(ONE_MEGABYTE), numberOfChunks: 1 }));
    const plaintextMessage = await openpgp.createMessage({ binary:inputStream });
    assert(plaintextMessage.fromStream);

    const armoredEncryptedMessage = await openpgp.encrypt({ message: plaintextMessage, passwords, config });
    const encryptedMessage = await openpgp.readMessage({ armoredMessage: armoredEncryptedMessage });
    assert.ok(encryptedMessage.packets[1] instanceof openpgp.SymEncryptedIntegrityProtectedDataPacket);
    assert.ok(encryptedMessage.packets[1].version === 2);
    const { data: decryptedData } = await openpgp.decrypt({ message: encryptedMessage, passwords, config });
    // read out output stream to trigger decryption
    const sink = require('stream').Writable.toWeb(require('fs').createWriteStream('/dev/null'));
    await decryptedData.pipeTo(sink);
  });

  suite.add('openpgp.encrypt/decrypt (AEAD, text, with streaming)', async () => {
    const ONE_MEGABYTE = 1000000;
    function* largeDataGenerator({ chunk, numberOfChunks }) {
      for (let chunkNumber = 0; chunkNumber < numberOfChunks; chunkNumber++) {
        yield chunk;
      }
    }

    const passwords = 'password';
    const config = { aeadProtect: true, preferredCompressionAlgorithm: openpgp.enums.compression.uncompressed };
    const inputStream = require('stream/web').ReadableStream.from(largeDataGenerator({ chunk: 'a'.repeat(ONE_MEGABYTE / 2), numberOfChunks: 1 }));
    const plaintextMessage = await openpgp.createMessage({ text: inputStream });
    assert(plaintextMessage.fromStream);

    const armoredEncryptedMessage = await openpgp.encrypt({ message: plaintextMessage, passwords, config });
    const encryptedMessage = await openpgp.readMessage({ armoredMessage: armoredEncryptedMessage });
    assert.ok(encryptedMessage.packets[1] instanceof openpgp.SymEncryptedIntegrityProtectedDataPacket);
    assert.ok(encryptedMessage.packets[1].version === 2);
    const { data: decryptedData } = await openpgp.decrypt({ message: encryptedMessage, passwords, config });
    // read out output stream to trigger decryption
    const sink = require('stream').Writable.toWeb(require('fs').createWriteStream('/dev/null'));
    await decryptedData.pipeTo(sink);
  });

  suite.add('openpgp.encrypt/decrypt (CFB, text @ 10MB, with streaming)', async () => {
    const ONE_MEGABYTE = 1000000;
    function* largeDataGenerator({ chunk, numberOfChunks }) {
      for (let chunkNumber = 0; chunkNumber < numberOfChunks; chunkNumber++) {
        yield chunk;
      }
    }

    const passwords = 'password';
    const config = { aeadProtect: false, preferredCompressionAlgorithm: openpgp.enums.compression.uncompressed };
    const inputStream = require('stream/web').ReadableStream.from(largeDataGenerator({ chunk: 'a'.repeat(ONE_MEGABYTE / 2), numberOfChunks: 20 }));
    const plaintextMessage = await openpgp.createMessage({ text: inputStream });
    assert(plaintextMessage.fromStream);

    const armoredEncryptedMessage = await openpgp.encrypt({ message: plaintextMessage, passwords, config });
    const encryptedMessage = await openpgp.readMessage({ armoredMessage: armoredEncryptedMessage });
    assert.ok(encryptedMessage.packets[1] instanceof openpgp.SymEncryptedIntegrityProtectedDataPacket);
    assert.ok(encryptedMessage.packets[1].version === 1);
    const { data: decryptedData } = await openpgp.decrypt({ message: encryptedMessage, passwords, config });
    // read out output stream to trigger decryption
    const sink = require('stream').Writable.toWeb(require('fs').createWriteStream('/dev/null'));
    await decryptedData.pipeTo(sink);
  });

  suite.add('openpgp.encrypt/decrypt (CFB, text @ 10MB, with unauthenticated streaming)', async () => {
    const ONE_MEGABYTE = 1000000;
    function* largeDataGenerator({ chunk, numberOfChunks }) {
      for (let chunkNumber = 0; chunkNumber < numberOfChunks; chunkNumber++) {
        yield chunk;
      }
    }

    const passwords = 'password';
    const config = { aeadProtect: false, preferredCompressionAlgorithm: openpgp.enums.compression.uncompressed };
    const inputStream = require('stream/web').ReadableStream.from(largeDataGenerator({ chunk: 'a'.repeat(ONE_MEGABYTE / 2), numberOfChunks: 20 }));
    const plaintextMessage = await openpgp.createMessage({ text: inputStream });
    assert(plaintextMessage.fromStream);

    const armoredEncryptedMessage = await openpgp.encrypt({ message: plaintextMessage, passwords, config });
    const encryptedMessage = await openpgp.readMessage({ armoredMessage: armoredEncryptedMessage });
    assert.ok(encryptedMessage.packets[1] instanceof openpgp.SymEncryptedIntegrityProtectedDataPacket);
    assert.ok(encryptedMessage.packets[1].version === 1);
    const { data: decryptedData } = await openpgp.decrypt({
      message: encryptedMessage,
      passwords,
      config: { ...config, allowUnauthenticatedStream: true }
    });
    // read out output stream to trigger decryption
    const sink = require('stream').Writable.toWeb(require('fs').createWriteStream('/dev/null'));
    await decryptedData.pipeTo(sink);
  });

  suite.add('openpgp.encrypt/decrypt (AEAD, text @ 10MB, with streaming)', async () => {
    const ONE_MEGABYTE = 1000000;
    function* largeDataGenerator({ chunk, numberOfChunks }) {
      for (let chunkNumber = 0; chunkNumber < numberOfChunks; chunkNumber++) {
        yield chunk;
      }
    }

    const passwords = 'password';
    const config = { aeadProtect: true, preferredCompressionAlgorithm: openpgp.enums.compression.uncompressed };
    const inputStream = require('stream/web').ReadableStream.from(largeDataGenerator({ chunk: 'a'.repeat(ONE_MEGABYTE / 2), numberOfChunks: 20 }));
    const plaintextMessage = await openpgp.createMessage({ text: inputStream });
    assert(plaintextMessage.fromStream);

    const armoredEncryptedMessage = await openpgp.encrypt({ message: plaintextMessage, passwords, config });
    const encryptedMessage = await openpgp.readMessage({ armoredMessage: armoredEncryptedMessage });
    assert.ok(encryptedMessage.packets[1] instanceof openpgp.SymEncryptedIntegrityProtectedDataPacket);
    assert.ok(encryptedMessage.packets[1].version === 2);
    const { data: decryptedData } = await openpgp.decrypt({ message: encryptedMessage, passwords, config });
    // read out output stream to trigger decryption
    const sink = require('stream').Writable.toWeb(require('fs').createWriteStream('/dev/null'));
    await decryptedData.pipeTo(sink);
  });

  const stats = await suite.run();
  // Print JSON stats to stdout
  console.log(JSON.stringify(stats, null, 4));
})();
