/**
 * @fileoverview Rule to enforce return statements in callbacks of array's methods
 * @author Toru Nagashima
 */

"use strict";

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

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

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

const TARGET_NODE_TYPE = /^(?:Arrow)?FunctionExpression$/u;
const TARGET_METHODS = /^(?:every|filter|find(?:Index)?|flatMap|forEach|map|reduce(?:Right)?|some|sort)$/u;

/**
 * Checks a given code path segment is reachable.
 * @param {CodePathSegment} segment A segment to check.
 * @returns {boolean} `true` if the segment is reachable.
 */
function isReachable(segment) {
    return segment.reachable;
}

/**
 * Checks a given node is a member access which has the specified name's
 * property.
 * @param {ASTNode} node A node to check.
 * @returns {boolean} `true` if the node is a member access which has
 *      the specified name's property. The node may be a `(Chain|Member)Expression` node.
 */
function isTargetMethod(node) {
    return astUtils.isSpecificMemberAccess(node, null, TARGET_METHODS);
}

/**
 * Returns a human-legible description of an array method
 * @param {string} arrayMethodName A method name to fully qualify
 * @returns {string} the method name prefixed with `Array.` if it is a class method,
 *      or else `Array.prototype.` if it is an instance method.
 */
function fullMethodName(arrayMethodName) {
    if (["from", "of", "isArray"].includes(arrayMethodName)) {
        return "Array.".concat(arrayMethodName);
    }
    return "Array.prototype.".concat(arrayMethodName);
}

/**
 * Checks whether or not a given node is a function expression which is the
 * callback of an array method, returning the method name.
 * @param {ASTNode} node A node to check. This is one of
 *      FunctionExpression or ArrowFunctionExpression.
 * @returns {string} The method name if the node is a callback method,
 *      null otherwise.
 */
function getArrayMethodName(node) {
    let currentNode = node;

    while (currentNode) {
        const parent = currentNode.parent;

        switch (parent.type) {

            /*
             * Looks up the destination. e.g.,
             * foo.every(nativeFoo || function foo() { ... });
             */
            case "LogicalExpression":
            case "ConditionalExpression":
            case "ChainExpression":
                currentNode = parent;
                break;

            /*
             * If the upper function is IIFE, checks the destination of the return value.
             * e.g.
             *   foo.every((function() {
             *     // setup...
             *     return function callback() { ... };
             *   })());
             */
            case "ReturnStatement": {
                const func = astUtils.getUpperFunction(parent);

                if (func === null || !astUtils.isCallee(func)) {
                    return null;
                }
                currentNode = func.parent;
                break;
            }

            /*
             * e.g.
             *   Array.from([], function() {});
             *   list.every(function() {});
             */
            case "CallExpression":
                if (astUtils.isArrayFromMethod(parent.callee)) {
                    if (
                        parent.arguments.length >= 2 &&
                        parent.arguments[1] === currentNode
                    ) {
                        return "from";
                    }
                }
                if (isTargetMethod(parent.callee)) {
                    if (
                        parent.arguments.length >= 1 &&
                        parent.arguments[0] === currentNode
                    ) {
                        return astUtils.getStaticPropertyName(parent.callee);
                    }
                }
                return null;

            // Otherwise this node is not target.
            default:
                return null;
        }
    }

    /* istanbul ignore next: unreachable */
    return null;
}

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

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

        docs: {
            description: "Enforce `return` statements in callbacks of array methods",
            recommended: false,
            url: "https://eslint.org/docs/rules/array-callback-return"
        },

        schema: [
            {
                type: "object",
                properties: {
                    allowImplicit: {
                        type: "boolean",
                        default: false
                    },
                    checkForEach: {
                        type: "boolean",
                        default: false
                    }
                },
                additionalProperties: false
            }
        ],

        messages: {
            expectedAtEnd: "{{arrayMethodName}}() expects a value to be returned at the end of {{name}}.",
            expectedInside: "{{arrayMethodName}}() expects a return value from {{name}}.",
            expectedReturnValue: "{{arrayMethodName}}() expects a return value from {{name}}.",
            expectedNoReturnValue: "{{arrayMethodName}}() expects no useless return value from {{name}}."
        }
    },

    create(context) {

        const options = context.options[0] || { allowImplicit: false, checkForEach: false };
        const sourceCode = context.getSourceCode();

        let funcInfo = {
            arrayMethodName: null,
            upper: null,
            codePath: null,
            hasReturn: false,
            shouldCheck: false,
            node: null
        };

        /**
         * Checks whether or not the last code path segment is reachable.
         * Then reports this function if the segment is reachable.
         *
         * If the last code path segment is reachable, there are paths which are not
         * returned or thrown.
         * @param {ASTNode} node A node to check.
         * @returns {void}
         */
        function checkLastSegment(node) {

            if (!funcInfo.shouldCheck) {
                return;
            }

            let messageId = null;

            if (funcInfo.arrayMethodName === "forEach") {
                if (options.checkForEach && node.type === "ArrowFunctionExpression" && node.expression) {
                    messageId = "expectedNoReturnValue";
                }
            } else {
                if (node.body.type === "BlockStatement" && funcInfo.codePath.currentSegments.some(isReachable)) {
                    messageId = funcInfo.hasReturn ? "expectedAtEnd" : "expectedInside";
                }
            }

            if (messageId) {
                const name = astUtils.getFunctionNameWithKind(node);

                context.report({
                    node,
                    loc: astUtils.getFunctionHeadLoc(node, sourceCode),
                    messageId,
                    data: { name, arrayMethodName: fullMethodName(funcInfo.arrayMethodName) }
                });
            }
        }

        return {

            // Stacks this function's information.
            onCodePathStart(codePath, node) {

                let methodName = null;

                if (TARGET_NODE_TYPE.test(node.type)) {
                    methodName = getArrayMethodName(node);
                }

                funcInfo = {
                    arrayMethodName: methodName,
                    upper: funcInfo,
                    codePath,
                    hasReturn: false,
                    shouldCheck:
                        methodName &&
                        !node.async &&
                        !node.generator,
                    node
                };
            },

            // Pops this function's information.
            onCodePathEnd() {
                funcInfo = funcInfo.upper;
            },

            // Checks the return statement is valid.
            ReturnStatement(node) {

                if (!funcInfo.shouldCheck) {
                    return;
                }

                funcInfo.hasReturn = true;

                let messageId = null;

                if (funcInfo.arrayMethodName === "forEach") {

                    // if checkForEach: true, returning a value at any path inside a forEach is not allowed
                    if (options.checkForEach && node.argument) {
                        messageId = "expectedNoReturnValue";
                    }
                } else {

                    // if allowImplicit: false, should also check node.argument
                    if (!options.allowImplicit && !node.argument) {
                        messageId = "expectedReturnValue";
                    }
                }

                if (messageId) {
                    context.report({
                        node,
                        messageId,
                        data: {
                            name: astUtils.getFunctionNameWithKind(funcInfo.node),
                            arrayMethodName: fullMethodName(funcInfo.arrayMethodName)
                        }
                    });
                }
            },

            // Reports a given function if the last path is reachable.
            "FunctionExpression:exit": checkLastSegment,
            "ArrowFunctionExpression:exit": checkLastSegment
        };
    }
};
