/**
 * @fileoverview Rule to require function names to match the name of the variable or property to which they are assigned.
 * @author Annie Zhang, Pavel Strashkin
 */

'use strict';

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

const astUtils = require('./utils/ast-utils');
const esutils = require('esutils');

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

/**
 * Determines if a pattern is `module.exports` or `module["exports"]`
 * @param {ASTNode} pattern The left side of the AssignmentExpression
 * @returns {boolean} True if the pattern is `module.exports` or `module["exports"]`
 */
function isModuleExports(pattern) {
  if (
    pattern.type === 'MemberExpression' &&
    pattern.object.type === 'Identifier' &&
    pattern.object.name === 'module'
  ) {
    // module.exports
    if (
      pattern.property.type === 'Identifier' &&
      pattern.property.name === 'exports'
    ) {
      return true;
    }

    // module["exports"]
    if (
      pattern.property.type === 'Literal' &&
      pattern.property.value === 'exports'
    ) {
      return true;
    }
  }
  return false;
}

/**
 * Determines if a string name is a valid identifier
 * @param {string} name The string to be checked
 * @param {int} ecmaVersion The ECMAScript version if specified in the parserOptions config
 * @returns {boolean} True if the string is a valid identifier
 */
function isIdentifier(name, ecmaVersion) {
  if (ecmaVersion >= 2015) {
    return esutils.keyword.isIdentifierES6(name);
  }
  return esutils.keyword.isIdentifierES5(name);
}

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

const alwaysOrNever = { enum: ['always', 'never'] };
const optionsObject = {
  type: 'object',
  properties: {
    considerPropertyDescriptor: {
      type: 'boolean',
    },
    includeCommonJSModuleExports: {
      type: 'boolean',
    },
  },
  additionalProperties: false,
};

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

    docs: {
      description:
        'Require function names to match the name of the variable or property to which they are assigned',
      recommended: false,
      frozen: true,
      url: 'https://eslint.org/docs/latest/rules/func-name-matching',
    },

    schema: {
      anyOf: [
        {
          type: 'array',
          additionalItems: false,
          items: [alwaysOrNever, optionsObject],
        },
        {
          type: 'array',
          additionalItems: false,
          items: [optionsObject],
        },
      ],
    },

    messages: {
      matchProperty:
        'Function name `{{funcName}}` should match property name `{{name}}`.',
      matchVariable:
        'Function name `{{funcName}}` should match variable name `{{name}}`.',
      notMatchProperty:
        'Function name `{{funcName}}` should not match property name `{{name}}`.',
      notMatchVariable:
        'Function name `{{funcName}}` should not match variable name `{{name}}`.',
    },
  },

  create(context) {
    const options =
      (typeof context.options[0] === 'object' ?
        context.options[0]
      : context.options[1]) || {};
    const nameMatches =
      typeof context.options[0] === 'string' ? context.options[0] : 'always';
    const considerPropertyDescriptor = options.considerPropertyDescriptor;
    const includeModuleExports = options.includeCommonJSModuleExports;
    const ecmaVersion = context.languageOptions.ecmaVersion;

    /**
     * Check whether node is a certain CallExpression.
     * @param {string} objName object name
     * @param {string} funcName function name
     * @param {ASTNode} node The node to check
     * @returns {boolean} `true` if node matches CallExpression
     */
    function isPropertyCall(objName, funcName, node) {
      if (!node) {
        return false;
      }
      return (
        node.type === 'CallExpression' &&
        astUtils.isSpecificMemberAccess(node.callee, objName, funcName)
      );
    }

    /**
     * Compares identifiers based on the nameMatches option
     * @param {string} x the first identifier
     * @param {string} y the second identifier
     * @returns {boolean} whether the two identifiers should warn.
     */
    function shouldWarn(x, y) {
      return (
        (nameMatches === 'always' && x !== y) ||
        (nameMatches === 'never' && x === y)
      );
    }

    /**
     * Reports
     * @param {ASTNode} node The node to report
     * @param {string} name The variable or property name
     * @param {string} funcName The function name
     * @param {boolean} isProp True if the reported node is a property assignment
     * @returns {void}
     */
    function report(node, name, funcName, isProp) {
      let messageId;

      if (nameMatches === 'always' && isProp) {
        messageId = 'matchProperty';
      } else if (nameMatches === 'always') {
        messageId = 'matchVariable';
      } else if (isProp) {
        messageId = 'notMatchProperty';
      } else {
        messageId = 'notMatchVariable';
      }
      context.report({
        node,
        messageId,
        data: {
          name,
          funcName,
        },
      });
    }

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

    //--------------------------------------------------------------------------
    // Public
    //--------------------------------------------------------------------------

    return {
      VariableDeclarator(node) {
        if (
          !node.init ||
          node.init.type !== 'FunctionExpression' ||
          node.id.type !== 'Identifier'
        ) {
          return;
        }
        if (node.init.id && shouldWarn(node.id.name, node.init.id.name)) {
          report(node, node.id.name, node.init.id.name, false);
        }
      },

      AssignmentExpression(node) {
        if (
          node.right.type !== 'FunctionExpression' ||
          (node.left.computed && node.left.property.type !== 'Literal') ||
          (!includeModuleExports && isModuleExports(node.left)) ||
          (node.left.type !== 'Identifier' &&
            node.left.type !== 'MemberExpression')
        ) {
          return;
        }

        const isProp = node.left.type === 'MemberExpression';
        const name =
          isProp ? astUtils.getStaticPropertyName(node.left) : node.left.name;

        if (
          node.right.id &&
          name &&
          isIdentifier(name) &&
          shouldWarn(name, node.right.id.name)
        ) {
          report(node, name, node.right.id.name, isProp);
        }
      },

      'Property, PropertyDefinition[value]'(node) {
        if (!(node.value.type === 'FunctionExpression' && node.value.id)) {
          return;
        }

        if (node.key.type === 'Identifier' && !node.computed) {
          const functionName = node.value.id.name;
          let propertyName = node.key.name;

          if (
            considerPropertyDescriptor &&
            propertyName === 'value' &&
            node.parent.type === 'ObjectExpression'
          ) {
            if (
              isPropertyCall('Object', 'defineProperty', node.parent.parent) ||
              isPropertyCall('Reflect', 'defineProperty', node.parent.parent)
            ) {
              const property = node.parent.parent.arguments[1];

              if (
                isStringLiteral(property) &&
                shouldWarn(property.value, functionName)
              ) {
                report(node, property.value, functionName, true);
              }
            } else if (
              isPropertyCall(
                'Object',
                'defineProperties',
                node.parent.parent.parent.parent
              )
            ) {
              propertyName = node.parent.parent.key.name;
              if (
                !node.parent.parent.computed &&
                shouldWarn(propertyName, functionName)
              ) {
                report(node, propertyName, functionName, true);
              }
            } else if (
              isPropertyCall(
                'Object',
                'create',
                node.parent.parent.parent.parent
              )
            ) {
              propertyName = node.parent.parent.key.name;
              if (
                !node.parent.parent.computed &&
                shouldWarn(propertyName, functionName)
              ) {
                report(node, propertyName, functionName, true);
              }
            } else if (shouldWarn(propertyName, functionName)) {
              report(node, propertyName, functionName, true);
            }
          } else if (shouldWarn(propertyName, functionName)) {
            report(node, propertyName, functionName, true);
          }
          return;
        }

        if (
          isStringLiteral(node.key) &&
          isIdentifier(node.key.value, ecmaVersion) &&
          shouldWarn(node.key.value, node.value.id.name)
        ) {
          report(node, node.key.value, node.value.id.name, true);
        }
      },
    };
  },
};