/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @emails react-core
 */

'use strict';

function isEmptyLiteral(node) {
  return (
    node.type === 'Literal' &&
    typeof node.value === 'string' &&
    node.value === ''
  );
}

// Symbols and Temporal.* objects will throw when using `'' + value`, but that
// pattern can be faster than `String(value)` because JS engines can optimize
// `+` better in some cases. Therefore, in perf-sensitive production codepaths
// we require using `'' + value` for string coercion. The only exception is prod
// error handling code, because it's bad to crash while assembling an error
// message or call stack! Also, error-handling code isn't usually perf-critical.
//
// Non-production codepaths (tests, devtools extension, build tools, etc.)
// should use `String(value)` because it will never crash and the (small) perf
// difference doesn't matter enough for non-prod use cases.
//
// This rule assists enforcing these guidelines:
// * `'' + value` is flagged with a message to remind developers to add a DEV
//   check from shared/CheckStringCoercion.js to make sure that the user gets a
//   clear error message in DEV is the coercion will throw. These checks are not
//   needed if throwing is not possible, e.g. if the value is already known to
//   be a string or number.
// * `String(value)` is flagged only if the `isProductionUserAppCode` option
//   is set. Set this option for prod code files, and don't set it for non-prod
//   files.

const ignoreKeys = [
  'range',
  'raw',
  'parent',
  'loc',
  'start',
  'end',
  '_babelType',
  'leadingComments',
  'trailingComments',
];
function astReplacer(key, value) {
  return ignoreKeys.includes(key) ? undefined : value;
}

/**
 * Simplistic comparison between AST node. Only the following patterns are
 * supported because that's almost all (all?) usage in React:
 * - Identifiers, e.g. `foo`
 * - Member access, e.g. `foo.bar`
 * - Array access with numeric literal, e.g. `foo[0]`
 */
function isEquivalentCode(node1, node2) {
  return (
    JSON.stringify(node1, astReplacer) === JSON.stringify(node2, astReplacer)
  );
}

function isDescendant(node, maybeParentNode) {
  let parent = node.parent;
  while (parent) {
    if (!parent) {
      return false;
    }
    if (parent === maybeParentNode) {
      return true;
    }
    parent = parent.parent;
  }
  return false;
}

function isSafeTypeofExpression(originalValueNode, node) {
  if (node.type === 'BinaryExpression') {
    // Example: typeof foo === 'string'
    if (node.operator !== '===') {
      return false;
    }
    const {left, right} = node;

    // left must be `typeof original`
    if (left.type !== 'UnaryExpression' || left.operator !== 'typeof') {
      return false;
    }
    if (!isEquivalentCode(left.argument, originalValueNode)) {
      return false;
    }
    // right must be a literal value of a safe type
    const safeTypes = ['string', 'number', 'boolean', 'undefined', 'bigint'];
    if (right.type !== 'Literal' || !safeTypes.includes(right.value)) {
      return false;
    }
    return true;
  } else if (node.type === 'LogicalExpression') {
    // Examples:
    // * typeof foo === 'string' && typeof foo === 'number
    // * typeof foo === 'string' && someOtherTest
    if (node.operator === '&&') {
      return (
        isSafeTypeofExpression(originalValueNode, node.left) ||
        isSafeTypeofExpression(originalValueNode, node.right)
      );
    } else if (node.operator === '||') {
      return (
        isSafeTypeofExpression(originalValueNode, node.left) &&
        isSafeTypeofExpression(originalValueNode, node.right)
      );
    }
  }
  return false;
}

/** 
  Returns true if the code is inside an `if` block that validates the value
  excludes symbols and objects. Examples: 
  * if (typeof value === 'string') { }
  * if (typeof value === 'string' || typeof value === 'number') { }
  * if (typeof value === 'string' || someOtherTest) { }

  @param - originalValueNode Top-level expression to test. Kept unchanged during
  recursion.
  @param - node Expression to test at current recursion level. Will be undefined
  on non-recursive call.
*/
function isInSafeTypeofBlock(originalValueNode, node) {
  if (!node) {
    node = originalValueNode;
  }
  let parent = node.parent;
  while (parent) {
    if (!parent) {
      return false;
    }
    // Normally, if the parent block is inside a type-safe `if` statement,
    // then all child code is also type-safe. But there's a quirky case we
    // need to defend against:
    //   if (typeof obj === 'string') { } else if (typeof obj === 'object') {'' + obj}
    //   if (typeof obj === 'string') { } else {'' + obj}
    // In that code above, the `if` block is safe, but the `else` block is
    // unsafe and should report. But the AST parent of the `else` clause is the
    // `if` statement. This is the one case where the parent doesn't confer
    // safety onto the child. The code below identifies that case and keeps
    // moving up the tree until we get out of the `else`'s parent `if` block.
    // This ensures that we don't use any of these "parents" (really siblings)
    // to confer safety onto the current node.
    if (
      parent.type === 'IfStatement' &&
      !isDescendant(originalValueNode, parent.alternate)
    ) {
      const test = parent.test;
      if (isSafeTypeofExpression(originalValueNode, test)) {
        return true;
      }
    }
    parent = parent.parent;
  }
}

const missingDevCheckMessage =
  'Missing DEV check before this string coercion.' +
  ' Check should be in this format:\n' +
  '  if (__DEV__) {\n' +
  '    checkXxxxxStringCoercion(value);\n' +
  '  }';

const prevStatementNotDevCheckMessage =
  'The statement before this coercion must be a DEV check in this format:\n' +
  '  if (__DEV__) {\n' +
  '    checkXxxxxStringCoercion(value);\n' +
  '  }';

/**
 * Does this node have an "is coercion safe?" DEV check
 * in the same block?
 */
function hasCoercionCheck(node) {
  // find the containing statement
  let topOfExpression = node;
  while (!topOfExpression.parent.body) {
    topOfExpression = topOfExpression.parent;
    if (!topOfExpression) {
      return 'Cannot find top of expression.';
    }
  }
  const containingBlock = topOfExpression.parent.body;
  const index = containingBlock.indexOf(topOfExpression);
  if (index <= 0) {
    return missingDevCheckMessage;
  }
  const prev = containingBlock[index - 1];

  // The previous statement is expected to be like this:
  //   if (__DEV__) {
  //     checkFormFieldValueStringCoercion(foo);
  //   }
  // where `foo` must be equivalent to `node` (which is the
  // mixed value being coerced to a string).
  if (
    prev.type !== 'IfStatement' ||
    prev.test.type !== 'Identifier' ||
    prev.test.name !== '__DEV__'
  ) {
    return prevStatementNotDevCheckMessage;
  }
  let maybeCheckNode = prev.consequent;
  if (maybeCheckNode.type === 'BlockStatement') {
    const body = maybeCheckNode.body;
    if (body.length === 0) {
      return prevStatementNotDevCheckMessage;
    }
    if (body.length !== 1) {
      return (
        'Too many statements in DEV block before this coercion.' +
        ' Expected only one (the check function call). ' +
        prevStatementNotDevCheckMessage
      );
    }
    maybeCheckNode = body[0];
  }

  if (maybeCheckNode.type !== 'ExpressionStatement') {
    return (
      'The DEV block before this coercion must only contain an expression. ' +
      prevStatementNotDevCheckMessage
    );
  }

  const call = maybeCheckNode.expression;
  if (
    call.type !== 'CallExpression' ||
    call.callee.type !== 'Identifier' ||
    !/^check(\w+?)StringCoercion$/.test(call.callee.name) ||
    !call.arguments.length
  ) {
    // `maybeCheckNode` should be a call of a function named checkXXXStringCoercion
    return (
      'Missing or invalid check function call before this coercion.' +
      ' Expected: call of a function like checkXXXStringCoercion. ' +
      prevStatementNotDevCheckMessage
    );
  }

  const same = isEquivalentCode(call.arguments[0], node);
  if (!same) {
    return (
      'Value passed to the check function before this coercion' +
      ' must match the value being coerced.'
    );
  }
}

function plusEmptyString(context, node) {
  if (
    node.operator === '+' &&
    (isEmptyLiteral(node.left) || isEmptyLiteral(node.right))
  ) {
    let valueToTest = isEmptyLiteral(node.left) ? node.right : node.left;
    if (valueToTest.type === 'TypeCastExpression' && valueToTest.expression) {
      valueToTest = valueToTest.expression;
    }

    if (
      valueToTest.type === 'Identifier' &&
      ['i', 'idx', 'lineNumber'].includes(valueToTest.name)
    ) {
      // Common non-object variable names are assumed to be safe
      return;
    }
    if (
      valueToTest.type === 'UnaryExpression' ||
      valueToTest.type === 'UpdateExpression'
    ) {
      // Any unary expression will return a non-object, non-symbol type.
      return;
    }
    if (isInSafeTypeofBlock(valueToTest)) {
      // The value is inside an if (typeof...) block that ensures it's safe
      return;
    }
    const coercionCheckMessage = hasCoercionCheck(valueToTest);
    if (!coercionCheckMessage) {
      // The previous statement is a correct check function call, so no report.
      return;
    }

    context.report({
      node,
      message:
        coercionCheckMessage +
        '\n' +
        "Using `'' + value` or `value + ''` is fast to coerce strings, but may throw." +
        ' For prod code, add a DEV check from shared/CheckStringCoercion immediately' +
        ' before this coercion.' +
        ' For non-prod code and prod error handling, use `String(value)` instead.',
    });
  }
}

function coerceWithStringConstructor(context, node) {
  const isProductionUserAppCode =
    context.options[0] && context.options[0].isProductionUserAppCode;
  if (isProductionUserAppCode && node.callee.name === 'String') {
    context.report(
      node,
      "For perf-sensitive coercion, avoid `String(value)`. Instead, use `'' + value`." +
        ' Precede it with a DEV check from shared/CheckStringCoercion' +
        ' unless Symbol and Temporal.* values are impossible.' +
        ' For non-prod code and prod error handling, use `String(value)` and disable this rule.'
    );
  }
}

module.exports = {
  meta: {
    schema: [
      {
        type: 'object',
        properties: {
          isProductionUserAppCode: {
            type: 'boolean',
            default: false,
          },
        },
        additionalProperties: false,
      },
    ],
  },
  create(context) {
    return {
      BinaryExpression: node => plusEmptyString(context, node),
      CallExpression: node => coerceWithStringConstructor(context, node),
    };
  },
};
