// Copyright 2020 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview Mutation Db.
 */

const crypto = require('crypto');
const fs = require('fs');
const fsPath = require('path');

const babelGenerator = require('@babel/generator').default;
const babelTraverse = require('@babel/traverse').default;
const babelTypes = require('@babel/types');
const globals = require('globals');

const random = require('./random.js');

const globalIdentifiers = new Set(Object.keys(globals.builtin));
const propertyNames = new Set([
    // Parsed from https://github.com/tc39/ecma262/blob/master/spec.html
    'add',
    'anchor',
    'apply',
    'big',
    'bind',
    'blink',
    'bold',
    'buffer',
    'byteLength',
    'byteOffset',
    'BYTES_PER_ELEMENT',
    'call',
    'catch',
    'charAt',
    'charCodeAt',
    'clear',
    'codePointAt',
    'compile',
    'concat',
    'constructor',
    'copyWithin',
    '__defineGetter__',
    '__defineSetter__',
    'delete',
    'endsWith',
    'entries',
    'every',
    'exec',
    'fill',
    'filter',
    'find',
    'findIndex',
    'fixed',
    'flags',
    'fontcolor',
    'fontsize',
    'forEach',
    'get',
    'getDate',
    'getDay',
    'getFloat32',
    'getFloat64',
    'getFullYear',
    'getHours',
    'getInt16',
    'getInt32',
    'getInt8',
    'getMilliseconds',
    'getMinutes',
    'getMonth',
    'getSeconds',
    'getTime',
    'getTimezoneOffset',
    'getUint16',
    'getUint32',
    'getUint8',
    'getUTCDate',
    'getUTCDay',
    'getUTCFullYear',
    'getUTCHours',
    'getUTCMilliseconds',
    'getUTCMinutes',
    'getUTCMonth',
    'getUTCSeconds',
    'getYear',
    'global',
    'has',
    'hasInstance',
    'hasOwnProperty',
    'ignoreCase',
    'includes',
    'indexOf',
    'isConcatSpreadable',
    'isPrototypeOf',
    'italics',
    'iterator',
    'join',
    'keys',
    'lastIndexOf',
    'length',
    'link',
    'localeCompare',
    '__lookupGetter__',
    '__lookupSetter__',
    'map',
    'match',
    'match',
    'message',
    'multiline',
    'name',
    'next',
    'normalize',
    'padEnd',
    'padStart',
    'pop',
    'propertyIsEnumerable',
    '__proto__',
    'prototype',
    'push',
    'reduce',
    'reduceRight',
    'repeat',
    'replace',
    'replace',
    'return',
    'reverse',
    'search',
    'search',
    'set',
    'set',
    'setDate',
    'setFloat32',
    'setFloat64',
    'setFullYear',
    'setHours',
    'setInt16',
    'setInt32',
    'setInt8',
    'setMilliseconds',
    'setMinutes',
    'setMonth',
    'setSeconds',
    'setTime',
    'setUint16',
    'setUint32',
    'setUint8',
    'setUTCDate',
    'setUTCFullYear',
    'setUTCHours',
    'setUTCMilliseconds',
    'setUTCMinutes',
    'setUTCMonth',
    'setUTCSeconds',
    'setYear',
    'shift',
    'size',
    'slice',
    'slice',
    'small',
    'some',
    'sort',
    'source',
    'species',
    'splice',
    'split',
    'split',
    'startsWith',
    'sticky',
    'strike',
    'sub',
    'subarray',
    'substr',
    'substring',
    'sup',
    'test',
    'then',
    'throw',
    'toDateString',
    'toExponential',
    'toFixed',
    'toGMTString',
    'toISOString',
    'toJSON',
    'toLocaleDateString',
    'toLocaleLowerCase',
    'toLocaleString',
    'toLocaleTimeString',
    'toLocaleUpperCase',
    'toLowerCase',
    'toPrecision',
    'toPrimitive',
    'toString',
    'toStringTag',
    'toTimeString',
    'toUpperCase',
    'toUTCString',
    'trim',
    'unicode',
    'unscopables',
    'unshift',
    'valueOf',
    'values',
]);

const MAX_DEPENDENCIES = 2;

class Expression {
  constructor(type, source, isStatement, originalPath,
              dependencies, needsSuper) {
    this.type = type;
    this.source = source;
    this.isStatement = isStatement;
    this.originalPath = originalPath;
    this.dependencies = dependencies;
    this.needsSuper = needsSuper;
  }
}

function dedupKey(expression) {
  if (!expression.dependencies) {
    return expression.source;
  }

  let result = expression.source;
  for (let dependency of expression.dependencies) {
    result = result.replace(new RegExp(dependency, 'g'), 'ID');
  }

  return result;
}

function _markSkipped(path) {
  while (path) {
    path.node.__skipped = true;
    path = path.parentPath;
  }
}

class MutateDbWriter {
  constructor(outputDir) {
    this.seen = new Set();
    this.outputDir = fsPath.resolve(outputDir);
    this.index = {
      statements: [],
      superStatements: [],
      all: [],
    };
  }

  process(source) {
    let self = this;

    let varIndex = 0;

    // First pass to collect dependency information.
    babelTraverse(source.ast, {
      Super(path) {
        while (path) {
          path.node.__needsSuper = true;
          path = path.parentPath;
        }
      },

      YieldExpression(path) {
        // Don't include yield expressions in DB.
        _markSkipped(path);
      },

      Identifier(path) {
        if (globalIdentifiers.has(path.node.name) &&
            path.node.name != 'eval') {
          // Global name.
          return;
        }

        if (propertyNames.has(path.node.name) &&
            path.parentPath.isMemberExpression() &&
            path.parentKey !== 'object') {
          // Builtin property name.
          return;
        }

        let binding = path.scope.getBinding(path.node.name);
        if (!binding) {
          // Unknown dependency. Don't handle this.
          _markSkipped(path);
          return;
        }

        let newName;
        if (path.node.name.startsWith('VAR_')) {
          newName = path.node.name;
        } else if (babelTypes.isFunctionDeclaration(binding.path.node) ||
                   babelTypes.isFunctionExpression(binding.path.node) ||
                   babelTypes.isDeclaration(binding.path.node) ||
                   babelTypes.isFunctionExpression(binding.path.node)) {
          // Unknown dependency. Don't handle this.
          _markSkipped(path);
          return;
        } else {
          newName = 'VAR_' + varIndex++;
          path.scope.rename(path.node.name, newName);
        }

        // Mark all parents as having a dependency.
        while (path) {
          path.node.__idDependencies = path.node.__idDependencies || [];
          if (path.node.__idDependencies.length <= MAX_DEPENDENCIES) {
            path.node.__idDependencies.push(newName);
          }
          path = path.parentPath;
        }
      }
    });

    babelTraverse(source.ast, {
      Expression(path) {
        if (!path.parentPath.isExpressionStatement()) {
          return;
        }

        if (path.node.__skipped ||
            (path.node.__idDependencies &&
             path.node.__idDependencies.length > MAX_DEPENDENCIES)) {
          return;
        }

        if (path.isIdentifier() || path.isMemberExpression() ||
            path.isConditionalExpression() ||
            path.isBinaryExpression() || path.isDoExpression() ||
            path.isLiteral() ||
            path.isObjectExpression() || path.isArrayExpression()) {
          // Skip:
          //   - Identifiers.
          //   - Member expressions (too many and too context dependent).
          //   - Conditional expressions (too many and too context dependent).
          //   - Binary expressions (too many).
          //   - Literals (too many).
          //   - Object/array expressions (too many).
          return;
        }

        if (path.isAssignmentExpression()) {
          if (!babelTypes.isMemberExpression(path.node.left)) {
            // Skip assignments that aren't to properties.
            return;
          }

          if (babelTypes.isIdentifier(path.node.left.object)) {
            if (babelTypes.isNumericLiteral(path.node.left.property)) {
              // Skip VAR[\d+] = ...;
              // There are too many and they generally aren't very useful.
              return;
            }

            if (babelTypes.isStringLiteral(path.node.left.property) &&
                !propertyNames.has(path.node.left.property.value)) {
              // Skip custom properties. e.g.
              // VAR["abc"] = ...;
              // There are too many and they generally aren't very useful.
              return;
            }
          }
        }

        if (path.isCallExpression() &&
            babelTypes.isIdentifier(path.node.callee) &&
            !globalIdentifiers.has(path.node.callee.name)) {
          // Skip VAR(...) calls since there's too much context we're missing.
          return;
        }

        if (path.isUnaryExpression() && path.node.operator == '-') {
          // Skip -... since there are too many.
          return;
        }

        // Make the template.
        let generated = babelGenerator(path.node, { concise: true }).code;
        let expression = new Expression(
            path.node.type,
            generated,
            path.parentPath.isExpressionStatement(),
            source.relPath,
            path.node.__idDependencies,
            Boolean(path.node.__needsSuper));

        // Try to de-dupe similar expressions.
        let key = dedupKey(expression);
        if (self.seen.has(key)) {
          return;
        }

        // Write results.
        let dirPath = fsPath.join(self.outputDir, expression.type);
        if (!fs.existsSync(dirPath)) {
          fs.mkdirSync(dirPath);
        }

        let sha1sum = crypto.createHash('sha1');
        sha1sum.update(key);

        let filePath = fsPath.join(dirPath, sha1sum.digest('hex') + '.json');
        fs.writeFileSync(filePath, JSON.stringify(expression));

        let relPath = fsPath.relative(self.outputDir, filePath);

        // Update index.
        self.seen.add(key);
        self.index.all.push(relPath);

        if (expression.needsSuper) {
          self.index.superStatements.push(relPath);
        } else {
          self.index.statements.push(relPath);
        }
      }
    });
  }

  writeIndex() {
    fs.writeFileSync(
        fsPath.join(this.outputDir, 'index.json'),
        JSON.stringify(this.index));
  }
}

class MutateDb {
  constructor(outputDir) {
    this.outputDir = fsPath.resolve(outputDir);
    this.index = JSON.parse(
        fs.readFileSync(fsPath.join(outputDir, 'index.json'), 'utf-8'));
  }

  getRandomStatement({canHaveSuper=false} = {}) {
    let choices;
    if (canHaveSuper) {
      choices = random.randInt(0, 1) ?
          this.index.all : this.index.superStatements;
    } else {
      choices = this.index.statements;
    }

    let path = fsPath.join(
        this.outputDir, choices[random.randInt(0, choices.length - 1)]);
    return JSON.parse(fs.readFileSync(path), 'utf-8');
  }
}

module.exports = {
  MutateDb: MutateDb,
  MutateDbWriter: MutateDbWriter,
}
