'use strict';

const { ok } = require('node:assert/strict');
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const Module = require('node:module');
const path = require('node:path');
const vm = require('node:vm');
const v8 = require('node:v8');
const { brotliCompressSync, brotliDecompressSync } = require('node:zlib');

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

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

function isBuffer (obj) {
  return obj != null && obj.constructor != null &&
    typeof obj.constructor.isBuffer === 'function' && obj.constructor.isBuffer(obj)
}

const COMPILED_EXTNAME = '.jsc';
const MAGIC_NUMBER = Buffer.from([0xde, 0xc0]);
const ZERO_LENGTH_EXTERNAL_REFERENCE_TABLE = Buffer.alloc(2);
const sheBangRegex = /^#!.*/;

// Set BYTENODE_DEBUG=1 to print the V8 cached-data header layout at load time.
// This is the key diagnostic for SIGTRAP crashes on newer V8 (e.g. Electron 42 / V8 14.8):
// it lets us compare, field by field, the .jsc compiled at build time against a dummy
// compiled in the *current* runtime process, so we can see exactly which header field
// (version hash / flag hash / read-only snapshot checksum) is mismatched.
const DEBUG = !!process.env.BYTENODE_DEBUG;

// Modern V8 SerializedCodeData header (uint32_t LE entries):
//   [0] magic number                       offset 0
//   [1] version hash                       offset 4
//   [2] source hash (== source length)     offset 8
//   [3] flag hash                          offset 12
//   [4] read-only snapshot checksum        offset 16
//   [5] payload length                     offset 20
//   [6] payload checksum                   offset 24
function dumpBytecodeHeader (label, buffer) {
  const names = [
    'magic        @0 ',
    'versionHash  @4 ',
    'sourceHash   @8 ',
    'flagHash     @12',
    'roChecksum   @16',
    'payloadLen   @20',
    'checksum     @24'
  ];
  const lines = [`[bytenode] ${label} (buffer length = ${buffer.length})`];
  for (let i = 0; i < names.length; i++) {
    const off = i * 4;
    if (off + 4 > buffer.length) break;
    const v = buffer.readUInt32LE(off);
    lines.push(`  ${names[i]} = 0x${v.toString(16).padStart(8, '0')} (${v})`);
  }
  console.error(lines.join('\n'));
}

function generateScript (cachedData, filename) {
  if (!isBufferV8Bytecode(cachedData)) {
    // Try to decompress as Brotli
    cachedData = brotliDecompressSync(cachedData);

    ok(isBufferV8Bytecode(cachedData), 'Invalid bytecode buffer');
  }

  if (DEBUG) {
    console.error(`[bytenode] loading ${filename || '<buffer>'}`);
    console.error(`[bytenode] runtime: node ${process.version}` +
      (process.versions.electron ? `, electron ${process.versions.electron}` : '') +
      `, v8 ${process.versions.v8}`);
    dumpBytecodeHeader('on-disk .jsc header (before fixBytecode)', cachedData);
    // A dummy compiled in THIS runtime process: its build-dependent header fields
    // (version hash / flag hash / ro snapshot checksum) are what V8 will expect here.
    try {
      dumpBytecodeHeader('runtime dummy header (what V8 expects)', compileCode('"\u0ca0_\u0ca0"'));
    } catch (err) {
      console.error('[bytenode] failed to compile runtime dummy:', err && err.message);
    }
  }

  fixBytecode(cachedData);

  if (DEBUG) {
    dumpBytecodeHeader('on-disk .jsc header (after fixBytecode)', cachedData);
  }

  const length = readSourceHash(cachedData);

  let dummyCode = '';

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

  if (DEBUG) {
    console.error(`[bytenode] sourceHash/dummy length = ${length}; about to call new vm.Script(...)`);
  }

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

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

  if (DEBUG) {
    console.error('[bytenode] vm.Script created, cachedData accepted.');
  }

  return script;
}

function isBufferV8Bytecode (buffer) {
  return (
    isBuffer(buffer) &&
    !buffer.subarray(0, 2).equals(ZERO_LENGTH_EXTERNAL_REFERENCE_TABLE) &&
    buffer.subarray(2, 4).equals(MAGIC_NUMBER)
  );

  // TODO: check that code start + payload size = buffer length. See
  //       https://github.com/bytenode/bytenode/issues/210#issuecomment-1605691369
}

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

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

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

  if (compress) bytecodeBuffer = brotliCompressSync(bytecodeBuffer);

  return bytecodeBuffer;
};

/**
 * This function runs the compileCode() function (above)
 * via a child process using Electron as Node
 * @param {string} javascriptCode
 * @param {object} [options] - optional options object
 * @param {string} [options.electronPath] - optional path to Electron executable, defaults to the installed node_modules/electron
 * @param {boolean} [options.compress]
 * @returns {Promise<Buffer>} - returns a Promise which resolves in the generated bytecode.
 */
const compileElectronCode = function (javascriptCode, options) {
  return new Promise((resolve, reject) => {
    function onEnd () {
      if (options.compress) data = brotliCompressSync(data);

      resolve(data);
    }

    options = options || {};

    let data = Buffer.from([]);

    // Resolve `electron` lazily and only when no explicit path was given. This lets
    // consumers (e.g. a linked/forked bytenode living outside the project's node_modules,
    // where `require('electron')` would not resolve) pass `electronPath` instead.
    const electronPath = options.electronPath
      ? path.normalize(options.electronPath)
      : /** @type {string} */ (require('electron'));
    if (!fs.existsSync(electronPath)) {
      throw new Error('Electron not found');
    }
    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 child = spawn(electronPath, [bytenodePath, '--compile', '--no-module', '-'], {
      env: { ELECTRON_RUN_AS_NODE: '1' },
      stdio: ['pipe', 'pipe', 'pipe', 'ipc']
    });

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

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

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

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

    child.on('error', (err) => reject(err));
    child.on('exit', onEnd);
  });
};

/**
 * Compiles code to bytecode inside an actual Electron *main* process (NOT
 * ELECTRON_RUN_AS_NODE). This is required on Electron >= 42 (V8 >= 14.8): the
 * main/browser process boots from Chromium's `v8_context_snapshot`, whose
 * read-only heap differs from the Node default snapshot used by
 * ELECTRON_RUN_AS_NODE. Bytecode carries a read-only-snapshot checksum
 * (header offset 16); if it was produced under run-as-node it will not match
 * the main process at load time, and V8 14.8 hard-aborts (SIGTRAP) while
 * deserializing instead of cleanly rejecting. Compiling here, in the same
 * process type that will later load the .jsc, makes the checksum match.
 * @param {string} javascriptCode
 * @param {object} [options]
 * @param {string} [options.electronPath]
 * @param {boolean} [options.compress]
 * @returns {Promise<Buffer>}
 */
const compileElectronMainCode = function (javascriptCode, options) {
  return new Promise((resolve, reject) => {
    options = options || {};

    const os = require('node:os');

    const electronPath = options.electronPath
      ? path.normalize(options.electronPath)
      : /** @type {string} */ (require('electron'));
    if (!fs.existsSync(electronPath)) {
      throw new Error('Electron not found');
    }

    const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bytenode-electron-main-'));
    const inFile = path.join(tmpDir, 'input.js');
    const outFile = path.join(tmpDir, 'output.jsc');
    const compilerScript = path.join(tmpDir, 'compiler.js');

    fs.writeFileSync(inFile, javascriptCode);

    // Minimal Electron main-process entry: wait for app ready, compile, write,
    // then quit. No BrowserWindow is created. The V8 flags must match those set
    // by bytenode when the .jsc is later loaded, so the flag hash agrees.
    const compilerSource = [
      "const { app } = require('electron');",
      "const fs = require('fs');",
      "const vm = require('vm');",
      "const v8 = require('v8');",
      "v8.setFlagsFromString('--no-lazy');",
      "v8.setFlagsFromString('--no-flush-bytecode');",
      // No renderer/window is created, so the Chromium sandbox is irrelevant here.
      // Disabling it avoids failures when Electron is launched as root in CI containers.
      "if (app.commandLine && app.commandLine.appendSwitch) app.commandLine.appendSwitch('no-sandbox');",
      'if (typeof app.disableHardwareAcceleration === "function") app.disableHardwareAcceleration();',
      'function run () {',
      '  try {',
      '    const code = fs.readFileSync(' + JSON.stringify(inFile) + ", 'utf-8');",
      '    const script = new vm.Script(code, { produceCachedData: true });',
      '    const buf = (script.createCachedData && script.createCachedData.call) ? script.createCachedData() : script.cachedData;',
      '    fs.writeFileSync(' + JSON.stringify(outFile) + ', buf);',
      '    process.exitCode = 0;',
      '  } catch (err) {',
      '    process.stderr.write(String((err && err.stack) || err) + "\\n");',
      '    process.exitCode = 1;',
      '  } finally {',
      '    app.quit();',
      '  }',
      '}',
      'app.whenReady().then(run);'
    ].join('\n');

    fs.writeFileSync(compilerScript, compilerSource);

    const cleanup = () => {
      try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* ignore */ }
    };

    const child = spawn(electronPath, [compilerScript], {
      // Deliberately NOT setting ELECTRON_RUN_AS_NODE: we want the browser/main process.
      env: process.env,
      stdio: ['ignore', 'inherit', 'inherit']
    });

    child.on('error', (err) => { cleanup(); reject(err); });

    child.on('exit', (code) => {
      if (code !== 0) {
        cleanup();
        reject(new Error('Electron main-process bytecode compilation failed (exit code ' + code + ')'));
        return;
      }
      try {
        let data = fs.readFileSync(outFile);
        if (options.compress) data = brotliCompressSync(data);
        cleanup();
        resolve(data);
      } catch (err) {
        cleanup();
        reject(err);
      }
    });
  });
};

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

  const dummyBytecode = compileCode('"ಠ_ಠ"');
  const version = parseFloat(process.version.slice(1, 5));

  if (process.version.startsWith('v8.8') || process.version.startsWith('v8.9')) {
    // Node is v8.8.x or v8.9.x
    if (DEBUG) console.error('[bytenode] fixBytecode branch: legacy v8.8/v8.9 (patch 16-20, 20-24)');
    dummyBytecode.subarray(16, 20).copy(bytecodeBuffer, 16);
    dummyBytecode.subarray(20, 24).copy(bytecodeBuffer, 20);
  } else if (version >= 12) {
    // Node >= 12 (modern V8 layout). Only the flag hash (offset 12) is patched.
    //
    // The previous upper bound (`version <= 23`) accidentally pushed Node 24+ into the
    // legacy branch below, which ALSO overwrites bytes 16-20 (the read-only snapshot
    // checksum) with the runtime dummy's value. On Electron 42 / V8 14.8 that masks a
    // real checksum mismatch and makes V8 hard-abort (SIGTRAP) inside vm.Script instead
    // of cleanly reporting cachedDataRejected. Patching only the flag hash is the safe,
    // version-stable behavior shared with Node 12-23.
    if (DEBUG) console.error('[bytenode] fixBytecode branch: modern node>=12 (patch flag hash 12-16 only)');
    dummyBytecode.subarray(12, 16).copy(bytecodeBuffer, 12);
  } else {
    // Node 9/10/11 (older header layout).
    if (DEBUG) console.error('[bytenode] fixBytecode branch: node 9/10/11 (patch 12-16, 16-20)');
    dummyBytecode.subarray(12, 16).copy(bytecodeBuffer, 12);
    dummyBytecode.subarray(16, 20).copy(bytecodeBuffer, 16);
  }
};

// TODO: rewrite this function
const readSourceHash = function (bytecodeBuffer) {
  if (!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.subarray(12, 16).reduce((sum, number, power) => sum += number * Math.pow(256, power), 0);
  } else {
    // eslint-disable-next-line no-return-assign
    return bytecodeBuffer.subarray(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 (!isBuffer(bytecodeBuffer)) {
    throw new Error('bytecodeBuffer must be a buffer object.');
  }

  const script = generateScript(bytecodeBuffer);

  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   {boolean}         [args.compress=false] If true, compress the output bytecode
 * @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.
 * @param   {string}          [args.electronPath] (optional) path to Electron executable. When present, the `electron` argument is ignored.
 * @param   {boolean|string}  [args.createLoader=false] If true, create a CommonJS loader file. As a string, select between 'module' or 'commonjs' loader.
 * @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, compress, electron, electronMain, createLoader, loaderFilename, electronPath;

  if (typeof args === 'string') {
    filename = args;
    compileAsModule = true;
    compress = false;
    electron = false;
    electronMain = false;
    createLoader = false;
  } else if (typeof args === 'object') {
    filename = args.filename;
    compileAsModule = args.compileAsModule !== false;
    compress = args.compress;
    electron = args.electron || !!args.electronPath;
    // Compile inside an Electron main process (required for Electron >= 42 / V8 >= 14.8).
    electronMain = args.electronMain;
    electronPath = args.electronPath;
    createLoader = args.createLoader;
    loaderFilename = args.loaderFilename;
    if (loaderFilename && !createLoader) createLoader = true;
  }

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

  if (createLoader && typeof createLoader !== 'string') {
    createLoader = 'commonjs';
  }

  // @ts-ignore
  const compiledFilename = args.output || output || filename.slice(0, -path.extname(filename).length) + 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');

  const sheBang = javascriptCode.match(sheBangRegex);
  let code = javascriptCode.replace(sheBangRegex, '');

  if (compileAsModule) {
    code = Module.wrap(code);
  }

  let bytecodeBuffer;

  if (electronMain) {
    bytecodeBuffer = await compileElectronMainCode(code, { compress, electronPath });
  } else if (electron) {
    bytecodeBuffer = await compileElectronCode(code, { compress, electronPath });
  } else {
    bytecodeBuffer = compileCode(code, compress);
  }

  fs.writeFileSync(compiledFilename, bytecodeBuffer);

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

  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);

  const script = generateScript(bytecodeBuffer, filename);

  /*
  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.main) {
    require.main = process.main;
  }

  // @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.
 * @param {string} type select between 'module' or 'commonjs' loader.
 */
const addLoaderFile = function (fileToLoad, loaderFilename, type, sheBang) {
  let loaderFilePath;
  if (typeof loaderFilename === 'boolean' || loaderFilename === undefined || loaderFilename === '') {
    loaderFilePath = fileToLoad.replace(COMPILED_EXTNAME, '.loader.js');
  } else {
    loaderFilename = loaderFilename.replace('%', path.parse(fileToLoad).name);
    loaderFilePath = path.join(path.dirname(fileToLoad), loaderFilename);
  }
  const loaderCode = type === 'module' ? loaderCodeModule : loaderCodeCommonJS;
  const relativePath = path.relative(path.dirname(loaderFilePath), fileToLoad);
  const code = loaderCode('./' + relativePath, sheBang, loaderFilePath);
  fs.writeFileSync(loaderFilePath, code);
};

const loaderCodeCommonJS = function (targetPath, sheBang) {
  const lines = [
    `require('bytenode')`,
    ``,
    `module.exports = require('${targetPath}')`
  ];

  if (sheBang) {
    lines.unshift(sheBang, '');
  }

  return lines.join('\n');
};

const loaderCodeModule = function (targetPath, sheBang, loaderFilePath) {
  const lines = [
    `import { createRequire } from 'node:module'`,
    ``,
    `import 'bytenode'`,
    ``,
    ``,
    `const require = createRequire(import.meta.url)`,
    ``
  ];

  if (sheBang) {
    lines.unshift(sheBang, '');

    lines.push(`require('${targetPath}')`);
  } else {
    // Only `require()` the module when not having a shebang, so we can be
    // somewhat sure it's not an executable, to prevent running it. Also, an
    // executable having exports is a code smell, they should be two different
    // modules, using the executable the exported one as a library
    let { default: defaultExport, ...namedExports } = require(loaderFilePath);

    defaultExport = defaultExport ? 'default: defaultExport' : '';
    namedExports = Object.keys(namedExports);

    let exports = [];
    if (defaultExport) {
      exports.push(defaultExport);
    }
    exports = exports.concat(namedExports).join(', ');

    if (!exports) {
      lines.push(`require('${targetPath}')`);
    }
    else {
      lines.push(`const {${exports}} = require('${targetPath}')`, ``, ``);

      if (defaultExport) {
        lines.push('export default defaultExport');
      }
      if (namedExports.length) {
        lines.push(`export { ${namedExports} }`);
      }
    }
  }

  return lines.join('\n');
};

global.bytenode = {
  compileCode,
  compileFile,
  compileElectronCode,
  compileElectronMainCode,
  runBytecode,
  runBytecodeFile,
  addLoaderFile,
  loaderCode: loaderCodeCommonJS,
  loaderCodeCommonJS,
  loaderCodeModule
};

module.exports = global.bytenode;
