/**
 * @fileoverview Prefer destructuring from arrays and objects
 * @author Alex LaFroscia
 */
"use strict";

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

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

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

const PRECEDENCE_OF_ASSIGNMENT_EXPR = astUtils.getPrecedence({ type: "AssignmentExpression" });

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

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

        docs: {
            description: "Require destructuring from arrays and/or objects",
            recommended: false,
            url: "https://eslint.org/docs/rules/prefer-destructuring"
        },

        fixable: "code",

        schema: [
            {

                /*
                 * old support {array: Boolean, object: Boolean}
                 * new support {VariableDeclarator: {}, AssignmentExpression: {}}
                 */
                oneOf: [
                    {
                        type: "object",
                        properties: {
                            VariableDeclarator: {
                                type: "object",
                                properties: {
                                    array: {
                                        type: "boolean"
                                    },
                                    object: {
                                        type: "boolean"
                                    }
                                },
                                additionalProperties: false
                            },
                            AssignmentExpression: {
                                type: "object",
                                properties: {
                                    array: {
                                        type: "boolean"
                                    },
                                    object: {
                                        type: "boolean"
                                    }
                                },
                                additionalProperties: false
                            }
                        },
                        additionalProperties: false
                    },
                    {
                        type: "object",
                        properties: {
                            array: {
                                type: "boolean"
                            },
                            object: {
                                type: "boolean"
                            }
                        },
                        additionalProperties: false
                    }
                ]
            },
            {
                type: "object",
                properties: {
                    enforceForRenamedProperties: {
                        type: "boolean"
                    }
                },
                additionalProperties: false
            }
        ],

        messages: {
            preferDestructuring: "Use {{type}} destructuring."
        }
    },
    create(context) {

        const enabledTypes = context.options[0];
        const enforceForRenamedProperties = context.options[1] && context.options[1].enforceForRenamedProperties;
        let normalizedOptions = {
            VariableDeclarator: { array: true, object: true },
            AssignmentExpression: { array: true, object: true }
        };

        if (enabledTypes) {
            normalizedOptions = typeof enabledTypes.array !== "undefined" || typeof enabledTypes.object !== "undefined"
                ? { VariableDeclarator: enabledTypes, AssignmentExpression: enabledTypes }
                : enabledTypes;
        }

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

        /**
         * Checks if destructuring type should be checked.
         * @param {string} nodeType "AssignmentExpression" or "VariableDeclarator"
         * @param {string} destructuringType "array" or "object"
         * @returns {boolean} `true` if the destructuring type should be checked for the given node
         */
        function shouldCheck(nodeType, destructuringType) {
            return normalizedOptions &&
                normalizedOptions[nodeType] &&
                normalizedOptions[nodeType][destructuringType];
        }

        /**
         * Determines if the given node is accessing an array index
         *
         * This is used to differentiate array index access from object property
         * access.
         * @param {ASTNode} node the node to evaluate
         * @returns {boolean} whether or not the node is an integer
         */
        function isArrayIndexAccess(node) {
            return Number.isInteger(node.property.value);
        }

        /**
         * Report that the given node should use destructuring
         * @param {ASTNode} reportNode the node to report
         * @param {string} type the type of destructuring that should have been done
         * @param {Function|null} fix the fix function or null to pass to context.report
         * @returns {void}
         */
        function report(reportNode, type, fix) {
            context.report({
                node: reportNode,
                messageId: "preferDestructuring",
                data: { type },
                fix
            });
        }

        /**
         * Determines if a node should be fixed into object destructuring
         *
         * The fixer only fixes the simplest case of object destructuring,
         * like: `let x = a.x`;
         *
         * Assignment expression is not fixed.
         * Array destructuring is not fixed.
         * Renamed property is not fixed.
         * @param {ASTNode} node the node to evaluate
         * @returns {boolean} whether or not the node should be fixed
         */
        function shouldFix(node) {
            return node.type === "VariableDeclarator" &&
                node.id.type === "Identifier" &&
                node.init.type === "MemberExpression" &&
                !node.init.computed &&
                node.init.property.type === "Identifier" &&
                node.id.name === node.init.property.name;
        }

        /**
         * Fix a node into object destructuring.
         * This function only handles the simplest case of object destructuring,
         * see {@link shouldFix}.
         * @param {SourceCodeFixer} fixer the fixer object
         * @param {ASTNode} node the node to be fixed.
         * @returns {Object} a fix for the node
         */
        function fixIntoObjectDestructuring(fixer, node) {
            const rightNode = node.init;
            const sourceCode = context.getSourceCode();

            // Don't fix if that would remove any comments. Only comments inside `rightNode.object` can be preserved.
            if (sourceCode.getCommentsInside(node).length > sourceCode.getCommentsInside(rightNode.object).length) {
                return null;
            }

            let objectText = sourceCode.getText(rightNode.object);

            if (astUtils.getPrecedence(rightNode.object) < PRECEDENCE_OF_ASSIGNMENT_EXPR) {
                objectText = `(${objectText})`;
            }

            return fixer.replaceText(
                node,
                `{${rightNode.property.name}} = ${objectText}`
            );
        }

        /**
         * Check that the `prefer-destructuring` rules are followed based on the
         * given left- and right-hand side of the assignment.
         *
         * Pulled out into a separate method so that VariableDeclarators and
         * AssignmentExpressions can share the same verification logic.
         * @param {ASTNode} leftNode the left-hand side of the assignment
         * @param {ASTNode} rightNode the right-hand side of the assignment
         * @param {ASTNode} reportNode the node to report the error on
         * @returns {void}
         */
        function performCheck(leftNode, rightNode, reportNode) {
            if (
                rightNode.type !== "MemberExpression" ||
                rightNode.object.type === "Super" ||
                rightNode.property.type === "PrivateIdentifier"
            ) {
                return;
            }

            if (isArrayIndexAccess(rightNode)) {
                if (shouldCheck(reportNode.type, "array")) {
                    report(reportNode, "array", null);
                }
                return;
            }

            const fix = shouldFix(reportNode)
                ? fixer => fixIntoObjectDestructuring(fixer, reportNode)
                : null;

            if (shouldCheck(reportNode.type, "object") && enforceForRenamedProperties) {
                report(reportNode, "object", fix);
                return;
            }

            if (shouldCheck(reportNode.type, "object")) {
                const property = rightNode.property;

                if (
                    (property.type === "Literal" && leftNode.name === property.value) ||
                    (property.type === "Identifier" && leftNode.name === property.name && !rightNode.computed)
                ) {
                    report(reportNode, "object", fix);
                }
            }
        }

        /**
         * Check if a given variable declarator is coming from an property access
         * that should be using destructuring instead
         * @param {ASTNode} node the variable declarator to check
         * @returns {void}
         */
        function checkVariableDeclarator(node) {

            // Skip if variable is declared without assignment
            if (!node.init) {
                return;
            }

            // We only care about member expressions past this point
            if (node.init.type !== "MemberExpression") {
                return;
            }

            performCheck(node.id, node.init, node);
        }

        /**
         * Run the `prefer-destructuring` check on an AssignmentExpression
         * @param {ASTNode} node the AssignmentExpression node
         * @returns {void}
         */
        function checkAssignmentExpression(node) {
            if (node.operator === "=") {
                performCheck(node.left, node.right, node);
            }
        }

        //--------------------------------------------------------------------------
        // Public
        //--------------------------------------------------------------------------

        return {
            VariableDeclarator: checkVariableDeclarator,
            AssignmentExpression: checkAssignmentExpression
        };
    }
};
