/**
 * @fileoverview disallow assignments that can lead to race conditions due to usage of `await` or `yield`
 * @author Teddy Katz
 */
"use strict";

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

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

module.exports = {
    meta: {
        docs: {
            description: "disallow assignments that can lead to race conditions due to usage of `await` or `yield`",
            category: "Possible Errors",
            recommended: false,
            url: "https://eslint.org/docs/rules/require-atomic-updates"
        },
        fixable: null,
        schema: [],
        messages: {
            nonAtomicUpdate: "Possible race condition: `{{value}}` might be reassigned based on an outdated value of `{{value}}`."
        }
    },

    create(context) {
        const sourceCode = context.getSourceCode();
        const identifierToSurroundingFunctionMap = new WeakMap();
        const expressionsByCodePathSegment = new Map();

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

        const resolvedVariableCache = new WeakMap();

        /**
         * Gets the variable scope around this variable reference
         * @param {ASTNode} identifier An `Identifier` AST node
         * @returns {Scope|null} An escope Scope
         */
        function getScope(identifier) {
            for (let currentNode = identifier; currentNode; currentNode = currentNode.parent) {
                const scope = sourceCode.scopeManager.acquire(currentNode, true);

                if (scope) {
                    return scope;
                }
            }
            return null;
        }

        /**
         * Resolves a given identifier to a given scope
         * @param {ASTNode} identifier An `Identifier` AST node
         * @param {Scope} scope An escope Scope
         * @returns {Variable|null} An escope Variable corresponding to the given identifier
         */
        function resolveVariableInScope(identifier, scope) {
            return scope.variables.find(variable => variable.name === identifier.name) ||
                (scope.upper ? resolveVariableInScope(identifier, scope.upper) : null);
        }

        /**
         * Resolves an identifier to a variable
         * @param {ASTNode} identifier An identifier node
         * @returns {Variable|null} The escope Variable that uses this identifier
         */
        function resolveVariable(identifier) {
            if (!resolvedVariableCache.has(identifier)) {
                const surroundingScope = getScope(identifier);

                if (surroundingScope) {
                    resolvedVariableCache.set(identifier, resolveVariableInScope(identifier, surroundingScope));
                } else {
                    resolvedVariableCache.set(identifier, null);
                }
            }

            return resolvedVariableCache.get(identifier);
        }

        /**
         * Checks if an expression is a variable that can only be observed within the given function.
         * @param {ASTNode} expression The expression to check
         * @param {ASTNode} surroundingFunction The function node
         * @returns {boolean} `true` if the expression is a variable which is local to the given function, and is never
         * referenced in a closure.
         */
        function isLocalVariableWithoutEscape(expression, surroundingFunction) {
            if (expression.type !== "Identifier") {
                return false;
            }

            const variable = resolveVariable(expression);

            if (!variable) {
                return false;
            }

            return variable.references.every(reference => identifierToSurroundingFunctionMap.get(reference.identifier) === surroundingFunction) &&
                variable.defs.every(def => identifierToSurroundingFunctionMap.get(def.name) === surroundingFunction);
        }

        /**
         * Reports an AssignmentExpression node that has a non-atomic update
         * @param {ASTNode} assignmentExpression The assignment that is potentially unsafe
         * @returns {void}
         */
        function reportAssignment(assignmentExpression) {
            context.report({
                node: assignmentExpression,
                messageId: "nonAtomicUpdate",
                data: {
                    value: sourceCode.getText(assignmentExpression.left)
                }
            });
        }

        const alreadyReportedAssignments = new WeakSet();

        class AssignmentTrackerState {
            constructor({ openAssignmentsWithoutReads = new Set(), openAssignmentsWithReads = new Set() } = {}) {
                this.openAssignmentsWithoutReads = openAssignmentsWithoutReads;
                this.openAssignmentsWithReads = openAssignmentsWithReads;
            }

            copy() {
                return new AssignmentTrackerState({
                    openAssignmentsWithoutReads: new Set(this.openAssignmentsWithoutReads),
                    openAssignmentsWithReads: new Set(this.openAssignmentsWithReads)
                });
            }

            merge(other) {
                const initialAssignmentsWithoutReadsCount = this.openAssignmentsWithoutReads.size;
                const initialAssignmentsWithReadsCount = this.openAssignmentsWithReads.size;

                other.openAssignmentsWithoutReads.forEach(assignment => this.openAssignmentsWithoutReads.add(assignment));
                other.openAssignmentsWithReads.forEach(assignment => this.openAssignmentsWithReads.add(assignment));

                return this.openAssignmentsWithoutReads.size > initialAssignmentsWithoutReadsCount ||
                    this.openAssignmentsWithReads.size > initialAssignmentsWithReadsCount;
            }

            enterAssignment(assignmentExpression) {
                (assignmentExpression.operator === "=" ? this.openAssignmentsWithoutReads : this.openAssignmentsWithReads).add(assignmentExpression);
            }

            exitAssignment(assignmentExpression) {
                this.openAssignmentsWithoutReads.delete(assignmentExpression);
                this.openAssignmentsWithReads.delete(assignmentExpression);
            }

            exitAwaitOrYield(node, surroundingFunction) {
                return [...this.openAssignmentsWithReads]
                    .filter(assignment => !isLocalVariableWithoutEscape(assignment.left, surroundingFunction))
                    .forEach(assignment => {
                        if (!alreadyReportedAssignments.has(assignment)) {
                            reportAssignment(assignment);
                            alreadyReportedAssignments.add(assignment);
                        }
                    });
            }

            exitIdentifierOrMemberExpression(node) {
                [...this.openAssignmentsWithoutReads]
                    .filter(assignment => (
                        assignment.left !== node &&
                        assignment.left.type === node.type &&
                        astUtils.equalTokens(assignment.left, node, sourceCode)
                    ))
                    .forEach(assignment => {
                        this.openAssignmentsWithoutReads.delete(assignment);
                        this.openAssignmentsWithReads.add(assignment);
                    });
            }
        }

        /**
         * If the control flow graph of a function enters an assignment expression, then does the
         * both of the following steps in order (possibly with other steps in between) before exiting the
         * assignment expression, then the assignment might be using an outdated value.
         * 1. Enters a read of the variable or property assigned in the expression (not necessary if operator assignment is used)
         * 2. Exits an `await` or `yield` expression
         *
         * This function checks for the outdated values and reports them.
         * @param {CodePathSegment} codePathSegment The current code path segment to traverse
         * @param {ASTNode} surroundingFunction The function node containing the code path segment
         * @returns {void}
         */
        function findOutdatedReads(
            codePathSegment,
            surroundingFunction,
            {
                stateBySegmentStart = new WeakMap(),
                stateBySegmentEnd = new WeakMap()
            } = {}
        ) {
            if (!stateBySegmentStart.has(codePathSegment)) {
                stateBySegmentStart.set(codePathSegment, new AssignmentTrackerState());
            }

            const currentState = stateBySegmentStart.get(codePathSegment).copy();

            expressionsByCodePathSegment.get(codePathSegment).forEach(({ entering, node }) => {
                if (node.type === "AssignmentExpression") {
                    if (entering) {
                        currentState.enterAssignment(node);
                    } else {
                        currentState.exitAssignment(node);
                    }
                } else if (!entering && (node.type === "AwaitExpression" || node.type === "YieldExpression")) {
                    currentState.exitAwaitOrYield(node, surroundingFunction);
                } else if (!entering && (node.type === "Identifier" || node.type === "MemberExpression")) {
                    currentState.exitIdentifierOrMemberExpression(node);
                }
            });

            stateBySegmentEnd.set(codePathSegment, currentState);

            codePathSegment.nextSegments.forEach(nextSegment => {
                if (stateBySegmentStart.has(nextSegment)) {
                    if (!stateBySegmentStart.get(nextSegment).merge(currentState)) {

                        /*
                         * This segment has already been processed with the given set of inputs;
                         * no need to do it again. After no new state is available to process
                         * for any control flow segment in the graph, the analysis reaches a fixpoint and
                         * traversal stops.
                         */
                        return;
                    }
                } else {
                    stateBySegmentStart.set(nextSegment, currentState.copy());
                }
                findOutdatedReads(
                    nextSegment,
                    surroundingFunction,
                    { stateBySegmentStart, stateBySegmentEnd }
                );
            });
        }

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

        const currentCodePathSegmentStack = [];
        let currentCodePathSegment = null;
        const functionStack = [];

        return {
            onCodePathStart() {
                currentCodePathSegmentStack.push(currentCodePathSegment);
            },

            onCodePathEnd(codePath, node) {
                currentCodePathSegment = currentCodePathSegmentStack.pop();

                if (astUtils.isFunction(node) && (node.async || node.generator)) {
                    findOutdatedReads(codePath.initialSegment, node);
                }
            },

            onCodePathSegmentStart(segment) {
                currentCodePathSegment = segment;
                expressionsByCodePathSegment.set(segment, []);
            },

            "AssignmentExpression, Identifier, MemberExpression, AwaitExpression, YieldExpression"(node) {
                expressionsByCodePathSegment.get(currentCodePathSegment).push({ entering: true, node });
            },

            "AssignmentExpression, Identifier, MemberExpression, AwaitExpression, YieldExpression:exit"(node) {
                expressionsByCodePathSegment.get(currentCodePathSegment).push({ entering: false, node });
            },

            ":function"(node) {
                functionStack.push(node);
            },

            ":function:exit"() {
                functionStack.pop();
            },

            Identifier(node) {
                if (functionStack.length) {
                    identifierToSurroundingFunctionMap.set(node, functionStack[functionStack.length - 1]);
                }
            }
        };
    }
};
