/**
 * @fileoverview Rule to disallow returning values from setters
 * @author Milos Djermanovic
 */

"use strict";

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

const astUtils = require("./utils/ast-utils");
const { findVariable } = require("@eslint-community/eslint-utils");

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

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

    return variable !== null && variable.scope.type === "global" && variable.defs.length === 0;
}

/**
 * Determines whether the given node is an argument of the specified global method call, at the given `index` position.
 * E.g., for given `index === 1`, this function checks for `objectName.methodName(foo, node)`, where objectName is a global variable.
 * @param {ASTNode} node The node to check.
 * @param {Scope} scope Scope to which the node belongs.
 * @param {string} objectName Name of the global object.
 * @param {string} methodName Name of the method.
 * @param {number} index The given position.
 * @returns {boolean} `true` if the node is argument at the given position.
 */
function isArgumentOfGlobalMethodCall(node, scope, objectName, methodName, index) {
    const callNode = node.parent;

    return callNode.type === "CallExpression" &&
        callNode.arguments[index] === node &&
        astUtils.isSpecificMemberAccess(callNode.callee, objectName, methodName) &&
        isGlobalReference(astUtils.skipChainExpression(callNode.callee).object, scope);
}

/**
 * Determines whether the given node is used as a property descriptor.
 * @param {ASTNode} node The node to check.
 * @param {Scope} scope Scope to which the node belongs.
 * @returns {boolean} `true` if the node is a property descriptor.
 */
function isPropertyDescriptor(node, scope) {
    if (
        isArgumentOfGlobalMethodCall(node, scope, "Object", "defineProperty", 2) ||
        isArgumentOfGlobalMethodCall(node, scope, "Reflect", "defineProperty", 2)
    ) {
        return true;
    }

    const parent = node.parent;

    if (
        parent.type === "Property" &&
        parent.value === node
    ) {
        const grandparent = parent.parent;

        if (
            grandparent.type === "ObjectExpression" &&
            (
                isArgumentOfGlobalMethodCall(grandparent, scope, "Object", "create", 1) ||
                isArgumentOfGlobalMethodCall(grandparent, scope, "Object", "defineProperties", 1)
            )
        ) {
            return true;
        }
    }

    return false;
}

/**
 * Determines whether the given function node is used as a setter function.
 * @param {ASTNode} node The node to check.
 * @param {Scope} scope Scope to which the node belongs.
 * @returns {boolean} `true` if the node is a setter.
 */
function isSetter(node, scope) {
    const parent = node.parent;

    if (
        (parent.type === "Property" || parent.type === "MethodDefinition") &&
        parent.kind === "set" &&
        parent.value === node
    ) {

        // Setter in an object literal or in a class
        return true;
    }

    if (
        parent.type === "Property" &&
        parent.value === node &&
        astUtils.getStaticPropertyName(parent) === "set" &&
        parent.parent.type === "ObjectExpression" &&
        isPropertyDescriptor(parent.parent, scope)
    ) {

        // Setter in a property descriptor
        return true;
    }

    return false;
}

/**
 * Finds function's outer scope.
 * @param {Scope} scope Function's own scope.
 * @returns {Scope} Function's outer scope.
 */
function getOuterScope(scope) {
    const upper = scope.upper;

    if (upper.type === "function-expression-name") {
        return upper.upper;
    }

    return upper;
}

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

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

        docs: {
            description: "Disallow returning values from setters",
            recommended: true,
            url: "https://eslint.org/docs/rules/no-setter-return"
        },

        schema: [],

        messages: {
            returnsValue: "Setter cannot return a value."
        }
    },

    create(context) {
        let funcInfo = null;

        /**
         * Creates and pushes to the stack a function info object for the given function node.
         * @param {ASTNode} node The function node.
         * @returns {void}
         */
        function enterFunction(node) {
            const outerScope = getOuterScope(context.getScope());

            funcInfo = {
                upper: funcInfo,
                isSetter: isSetter(node, outerScope)
            };
        }

        /**
         * Pops the current function info object from the stack.
         * @returns {void}
         */
        function exitFunction() {
            funcInfo = funcInfo.upper;
        }

        /**
         * Reports the given node.
         * @param {ASTNode} node Node to report.
         * @returns {void}
         */
        function report(node) {
            context.report({ node, messageId: "returnsValue" });
        }

        return {

            /*
             * Function declarations cannot be setters, but we still have to track them in the `funcInfo` stack to avoid
             * false positives, because a ReturnStatement node can belong to a function declaration inside a setter.
             *
             * Note: A previously declared function can be referenced and actually used as a setter in a property descriptor,
             * but that's out of scope for this rule.
             */
            FunctionDeclaration: enterFunction,
            FunctionExpression: enterFunction,
            ArrowFunctionExpression(node) {
                enterFunction(node);

                if (funcInfo.isSetter && node.expression) {

                    // { set: foo => bar } property descriptor. Report implicit return 'bar' as the equivalent for a return statement.
                    report(node.body);
                }
            },

            "FunctionDeclaration:exit": exitFunction,
            "FunctionExpression:exit": exitFunction,
            "ArrowFunctionExpression:exit": exitFunction,

            ReturnStatement(node) {

                // Global returns (e.g., at the top level of a Node module) don't have `funcInfo`.
                if (funcInfo && funcInfo.isSetter && node.argument) {
                    report(node);
                }
            }
        };
    }
};
