/**
 * @fileoverview Rule to disallow use of Object.prototype builtins on objects
 * @author Andrew Levine
 */
"use strict";

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

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

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

/**
 * Returns true if the node or any of the objects
 * to the left of it in the member/call chain is optional.
 *
 * e.g. `a?.b`, `a?.b.c`, `a?.()`, `a()?.()`
 * @param {ASTNode} node The expression to check
 * @returns {boolean} `true` if there is a short-circuiting optional `?.`
 * in the same option chain to the left of this call or member expression,
 * or the node itself is an optional call or member `?.`.
 */
function isAfterOptional(node) {
    let leftNode;

    if (node.type === "MemberExpression") {
        leftNode = node.object;
    } else if (node.type === "CallExpression") {
        leftNode = node.callee;
    } else {
        return false;
    }
    if (node.optional) {
        return true;
    }
    return isAfterOptional(leftNode);
}


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

/** @type {import('../shared/types').Rule} */
module.exports = {
    meta: {
        type: "problem",

        docs: {
            description: "Disallow calling some `Object.prototype` methods directly on objects",
            recommended: true,
            url: "https://eslint.org/docs/latest/rules/no-prototype-builtins"
        },

        hasSuggestions: true,

        schema: [],

        messages: {
            prototypeBuildIn: "Do not access Object.prototype method '{{prop}}' from target object.",
            callObjectPrototype: "Call Object.prototype.{{prop}} explicitly."
        }
    },

    create(context) {
        const DISALLOWED_PROPS = new Set([
            "hasOwnProperty",
            "isPrototypeOf",
            "propertyIsEnumerable"
        ]);

        /**
         * Reports if a disallowed property is used in a CallExpression
         * @param {ASTNode} node The CallExpression node.
         * @returns {void}
         */
        function disallowBuiltIns(node) {

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

            if (callee.type !== "MemberExpression") {
                return;
            }

            const propName = astUtils.getStaticPropertyName(callee);

            if (propName !== null && DISALLOWED_PROPS.has(propName)) {
                context.report({
                    messageId: "prototypeBuildIn",
                    loc: callee.property.loc,
                    data: { prop: propName },
                    node,
                    suggest: [
                        {
                            messageId: "callObjectPrototype",
                            data: { prop: propName },
                            fix(fixer) {
                                const sourceCode = context.sourceCode;

                                /*
                                 * A call after an optional chain (e.g. a?.b.hasOwnProperty(c))
                                 * must be fixed manually because the call can be short-circuited
                                 */
                                if (isAfterOptional(node)) {
                                    return null;
                                }

                                /*
                                 * A call on a ChainExpression (e.g. (a?.hasOwnProperty)(c)) will trigger
                                 * no-unsafe-optional-chaining which should be fixed before this suggestion
                                 */
                                if (node.callee.type === "ChainExpression") {
                                    return null;
                                }

                                const objectVariable = astUtils.getVariableByName(sourceCode.getScope(node), "Object");

                                /*
                                 * We can't use Object if the global Object was shadowed,
                                 * or Object does not exist in the global scope for some reason
                                 */
                                if (!objectVariable || objectVariable.scope.type !== "global" || objectVariable.defs.length > 0) {
                                    return null;
                                }

                                let objectText = sourceCode.getText(callee.object);

                                if (astUtils.getPrecedence(callee.object) <= astUtils.getPrecedence({ type: "SequenceExpression" })) {
                                    objectText = `(${objectText})`;
                                }

                                const openParenToken = sourceCode.getTokenAfter(
                                    node.callee,
                                    astUtils.isOpeningParenToken
                                );
                                const isEmptyParameters = node.arguments.length === 0;
                                const delim = isEmptyParameters ? "" : ", ";
                                const fixes = [
                                    fixer.replaceText(callee, `Object.prototype.${propName}.call`),
                                    fixer.insertTextAfter(openParenToken, objectText + delim)
                                ];

                                return fixes;
                            }
                        }
                    ]
                });
            }
        }

        return {
            CallExpression: disallowBuiltIns
        };
    }
};
