/**
 * @fileoverview Rule to disallow returning values from Promise executor functions
 * @author Milos Djermanovic
 */

"use strict";

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

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

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

const functionTypesToCheck = new Set(["ArrowFunctionExpression", "FunctionExpression"]);

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

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

/**
 * Determines whether the given function node is used as a Promise executor.
 * @param {ASTNode} node The node to check.
 * @param {Scope} scope Function's own scope.
 * @returns {boolean} `true` if the node is a Promise executor.
 */
function isPromiseExecutor(node, scope) {
    const parent = node.parent;

    return parent.type === "NewExpression" &&
        parent.arguments[0] === node &&
        parent.callee.type === "Identifier" &&
        parent.callee.name === "Promise" &&
        isGlobalReference(parent.callee, getOuterScope(scope));
}

/**
 * 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 expressionIsVoid(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) {

    // https://github.com/eslint/eslint/pull/17282#issuecomment-1592795923
    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",

        defaultOptions: [{
            allowVoid: false
        }],

        docs: {
            description: "Disallow returning values from Promise executor functions",
            recommended: false,
            url: "https://eslint.org/docs/latest/rules/no-promise-executor-return"
        },

        hasSuggestions: true,

        schema: [{
            type: "object",
            properties: {
                allowVoid: {
                    type: "boolean"
                }
            },
            additionalProperties: false
        }],

        messages: {
            returnsValue: "Return values from promise executor functions cannot be read.",

            // arrow and function suggestions
            prependVoid: "Prepend `void` to the expression.",

            // only arrow suggestions
            wrapBraces: "Wrap the expression in `{}`."
        }
    },

    create(context) {
        let funcInfo = null;
        const sourceCode = context.sourceCode;
        const [{ allowVoid }] = context.options;

        return {

            onCodePathStart(_, node) {
                funcInfo = {
                    upper: funcInfo,
                    shouldCheck:
                        functionTypesToCheck.has(node.type) &&
                        isPromiseExecutor(node, sourceCode.getScope(node))
                };

                if (// Is a Promise executor
                    funcInfo.shouldCheck &&
                    node.type === "ArrowFunctionExpression" &&
                    node.expression &&

                    // Except void
                    !(allowVoid && expressionIsVoid(node.body))
                ) {
                    const suggest = [];

                    // prevent useless refactors
                    if (allowVoid) {
                        suggest.push({
                            messageId: "prependVoid",
                            fix(fixer) {
                                return voidPrependFixer(sourceCode, node.body, fixer);
                            }
                        });
                    }

                    // Do not suggest wrapping an unnamed FunctionExpression in braces as that would be invalid syntax.
                    if (!(node.body.type === "FunctionExpression" && !node.body.id)) {
                        suggest.push({
                            messageId: "wrapBraces",
                            fix(fixer) {
                                return curlyWrapFixer(sourceCode, node, fixer);
                            }
                        });
                    }

                    context.report({
                        node: node.body,
                        messageId: "returnsValue",
                        suggest
                    });
                }
            },

            onCodePathEnd() {
                funcInfo = funcInfo.upper;
            },

            ReturnStatement(node) {
                if (!(funcInfo.shouldCheck && node.argument)) {
                    return;
                }

                // node is `return <expression>`
                if (!allowVoid) {
                    context.report({ node, messageId: "returnsValue" });
                    return;
                }

                if (expressionIsVoid(node.argument)) {
                    return;
                }

                // allowVoid && !expressionIsVoid
                context.report({
                    node,
                    messageId: "returnsValue",
                    suggest: [{
                        messageId: "prependVoid",
                        fix(fixer) {
                            return voidPrependFixer(sourceCode, node.argument, fixer);
                        }
                    }]
                });
            }
        };
    }
};
