/**
 * @fileoverview Rule to flag use of constructors without capital letters
 * @author Nicholas C. Zakas
 */

'use strict';

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

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

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

const CAPS_ALLOWED = [
  'Array',
  'Boolean',
  'Date',
  'Error',
  'Function',
  'Number',
  'Object',
  'RegExp',
  'String',
  'Symbol',
  'BigInt',
];

/**
 * A reducer function to invert an array to an Object mapping the string form of the key, to `true`.
 * @param {Object} map Accumulator object for the reduce.
 * @param {string} key Object key to set to `true`.
 * @returns {Object} Returns the updated Object for further reduction.
 */
function invert(map, key) {
  map[key] = true;
  return map;
}

/**
 * Creates an object with the cap is new exceptions as its keys and true as their values.
 * @param {Object} config Rule configuration
 * @returns {Object} Object with cap is new exceptions.
 */
function calculateCapIsNewExceptions(config) {
  const capIsNewExceptions = Array.from(
    new Set([...config.capIsNewExceptions, ...CAPS_ALLOWED])
  );

  return capIsNewExceptions.reduce(invert, {});
}

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

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

    docs: {
      description: 'Require constructor names to begin with a capital letter',
      recommended: false,
      url: 'https://eslint.org/docs/latest/rules/new-cap',
    },

    schema: [
      {
        type: 'object',
        properties: {
          newIsCap: {
            type: 'boolean',
          },
          capIsNew: {
            type: 'boolean',
          },
          newIsCapExceptions: {
            type: 'array',
            items: {
              type: 'string',
            },
          },
          newIsCapExceptionPattern: {
            type: 'string',
          },
          capIsNewExceptions: {
            type: 'array',
            items: {
              type: 'string',
            },
          },
          capIsNewExceptionPattern: {
            type: 'string',
          },
          properties: {
            type: 'boolean',
          },
        },
        additionalProperties: false,
      },
    ],

    defaultOptions: [
      {
        capIsNew: true,
        capIsNewExceptions: CAPS_ALLOWED,
        newIsCap: true,
        newIsCapExceptions: [],
        properties: true,
      },
    ],

    messages: {
      upper:
        'A function with a name starting with an uppercase letter should only be used as a constructor.',
      lower: 'A constructor name should not start with a lowercase letter.',
    },
  },

  create(context) {
    const [config] = context.options;
    const skipProperties = !config.properties;

    const newIsCapExceptions = config.newIsCapExceptions.reduce(invert, {});
    const newIsCapExceptionPattern =
      config.newIsCapExceptionPattern ?
        new RegExp(config.newIsCapExceptionPattern, 'u')
      : null;

    const capIsNewExceptions = calculateCapIsNewExceptions(config);
    const capIsNewExceptionPattern =
      config.capIsNewExceptionPattern ?
        new RegExp(config.capIsNewExceptionPattern, 'u')
      : null;

    const listeners = {};

    const sourceCode = context.sourceCode;

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

    /**
     * Get exact callee name from expression
     * @param {ASTNode} node CallExpression or NewExpression node
     * @returns {string} name
     */
    function extractNameFromExpression(node) {
      return node.callee.type === 'Identifier' ?
          node.callee.name
        : astUtils.getStaticPropertyName(node.callee) || '';
    }

    /**
     * Returns the capitalization state of the string -
     * Whether the first character is uppercase, lowercase, or non-alphabetic
     * @param {string} str String
     * @returns {string} capitalization state: "non-alpha", "lower", or "upper"
     */
    function getCap(str) {
      const firstChar = str.charAt(0);

      const firstCharLower = firstChar.toLowerCase();
      const firstCharUpper = firstChar.toUpperCase();

      if (firstCharLower === firstCharUpper) {
        // char has no uppercase variant, so it's non-alphabetic
        return 'non-alpha';
      }
      if (firstChar === firstCharLower) {
        return 'lower';
      }
      return 'upper';
    }

    /**
     * Check if capitalization is allowed for a CallExpression
     * @param {Object} allowedMap Object mapping calleeName to a Boolean
     * @param {ASTNode} node CallExpression node
     * @param {string} calleeName Capitalized callee name from a CallExpression
     * @param {Object} pattern RegExp object from options pattern
     * @returns {boolean} Returns true if the callee may be capitalized
     */
    function isCapAllowed(allowedMap, node, calleeName, pattern) {
      const sourceText = sourceCode.getText(node.callee);

      if (allowedMap[calleeName] || allowedMap[sourceText]) {
        return true;
      }

      if (pattern && pattern.test(sourceText)) {
        return true;
      }

      const callee = astUtils.skipChainExpression(node.callee);

      if (calleeName === 'UTC' && callee.type === 'MemberExpression') {
        // allow if callee is Date.UTC
        return (
          callee.object.type === 'Identifier' && callee.object.name === 'Date'
        );
      }

      return skipProperties && callee.type === 'MemberExpression';
    }

    /**
     * Reports the given messageId for the given node. The location will be the start of the property or the callee.
     * @param {ASTNode} node CallExpression or NewExpression node.
     * @param {string} messageId The messageId to report.
     * @returns {void}
     */
    function report(node, messageId) {
      let callee = astUtils.skipChainExpression(node.callee);

      if (callee.type === 'MemberExpression') {
        callee = callee.property;
      }

      context.report({ node, loc: callee.loc, messageId });
    }

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

    if (config.newIsCap) {
      listeners.NewExpression = function (node) {
        const constructorName = extractNameFromExpression(node);

        if (constructorName) {
          const capitalization = getCap(constructorName);
          const isAllowed =
            capitalization !== 'lower' ||
            isCapAllowed(
              newIsCapExceptions,
              node,
              constructorName,
              newIsCapExceptionPattern
            );

          if (!isAllowed) {
            report(node, 'lower');
          }
        }
      };
    }

    if (config.capIsNew) {
      listeners.CallExpression = function (node) {
        const calleeName = extractNameFromExpression(node);

        if (calleeName) {
          const capitalization = getCap(calleeName);
          const isAllowed =
            capitalization !== 'upper' ||
            isCapAllowed(
              capIsNewExceptions,
              node,
              calleeName,
              capIsNewExceptionPattern
            );

          if (!isAllowed) {
            report(node, 'upper');
          }
        }
      };
    }

    return listeners;
  },
};