/**
 * @fileoverview A rule to verify `super()` callings in constructor.
 * @author Toru Nagashima
 */

"use strict";

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

/**
 * Checks whether or not a given node is a constructor.
 * @param {ASTNode} node A node to check. This node type is one of
 *   `Program`, `FunctionDeclaration`, `FunctionExpression`, and
 *   `ArrowFunctionExpression`.
 * @returns {boolean} `true` if the node is a constructor.
 */
function isConstructorFunction(node) {
    return (
        node.type === "FunctionExpression" &&
        node.parent.type === "MethodDefinition" &&
        node.parent.kind === "constructor"
    );
}

/**
 * Checks whether a given node can be a constructor or not.
 * @param {ASTNode} node A node to check.
 * @returns {boolean} `true` if the node can be a constructor.
 */
function isPossibleConstructor(node) {
    if (!node) {
        return false;
    }

    switch (node.type) {
        case "ClassExpression":
        case "FunctionExpression":
        case "ThisExpression":
        case "MemberExpression":
        case "CallExpression":
        case "NewExpression":
        case "ChainExpression":
        case "YieldExpression":
        case "TaggedTemplateExpression":
        case "MetaProperty":
            return true;

        case "Identifier":
            return node.name !== "undefined";

        case "AssignmentExpression":
            if (["=", "&&="].includes(node.operator)) {
                return isPossibleConstructor(node.right);
            }

            if (["||=", "??="].includes(node.operator)) {
                return (
                    isPossibleConstructor(node.left) ||
                    isPossibleConstructor(node.right)
                );
            }

            /**
             * All other assignment operators are mathematical assignment operators (arithmetic or bitwise).
             * An assignment expression with a mathematical operator can either evaluate to a primitive value,
             * or throw, depending on the operands. Thus, it cannot evaluate to a constructor function.
             */
            return false;

        case "LogicalExpression":

            /*
             * If the && operator short-circuits, the left side was falsy and therefore not a constructor, and if
             * it doesn't short-circuit, it takes the value from the right side, so the right side must always be a
             * possible constructor. A future improvement could verify that the left side could be truthy by
             * excluding falsy literals.
             */
            if (node.operator === "&&") {
                return isPossibleConstructor(node.right);
            }

            return (
                isPossibleConstructor(node.left) ||
                isPossibleConstructor(node.right)
            );

        case "ConditionalExpression":
            return (
                isPossibleConstructor(node.alternate) ||
                isPossibleConstructor(node.consequent)
            );

        case "SequenceExpression": {
            const lastExpression = node.expressions.at(-1);

            return isPossibleConstructor(lastExpression);
        }

        default:
            return false;
    }
}

/**
 * A class to store information about a code path segment.
 */
class SegmentInfo {

    /**
     * Indicates if super() is called in all code paths.
     * @type {boolean}
     */
    calledInEveryPaths = false;

    /**
     * Indicates if super() is called in any code paths.
     * @type {boolean}
     */
    calledInSomePaths = false;

    /**
     * The nodes which have been validated and don't need to be reconsidered.
     * @type {ASTNode[]}
     */
    validNodes = [];
}

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

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

        docs: {
            description: "Require `super()` calls in constructors",
            recommended: true,
            url: "https://eslint.org/docs/latest/rules/constructor-super"
        },

        schema: [],

        messages: {
            missingSome: "Lacked a call of 'super()' in some code paths.",
            missingAll: "Expected to call 'super()'.",

            duplicate: "Unexpected duplicate 'super()'.",
            badSuper: "Unexpected 'super()' because 'super' is not a constructor."
        }
    },

    create(context) {

        /*
         * {{hasExtends: boolean, scope: Scope, codePath: CodePath}[]}
         * Information for each constructor.
         * - upper:      Information of the upper constructor.
         * - hasExtends: A flag which shows whether own class has a valid `extends`
         *               part.
         * - scope:      The scope of own class.
         * - codePath:   The code path object of the constructor.
         */
        let funcInfo = null;

        /**
         * @type {Record<string, SegmentInfo>}
         */
        const segInfoMap = Object.create(null);

        /**
         * Gets the flag which shows `super()` is called in some paths.
         * @param {CodePathSegment} segment A code path segment to get.
         * @returns {boolean} The flag which shows `super()` is called in some paths
         */
        function isCalledInSomePath(segment) {
            return segment.reachable && segInfoMap[segment.id].calledInSomePaths;
        }

        /**
         * Determines if a segment has been seen in the traversal.
         * @param {CodePathSegment} segment A code path segment to check.
         * @returns {boolean} `true` if the segment has been seen.
         */
        function hasSegmentBeenSeen(segment) {
            return !!segInfoMap[segment.id];
        }

        /**
         * Gets the flag which shows `super()` is called in all paths.
         * @param {CodePathSegment} segment A code path segment to get.
         * @returns {boolean} The flag which shows `super()` is called in all paths.
         */
        function isCalledInEveryPath(segment) {
            return segment.reachable && segInfoMap[segment.id].calledInEveryPaths;
        }

        return {

            /**
             * Stacks a constructor information.
             * @param {CodePath} codePath A code path which was started.
             * @param {ASTNode} node The current node.
             * @returns {void}
             */
            onCodePathStart(codePath, node) {
                if (isConstructorFunction(node)) {

                    // Class > ClassBody > MethodDefinition > FunctionExpression
                    const classNode = node.parent.parent.parent;
                    const superClass = classNode.superClass;

                    funcInfo = {
                        upper: funcInfo,
                        isConstructor: true,
                        hasExtends: Boolean(superClass),
                        superIsConstructor: isPossibleConstructor(superClass),
                        codePath,
                        currentSegments: new Set()
                    };
                } else {
                    funcInfo = {
                        upper: funcInfo,
                        isConstructor: false,
                        hasExtends: false,
                        superIsConstructor: false,
                        codePath,
                        currentSegments: new Set()
                    };
                }
            },

            /**
             * Pops a constructor information.
             * And reports if `super()` lacked.
             * @param {CodePath} codePath A code path which was ended.
             * @param {ASTNode} node The current node.
             * @returns {void}
             */
            onCodePathEnd(codePath, node) {
                const hasExtends = funcInfo.hasExtends;

                // Pop.
                funcInfo = funcInfo.upper;

                if (!hasExtends) {
                    return;
                }

                // Reports if `super()` lacked.
                const returnedSegments = codePath.returnedSegments;
                const calledInEveryPaths = returnedSegments.every(isCalledInEveryPath);
                const calledInSomePaths = returnedSegments.some(isCalledInSomePath);

                if (!calledInEveryPaths) {
                    context.report({
                        messageId: calledInSomePaths
                            ? "missingSome"
                            : "missingAll",
                        node: node.parent
                    });
                }
            },

            /**
             * Initialize information of a given code path segment.
             * @param {CodePathSegment} segment A code path segment to initialize.
             * @param {CodePathSegment} node Node that starts the segment.
             * @returns {void}
             */
            onCodePathSegmentStart(segment, node) {

                funcInfo.currentSegments.add(segment);

                if (!(funcInfo.isConstructor && funcInfo.hasExtends)) {
                    return;
                }

                // Initialize info.
                const info = segInfoMap[segment.id] = new SegmentInfo();

                const seenPrevSegments = segment.prevSegments.filter(hasSegmentBeenSeen);

                // When there are previous segments, aggregates these.
                if (seenPrevSegments.length > 0) {
                    info.calledInSomePaths = seenPrevSegments.some(isCalledInSomePath);
                    info.calledInEveryPaths = seenPrevSegments.every(isCalledInEveryPath);
                }

                /*
                 * ForStatement > *.update segments are a special case as they are created in advance,
                 * without seen previous segments. Since they logically don't affect `calledInEveryPaths`
                 * calculations, and they can never be a lone previous segment of another one, we'll set
                 * their `calledInEveryPaths` to `true` to effectively ignore them in those calculations.
                 * .
                 */
                if (node.parent && node.parent.type === "ForStatement" && node.parent.update === node) {
                    info.calledInEveryPaths = true;
                }
            },

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

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

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


            /**
             * Update information of the code path segment when a code path was
             * looped.
             * @param {CodePathSegment} fromSegment The code path segment of the
             *      end of a loop.
             * @param {CodePathSegment} toSegment A code path segment of the head
             *      of a loop.
             * @returns {void}
             */
            onCodePathSegmentLoop(fromSegment, toSegment) {
                if (!(funcInfo.isConstructor && funcInfo.hasExtends)) {
                    return;
                }

                funcInfo.codePath.traverseSegments(
                    { first: toSegment, last: fromSegment },
                    (segment, controller) => {
                        const info = segInfoMap[segment.id];

                        // skip segments after the loop
                        if (!info) {
                            controller.skip();
                            return;
                        }

                        const seenPrevSegments = segment.prevSegments.filter(hasSegmentBeenSeen);
                        const calledInSomePreviousPaths = seenPrevSegments.some(isCalledInSomePath);
                        const calledInEveryPreviousPaths = seenPrevSegments.every(isCalledInEveryPath);

                        info.calledInSomePaths ||= calledInSomePreviousPaths;
                        info.calledInEveryPaths ||= calledInEveryPreviousPaths;

                        // If flags become true anew, reports the valid nodes.
                        if (calledInSomePreviousPaths) {
                            const nodes = info.validNodes;

                            info.validNodes = [];

                            for (let i = 0; i < nodes.length; ++i) {
                                const node = nodes[i];

                                context.report({
                                    messageId: "duplicate",
                                    node
                                });
                            }
                        }
                    }
                );
            },

            /**
             * Checks for a call of `super()`.
             * @param {ASTNode} node A CallExpression node to check.
             * @returns {void}
             */
            "CallExpression:exit"(node) {
                if (!(funcInfo.isConstructor && funcInfo.hasExtends)) {
                    return;
                }

                // Skips except `super()`.
                if (node.callee.type !== "Super") {
                    return;
                }

                // Reports if needed.
                const segments = funcInfo.currentSegments;
                let duplicate = false;
                let info = null;

                for (const segment of segments) {

                    if (segment.reachable) {
                        info = segInfoMap[segment.id];

                        duplicate = duplicate || info.calledInSomePaths;
                        info.calledInSomePaths = info.calledInEveryPaths = true;
                    }
                }

                if (info) {
                    if (duplicate) {
                        context.report({
                            messageId: "duplicate",
                            node
                        });
                    } else if (!funcInfo.superIsConstructor) {
                        context.report({
                            messageId: "badSuper",
                            node
                        });
                    } else {
                        info.validNodes.push(node);
                    }
                }
            },

            /**
             * Set the mark to the returned path as `super()` was called.
             * @param {ASTNode} node A ReturnStatement node to check.
             * @returns {void}
             */
            ReturnStatement(node) {
                if (!(funcInfo.isConstructor && funcInfo.hasExtends)) {
                    return;
                }

                // Skips if no argument.
                if (!node.argument) {
                    return;
                }

                // Returning argument is a substitute of 'super()'.
                const segments = funcInfo.currentSegments;

                for (const segment of segments) {

                    if (segment.reachable) {
                        const info = segInfoMap[segment.id];

                        info.calledInSomePaths = info.calledInEveryPaths = true;
                    }
                }
            }
        };
    }
};
