/**
 * @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(?:Last)?(?:Index)?|flatMap|forEach|map|reduce(?:Right)?|some|sort|toSorted)$/u;

/**
 * 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);
}

/**
 * Checks all segments in a set and returns true if any are reachable.
 * @param {Set<CodePathSegment>} segments The segments to check.
 * @returns {boolean} True if any segment is reachable; false otherwise.
 */
function isAnySegmentReachable(segments) {

    for (const segment of segments) {
        if (segment.reachable) {
            return true;
        }
    }

    return false;
}

/**
 * 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;
        }
    }

    /* c8 ignore next */
    return null;
}

/**
 * Checks if the given node is a void expression.
 * @param {ASTNode} node The node to check.
 * @returns {boolean} - `true` if the node is a void expression
 */
function isExpressionVoid(node) {
    return node.type === "UnaryExpression" && node.operator === "void";
}

/**
 * Fixes the linting error by prepending "void " to the given node
 * @param {Object} sourceCode context given by context.sourceCode
 * @param {ASTNode} node The node to fix.
 * @param {Object} fixer The fixer object provided by ESLint.
 * @returns {Array<Object>} - An array of fix objects to apply to the node.
 */
function voidPrependFixer(sourceCode, node, fixer) {

    const requiresParens =

        // prepending `void ` will fail if the node has a lower precedence than void
        astUtils.getPrecedence(node) < astUtils.getPrecedence({ type: "UnaryExpression", operator: "void" }) &&

        // check if there are parentheses around the node to avoid redundant parentheses
        !astUtils.isParenthesised(sourceCode, node);

    // avoid parentheses issues
    const returnOrArrowToken = sourceCode.getTokenBefore(
        node,
        node.parent.type === "ArrowFunctionExpression"
            ? astUtils.isArrowToken

            // isReturnToken
            : token => token.type === "Keyword" && token.value === "return"
    );

    const firstToken = sourceCode.getTokenAfter(returnOrArrowToken);

    const prependSpace =

        // is return token, as => allows void to be adjacent
        returnOrArrowToken.value === "return" &&

        // If two tokens (return and "(") are adjacent
        returnOrArrowToken.range[1] === firstToken.range[0];

    return [
        fixer.insertTextBefore(firstToken, `${prependSpace ? " " : ""}void ${requiresParens ? "(" : ""}`),
        fixer.insertTextAfter(node, requiresParens ? ")" : "")
    ];
}

/**
 * Fixes the linting error by `wrapping {}` around the given node's body.
 * @param {Object} sourceCode context given by context.sourceCode
 * @param {ASTNode} node The node to fix.
 * @param {Object} fixer The fixer object provided by ESLint.
 * @returns {Array<Object>} - An array of fix objects to apply to the node.
 */
function curlyWrapFixer(sourceCode, node, fixer) {
    const arrowToken = sourceCode.getTokenBefore(node.body, astUtils.isArrowToken);
    const firstToken = sourceCode.getTokenAfter(arrowToken);
    const lastToken = sourceCode.getLastToken(node);

    return [
        fixer.insertTextBefore(firstToken, "{"),
        fixer.insertTextAfter(lastToken, "}")
    ];
}

//------------------------------------------------------------------------------
// 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/latest/rules/array-callback-return"
        },

        // eslint-disable-next-line eslint-plugin/require-meta-has-suggestions -- false positive
        hasSuggestions: true,

        schema: [
            {
                type: "object",
                properties: {
                    allowImplicit: {
                        type: "boolean",
                        default: false
                    },
                    checkForEach: {
                        type: "boolean",
                        default: false
                    },
                    allowVoid: {
                        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}}.",
            wrapBraces: "Wrap the expression in `{}`.",
            prependVoid: "Prepend `void` to the expression."
        }
    },

    create(context) {

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

        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;
            }

            const messageAndSuggestions = { messageId: "", suggest: [] };

            if (funcInfo.arrayMethodName === "forEach") {
                if (options.checkForEach && node.type === "ArrowFunctionExpression" && node.expression) {

                    if (options.allowVoid) {
                        if (isExpressionVoid(node.body)) {
                            return;
                        }

                        messageAndSuggestions.messageId = "expectedNoReturnValue";
                        messageAndSuggestions.suggest = [
                            {
                                messageId: "wrapBraces",
                                fix(fixer) {
                                    return curlyWrapFixer(sourceCode, node, fixer);
                                }
                            },
                            {
                                messageId: "prependVoid",
                                fix(fixer) {
                                    return voidPrependFixer(sourceCode, node.body, fixer);
                                }
                            }
                        ];
                    } else {
                        messageAndSuggestions.messageId = "expectedNoReturnValue";
                        messageAndSuggestions.suggest = [{
                            messageId: "wrapBraces",
                            fix(fixer) {
                                return curlyWrapFixer(sourceCode, node, fixer);
                            }
                        }];
                    }
                }
            } else {
                if (node.body.type === "BlockStatement" && isAnySegmentReachable(funcInfo.currentSegments)) {
                    messageAndSuggestions.messageId = funcInfo.hasReturn ? "expectedAtEnd" : "expectedInside";
                }
            }

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

                context.report({
                    node,
                    loc: astUtils.getFunctionHeadLoc(node, sourceCode),
                    messageId: messageAndSuggestions.messageId,
                    data: { name, arrayMethodName: fullMethodName(funcInfo.arrayMethodName) },
                    suggest: messageAndSuggestions.suggest.length !== 0 ? messageAndSuggestions.suggest : null
                });
            }
        }

        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,
                    currentSegments: new Set()
                };
            },

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

            onUnreachableCodePathSegmentStart(segment) {
                funcInfo.currentSegments.add(segment);
            },

            onUnreachableCodePathSegmentEnd(segment) {
                funcInfo.currentSegments.delete(segment);
            },

            onCodePathSegmentStart(segment) {
                funcInfo.currentSegments.add(segment);
            },

            onCodePathSegmentEnd(segment) {
                funcInfo.currentSegments.delete(segment);
            },


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

                if (!funcInfo.shouldCheck) {
                    return;
                }

                funcInfo.hasReturn = true;

                const messageAndSuggestions = { messageId: "", suggest: [] };

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

                    // if checkForEach: true, returning a value at any path inside a forEach is not allowed
                    if (options.checkForEach && node.argument) {

                        if (options.allowVoid) {
                            if (isExpressionVoid(node.argument)) {
                                return;
                            }

                            messageAndSuggestions.messageId = "expectedNoReturnValue";
                            messageAndSuggestions.suggest = [{
                                messageId: "prependVoid",
                                fix(fixer) {
                                    return voidPrependFixer(sourceCode, node.argument, fixer);
                                }
                            }];
                        } else {
                            messageAndSuggestions.messageId = "expectedNoReturnValue";
                        }
                    }
                } else {

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

                if (messageAndSuggestions.messageId) {
                    context.report({
                        node,
                        messageId: messageAndSuggestions.messageId,
                        data: {
                            name: astUtils.getFunctionNameWithKind(funcInfo.node),
                            arrayMethodName: fullMethodName(funcInfo.arrayMethodName)
                        },
                        suggest: messageAndSuggestions.suggest.length !== 0 ? messageAndSuggestions.suggest : null
                    });
                }
            },

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