'use strict';

const fs = require('fs');
const vm = require('vm');
const v8 = require('v8');
const path = require('path');
const Module = require('module');
const fork = require('child_process').fork;

v8.setFlagsFromString('--no-lazy');

if (Number.parseInt(process.versions.node, 10) >= 12) {
  v8.setFlagsFromString('--no-flush-bytecode'); // Thanks to A-Parser (@a-parser)
}

const COMPILED_EXTNAME = '.jsc';

/**
 * Generates v8 bytecode buffer.
 * @param   {string} javascriptCode JavaScript source that will be compiled to bytecode.
 * @returns {Buffer} The generated bytecode.
 */
const compileCode = function (javascriptCode) {
  if (typeof javascriptCode !== 'string') {
    throw new Error(`javascriptCode must be string. ${typeof javascriptCode} was given.`);
  }

  const script = new vm.Script(javascriptCode, {
    produceCachedData: true
  });

  const bytecodeBuffer = (script.createCachedData && script.createCachedData.call)
    ? script.createCachedData()
    : script.cachedData;

  return bytecodeBuffer;
};

/**
 * This function runs the compileCode() function (above)
 * via a child process usine Electron as Node
 * @param {string} javascriptCode
 * @returns {Promise<Buffer>} - returns a Promise which resolves in the generated bytecode.
 */
const compileElectronCode = function (javascriptCode) {
  return new Promise((resolve, reject) => {
    let data = Buffer.from([]);

    const electronPath = path.join(path.dirname(require.resolve('electron')), 'cli.js');
    if (!fs.existsSync(electronPath)) {
      throw new Error('Electron not installed');
    }
    const bytenodePath = path.join(__dirname, 'cli.js');

    // create a subprocess in which we run Electron as our Node and V8 engine
    // running Bytenode to compile our code through stdin/stdout
    const proc = fork(electronPath, [bytenodePath, '--compile', '--no-module', '-'], {
      env: { ELECTRON_RUN_AS_NODE: '1' },
      stdio: ['pipe', 'pipe', 'pipe', 'ipc']
    });

    if (proc.stdin) {
      proc.stdin.write(javascriptCode);
      proc.stdin.end();
    }

    if (proc.stdout) {
      proc.stdout.on('data', (chunk) => {
        data = Buffer.concat([data, chunk]);
      });
      proc.stdout.on('error', (err) => {
        console.error(err);
      });
      proc.stdout.on('end', () => {
        resolve(data);
      });
    }

    if (proc.stderr) {
      proc.stderr.on('data', (chunk) => {
        console.error('Error: ', chunk.toString());
      });
      proc.stderr.on('error', (err) => {
        console.error('Error: ', err);
      });
    }

    proc.addListener('message', (message) => console.log(message));
    proc.addListener('error', err => console.error(err));

    proc.on('error', (err) => reject(err));
    proc.on('exit', () => { resolve(data); });
  });
};

// TODO: rewrite this function
const fixBytecode = function (bytecodeBuffer) {
  if (!Buffer.isBuffer(bytecodeBuffer)) {
    throw new Error('bytecodeBuffer must be a buffer object.');
  }

  const dummyBytecode = compileCode('"ಠ_ಠ"');

  if (process.version.startsWith('v8.8') || process.version.startsWith('v8.9')) {
    // Node is v8.8.x or v8.9.x
    dummyBytecode.slice(16, 20).copy(bytecodeBuffer, 16);
    dummyBytecode.slice(20, 24).copy(bytecodeBuffer, 20);
  } else if (process.version.startsWith('v12') ||
    process.version.startsWith('v13') ||
    process.version.startsWith('v14') ||
    process.version.startsWith('v15') ||
    process.version.startsWith('v16') ||
    process.version.startsWith('v17') ||
    process.version.startsWith('v18') ||
    process.version.startsWith('v19')) {
    dummyBytecode.slice(12, 16).copy(bytecodeBuffer, 12);
  } else {
    dummyBytecode.slice(12, 16).copy(bytecodeBuffer, 12);
    dummyBytecode.slice(16, 20).copy(bytecodeBuffer, 16);
  }
};

// TODO: rewrite this function
const readSourceHash = function (bytecodeBuffer) {
  if (!Buffer.isBuffer(bytecodeBuffer)) {
    throw new Error('bytecodeBuffer must be a buffer object.');
  }

  if (process.version.startsWith('v8.8') || process.version.startsWith('v8.9')) {
    // Node is v8.8.x or v8.9.x
    // eslint-disable-next-line no-return-assign
    return bytecodeBuffer.slice(12, 16).reduce((sum, number, power) => sum += number * Math.pow(256, power), 0);
  } else {
    // eslint-disable-next-line no-return-assign
    return bytecodeBuffer.slice(8, 12).reduce((sum, number, power) => sum += number * Math.pow(256, power), 0);
  }
};

/**
 * Runs v8 bytecode buffer and returns the result.
 * @param   {Buffer} bytecodeBuffer The buffer object that was created using compileCode function.
 * @returns {any}    The result of the very last statement executed in the script.
 */
const runBytecode = function (bytecodeBuffer) {
  if (!Buffer.isBuffer(bytecodeBuffer)) {
    throw new Error('bytecodeBuffer must be a buffer object.');
  }

  fixBytecode(bytecodeBuffer);

  const length = readSourceHash(bytecodeBuffer);

  let dummyCode = '';

  if (length > 1) {
    dummyCode = '"' + '\u200b'.repeat(length - 2) + '"'; // "\u200b" Zero width space
  }

  const script = new vm.Script(dummyCode, {
    cachedData: bytecodeBuffer
  });

  if (script.cachedDataRejected) {
    throw new Error('Invalid or incompatible cached data (cachedDataRejected)');
  }

  return script.runInThisContext();
};

/**
 * Compiles JavaScript file to .jsc file.
 * @param   {object|string} args
 * @param   {string}          args.filename The JavaScript source file that will be compiled
 * @param   {boolean}         [args.compileAsModule=true] If true, the output will be a commonjs module
 * @param   {string}          [args.output=filename.jsc] The output filename. Defaults to the same path and name of the original file, but with `.jsc` extension.
 * @param   {boolean}         [args.electron=false] If true, compile code for Electron (which needs to be installed)
 * @param   {boolean}         [args.createLoader=false] If true, create a loader file.
 * @param   {boolean}         [args.loaderFilename='%.loader.js'] Filename or pattern for generated loader files. Defaults to originalFilename.loader.js. Use % as a substitute for originalFilename.
 * @param   {string}        [output] The output filename. (Deprecated: use args.output instead)
 * @returns {Promise<string>}        A Promise which returns the compiled filename
 */
const compileFile = async function (args, output) {
  let filename, compileAsModule, electron, createLoader, loaderFilename;

  if (typeof args === 'string') {
    filename = args;
    compileAsModule = true;
    electron = false;
    createLoader = false;
  } else if (typeof args === 'object') {
    filename = args.filename;
    compileAsModule = args.compileAsModule !== false;
    electron = args.electron;
    createLoader = args.createLoader;
    loaderFilename = args.loaderFilename;
    if (loaderFilename) createLoader = true;
  }

  if (typeof filename !== 'string') {
    throw new Error(`filename must be a string. ${typeof filename} was given.`);
  }

  // @ts-ignore
  const compiledFilename = args.output || output || filename.slice(0, -3) + COMPILED_EXTNAME;

  if (typeof compiledFilename !== 'string') {
    throw new Error(`output must be a string. ${typeof compiledFilename} was given.`);
  }

  const javascriptCode = fs.readFileSync(filename, 'utf-8');

  let code;

  if (compileAsModule) {
    code = Module.wrap(javascriptCode.replace(/^#!.*/, ''));
  } else {
    code = javascriptCode.replace(/^#!.*/, '');
  }

  let bytecodeBuffer;

  if (electron) {
    bytecodeBuffer = await compileElectronCode(code);
  } else {
    bytecodeBuffer = compileCode(code);
  }

  fs.writeFileSync(compiledFilename, bytecodeBuffer);

  if (createLoader) {
    addLoaderFile(compiledFilename, loaderFilename);
  }

  return compiledFilename;
};

/**
 * Runs .jsc file and returns the result.
 * @param   {string} filename
 * @returns {any}    The result of the very last statement executed in the script.
 */
const runBytecodeFile = function (filename) {
  if (typeof filename !== 'string') {
    throw new Error(`filename must be a string. ${typeof filename} was given.`);
  }

  const bytecodeBuffer = fs.readFileSync(filename);

  return runBytecode(bytecodeBuffer);
};

Module._extensions[COMPILED_EXTNAME] = function (fileModule, filename) {
  const bytecodeBuffer = fs.readFileSync(filename);

  fixBytecode(bytecodeBuffer);

  const length = readSourceHash(bytecodeBuffer);

  let dummyCode = '';

  if (length > 1) {
    dummyCode = '"' + '\u200b'.repeat(length - 2) + '"'; // "\u200b" Zero width space
  }

  const script = new vm.Script(dummyCode, {
    filename: filename,
    lineOffset: 0,
    displayErrors: true,
    cachedData: bytecodeBuffer
  });

  if (script.cachedDataRejected) {
    throw new Error('Invalid or incompatible cached data (cachedDataRejected)');
  }

  /*
  This part is based on:
  https://github.com/zertosh/v8-compile-cache/blob/7182bd0e30ab6f6421365cee0a0c4a8679e9eb7c/v8-compile-cache.js#L158-L178
  */

  function require (id) {
    return fileModule.require(id);
  }
  require.resolve = function (request, options) {
    // @ts-ignore
    return Module._resolveFilename(request, fileModule, false, options);
  };
  if (process.mainModule) {
    require.main = process.mainModule;
  }

  // @ts-ignore
  require.extensions = Module._extensions;
  // @ts-ignore
  require.cache = Module._cache;

  const compiledWrapper = script.runInThisContext({
    filename: filename,
    lineOffset: 0,
    columnOffset: 0,
    displayErrors: true
  });

  const dirname = path.dirname(filename);

  const args = [fileModule.exports, require, fileModule, filename, dirname, process, global];

  return compiledWrapper.apply(fileModule.exports, args);
};

/**
 * Add a loader file for a given .jsc file
 * @param {String} fileToLoad path of the .jsc file we're loading
 * @param {String} loaderFilename - optional pattern or name of the file to write - defaults to filename.loader.js. Patterns: "%" represents the root name of .jsc file.
 */
const addLoaderFile = function (fileToLoad, loaderFilename) {
  let loaderFilePath;
  if (typeof loaderFilename === 'boolean' || loaderFilename === undefined || loaderFilename === '') {
    loaderFilePath = fileToLoad.replace('.jsc', '.loader.js');
  } else {
    loaderFilename = loaderFilename.replace('%', path.parse(fileToLoad).name);
    loaderFilePath = path.join(path.dirname(fileToLoad), loaderFilename);
  }
  const relativePath = path.relative(path.dirname(loaderFilePath), fileToLoad);
  const code = loaderCode('./' + relativePath);
  fs.writeFileSync(loaderFilePath, code);
};

const loaderCode = function (targetPath) {
  return `
    require('bytenode');
    require('${targetPath}');
  `;
};

global.bytenode = {
  compileCode,
  compileFile,
  compileElectronCode,
  runBytecode,
  runBytecodeFile,
  addLoaderFile,
  loaderCode
};

module.exports = global.bytenode;
