/**
 * @fileoverview Rule to disallow use of the `RegExp` constructor in favor of regular expression literals
 * @author Milos Djermanovic
 */

'use strict';

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const astUtils = require('./utils/ast-utils');
const {
  CALL,
  CONSTRUCT,
  ReferenceTracker,
  findVariable,
} = require('@eslint-community/eslint-utils');
const {
  RegExpValidator,
  visitRegExpAST,
  RegExpParser,
} = require('@eslint-community/regexpp');
const { canTokensBeAdjacent } = require('./utils/ast-utils');
const { REGEXPP_LATEST_ECMA_VERSION } = require('./utils/regular-expressions');

//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------

/**
 * Determines whether the given node is a string literal.
 * @param {ASTNode} node Node to check.
 * @returns {boolean} True if the node is a string literal.
 */
function isStringLiteral(node) {
  return node.type === 'Literal' && typeof node.value === 'string';
}

/**
 * Determines whether the given node is a regex literal.
 * @param {ASTNode} node Node to check.
 * @returns {boolean} True if the node is a regex literal.
 */
function isRegexLiteral(node) {
  return node.type === 'Literal' && Object.hasOwn(node, 'regex');
}

const validPrecedingTokens = new Set([
  '(',
  ';',
  '[',
  ',',
  '=',
  '+',
  '*',
  '-',
  '?',
  '~',
  '%',
  '**',
  '!',
  'typeof',
  'instanceof',
  '&&',
  '||',
  '??',
  'return',
  '...',
  'delete',
  'void',
  'in',
  '<',
  '>',
  '<=',
  '>=',
  '==',
  '===',
  '!=',
  '!==',
  '<<',
  '>>',
  '>>>',
  '&',
  '|',
  '^',
  ':',
  '{',
  '=>',
  '*=',
  '<<=',
  '>>=',
  '>>>=',
  '^=',
  '|=',
  '&=',
  '??=',
  '||=',
  '&&=',
  '**=',
  '+=',
  '-=',
  '/=',
  '%=',
  '/',
  'do',
  'break',
  'continue',
  'debugger',
  'case',
  'throw',
]);

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

/** @type {import('../types').Rule.RuleModule} */
module.exports = {
  meta: {
    type: 'suggestion',

    defaultOptions: [
      {
        disallowRedundantWrapping: false,
      },
    ],

    docs: {
      description:
        'Disallow use of the `RegExp` constructor in favor of regular expression literals',
      recommended: false,
      url: 'https://eslint.org/docs/latest/rules/prefer-regex-literals',
    },

    hasSuggestions: true,

    schema: [
      {
        type: 'object',
        properties: {
          disallowRedundantWrapping: {
            type: 'boolean',
          },
        },
        additionalProperties: false,
      },
    ],

    messages: {
      unexpectedRegExp:
        "Use a regular expression literal instead of the 'RegExp' constructor.",
      replaceWithLiteral:
        'Replace with an equivalent regular expression literal.',
      replaceWithLiteralAndFlags:
        "Replace with an equivalent regular expression literal with flags '{{ flags }}'.",
      replaceWithIntendedLiteralAndFlags:
        "Replace with a regular expression literal with flags '{{ flags }}'.",
      unexpectedRedundantRegExp:
        "Regular expression literal is unnecessarily wrapped within a 'RegExp' constructor.",
      unexpectedRedundantRegExpWithFlags:
        "Use regular expression literal with flags instead of the 'RegExp' constructor.",
    },
  },

  create(context) {
    const [{ disallowRedundantWrapping }] = context.options;
    const sourceCode = context.sourceCode;

    /**
     * Determines whether the given identifier node is a reference to a global variable.
     * @param {ASTNode} node `Identifier` node to check.
     * @returns {boolean} True if the identifier is a reference to a global variable.
     */
    function isGlobalReference(node) {
      const scope = sourceCode.getScope(node);
      const variable = findVariable(scope, node);

      return (
        variable !== null &&
        variable.scope.type === 'global' &&
        variable.defs.length === 0
      );
    }

    /**
     * Determines whether the given node is a String.raw`` tagged template expression
     * with a static template literal.
     * @param {ASTNode} node Node to check.
     * @returns {boolean} True if the node is String.raw`` with a static template.
     */
    function isStringRawTaggedStaticTemplateLiteral(node) {
      return (
        node.type === 'TaggedTemplateExpression' &&
        astUtils.isSpecificMemberAccess(node.tag, 'String', 'raw') &&
        isGlobalReference(astUtils.skipChainExpression(node.tag).object) &&
        astUtils.isStaticTemplateLiteral(node.quasi)
      );
    }

    /**
     * Gets the value of a string
     * @param {ASTNode} node The node to get the string of.
     * @returns {string|null} The value of the node.
     */
    function getStringValue(node) {
      if (isStringLiteral(node)) {
        return node.value;
      }

      if (astUtils.isStaticTemplateLiteral(node)) {
        return node.quasis[0].value.cooked;
      }

      if (isStringRawTaggedStaticTemplateLiteral(node)) {
        return node.quasi.quasis[0].value.raw;
      }

      return null;
    }

    /**
     * Determines whether the given node is considered to be a static string by the logic of this rule.
     * @param {ASTNode} node Node to check.
     * @returns {boolean} True if the node is a static string.
     */
    function isStaticString(node) {
      return (
        isStringLiteral(node) ||
        astUtils.isStaticTemplateLiteral(node) ||
        isStringRawTaggedStaticTemplateLiteral(node)
      );
    }

    /**
     * Determines whether the relevant arguments of the given are all static string literals.
     * @param {ASTNode} node Node to check.
     * @returns {boolean} True if all arguments are static strings.
     */
    function hasOnlyStaticStringArguments(node) {
      const args = node.arguments;

      if (
        (args.length === 1 || args.length === 2) &&
        args.every(isStaticString)
      ) {
        return true;
      }

      return false;
    }

    /**
     * Determines whether the arguments of the given node indicate that a regex literal is unnecessarily wrapped.
     * @param {ASTNode} node Node to check.
     * @returns {boolean} True if the node already contains a regex literal argument.
     */
    function isUnnecessarilyWrappedRegexLiteral(node) {
      const args = node.arguments;

      if (args.length === 1 && isRegexLiteral(args[0])) {
        return true;
      }

      if (
        args.length === 2 &&
        isRegexLiteral(args[0]) &&
        isStaticString(args[1])
      ) {
        return true;
      }

      return false;
    }

    /**
     * Returns a ecmaVersion compatible for regexpp.
     * @param {number} ecmaVersion The ecmaVersion to convert.
     * @returns {import("@eslint-community/regexpp/ecma-versions").EcmaVersion} The resulting ecmaVersion compatible for regexpp.
     */
    function getRegexppEcmaVersion(ecmaVersion) {
      if (ecmaVersion <= 5) {
        return 5;
      }
      return Math.min(ecmaVersion, REGEXPP_LATEST_ECMA_VERSION);
    }

    const regexppEcmaVersion = getRegexppEcmaVersion(
      context.languageOptions.ecmaVersion
    );

    /**
     * Makes a character escaped or else returns null.
     * @param {string} character The character to escape.
     * @returns {string} The resulting escaped character.
     */
    function resolveEscapes(character) {
      switch (character) {
        case '\n':
        case '\\\n':
          return '\\n';

        case '\r':
        case '\\\r':
          return '\\r';

        case '\t':
        case '\\\t':
          return '\\t';

        case '\v':
        case '\\\v':
          return '\\v';

        case '\f':
        case '\\\f':
          return '\\f';

        case '/':
          return '\\/';

        default:
          return null;
      }
    }

    /**
     * Checks whether the given regex and flags are valid for the ecma version or not.
     * @param {string} pattern The regex pattern to check.
     * @param {string | undefined} flags The regex flags to check.
     * @returns {boolean} True if the given regex pattern and flags are valid for the ecma version.
     */
    function isValidRegexForEcmaVersion(pattern, flags) {
      const validator = new RegExpValidator({
        ecmaVersion: regexppEcmaVersion,
      });

      try {
        validator.validatePattern(pattern, 0, pattern.length, {
          unicode: flags ? flags.includes('u') : false,
          unicodeSets: flags ? flags.includes('v') : false,
        });
        if (flags) {
          validator.validateFlags(flags);
        }
        return true;
      } catch {
        return false;
      }
    }

    /**
     * Checks whether two given regex flags contain the same flags or not.
     * @param {string} flagsA The regex flags.
     * @param {string} flagsB The regex flags.
     * @returns {boolean} True if two regex flags contain same flags.
     */
    function areFlagsEqual(flagsA, flagsB) {
      return [...flagsA].sort().join('') === [...flagsB].sort().join('');
    }

    /**
     * Merges two regex flags.
     * @param {string} flagsA The regex flags.
     * @param {string} flagsB The regex flags.
     * @returns {string} The merged regex flags.
     */
    function mergeRegexFlags(flagsA, flagsB) {
      const flagsSet = new Set([...flagsA, ...flagsB]);

      return [...flagsSet].join('');
    }

    /**
     * Checks whether a give node can be fixed to the given regex pattern and flags.
     * @param {ASTNode} node The node to check.
     * @param {string} pattern The regex pattern to check.
     * @param {string} flags The regex flags
     * @returns {boolean} True if a node can be fixed to the given regex pattern and flags.
     */
    function canFixTo(node, pattern, flags) {
      const tokenBefore = sourceCode.getTokenBefore(node);

      return (
        sourceCode.getCommentsInside(node).length === 0 &&
        (!tokenBefore || validPrecedingTokens.has(tokenBefore.value)) &&
        isValidRegexForEcmaVersion(pattern, flags)
      );
    }

    /**
     * Returns a safe output code considering the before and after tokens.
     * @param {ASTNode} node The regex node.
     * @param {string} newRegExpValue The new regex expression value.
     * @returns {string} The output code.
     */
    function getSafeOutput(node, newRegExpValue) {
      const tokenBefore = sourceCode.getTokenBefore(node);
      const tokenAfter = sourceCode.getTokenAfter(node);

      return (
        ((
          tokenBefore &&
          !canTokensBeAdjacent(tokenBefore, newRegExpValue) &&
          tokenBefore.range[1] === node.range[0]
        ) ?
          ' '
        : '') +
        newRegExpValue +
        ((
          tokenAfter &&
          !canTokensBeAdjacent(newRegExpValue, tokenAfter) &&
          node.range[1] === tokenAfter.range[0]
        ) ?
          ' '
        : '')
      );
    }

    return {
      Program(node) {
        const scope = sourceCode.getScope(node);
        const tracker = new ReferenceTracker(scope);
        const traceMap = {
          RegExp: {
            [CALL]: true,
            [CONSTRUCT]: true,
          },
        };

        for (const { node: refNode } of tracker.iterateGlobalReferences(
          traceMap
        )) {
          if (
            disallowRedundantWrapping &&
            isUnnecessarilyWrappedRegexLiteral(refNode)
          ) {
            const regexNode = refNode.arguments[0];

            if (refNode.arguments.length === 2) {
              const suggests = [];

              const argFlags = getStringValue(refNode.arguments[1]) || '';

              if (canFixTo(refNode, regexNode.regex.pattern, argFlags)) {
                suggests.push({
                  messageId: 'replaceWithLiteralAndFlags',
                  pattern: regexNode.regex.pattern,
                  flags: argFlags,
                });
              }

              const literalFlags = regexNode.regex.flags || '';
              const mergedFlags = mergeRegexFlags(literalFlags, argFlags);

              if (
                !areFlagsEqual(mergedFlags, argFlags) &&
                canFixTo(refNode, regexNode.regex.pattern, mergedFlags)
              ) {
                suggests.push({
                  messageId: 'replaceWithIntendedLiteralAndFlags',
                  pattern: regexNode.regex.pattern,
                  flags: mergedFlags,
                });
              }

              context.report({
                node: refNode,
                messageId: 'unexpectedRedundantRegExpWithFlags',
                suggest: suggests.map(({ flags, pattern, messageId }) => ({
                  messageId,
                  data: {
                    flags,
                  },
                  fix(fixer) {
                    return fixer.replaceText(
                      refNode,
                      getSafeOutput(refNode, `/${pattern}/${flags}`)
                    );
                  },
                })),
              });
            } else {
              const outputs = [];

              if (
                canFixTo(
                  refNode,
                  regexNode.regex.pattern,
                  regexNode.regex.flags
                )
              ) {
                outputs.push(sourceCode.getText(regexNode));
              }

              context.report({
                node: refNode,
                messageId: 'unexpectedRedundantRegExp',
                suggest: outputs.map((output) => ({
                  messageId: 'replaceWithLiteral',
                  fix(fixer) {
                    return fixer.replaceText(
                      refNode,
                      getSafeOutput(refNode, output)
                    );
                  },
                })),
              });
            }
          } else if (hasOnlyStaticStringArguments(refNode)) {
            let regexContent = getStringValue(refNode.arguments[0]);
            let noFix = false;
            let flags;

            if (refNode.arguments[1]) {
              flags = getStringValue(refNode.arguments[1]);
            }

            if (!canFixTo(refNode, regexContent, flags)) {
              noFix = true;
            }

            if (
              !/^[-a-zA-Z0-9\\[\](){} \t\r\n\v\f!@#$%^&*+^_=/~`.><?,'"|:;]*$/u.test(
                regexContent
              )
            ) {
              noFix = true;
            }

            if (regexContent && !noFix) {
              let charIncrease = 0;

              const ast = new RegExpParser({
                ecmaVersion: regexppEcmaVersion,
              }).parsePattern(regexContent, 0, regexContent.length, {
                unicode: flags ? flags.includes('u') : false,
                unicodeSets: flags ? flags.includes('v') : false,
              });

              visitRegExpAST(ast, {
                onCharacterEnter(characterNode) {
                  const escaped = resolveEscapes(characterNode.raw);

                  if (escaped) {
                    regexContent =
                      regexContent.slice(
                        0,
                        characterNode.start + charIncrease
                      ) +
                      escaped +
                      regexContent.slice(characterNode.end + charIncrease);

                    if (characterNode.raw.length === 1) {
                      charIncrease += 1;
                    }
                  }
                },
              });
            }

            const newRegExpValue = `/${regexContent || '(?:)'}/${flags || ''}`;

            context.report({
              node: refNode,
              messageId: 'unexpectedRegExp',
              suggest:
                noFix ?
                  []
                : [
                    {
                      messageId: 'replaceWithLiteral',
                      fix(fixer) {
                        return fixer.replaceText(
                          refNode,
                          getSafeOutput(refNode, newRegExpValue)
                        );
                      },
                    },
                  ],
            });
          }
        }
      },
    };
  },
};