// Copyright 2023 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import type {TSESTree} from '@typescript-eslint/utils';

import {createRule} from './utils/ruleCreator.ts';

type Node = TSESTree.Node;
type MemberExpression = TSESTree.MemberExpression;
type BlockStatement = TSESTree.BlockStatement;
type ForStatement = TSESTree.ForStatement;
type ForInStatement = TSESTree.ForInStatement;
type ForOfStatement = TSESTree.ForOfStatement;
type SwitchStatement = TSESTree.SwitchStatement;
type CatchClause = TSESTree.CatchClause;
type StaticBlock = TSESTree.StaticBlock;

type CanvasCall = 'save'|'restore';

export default createRule({
  name: 'canvas-context-tracking',
  meta: {
    type: 'problem',
    docs: {
      description: 'Track context.save() and context.restores() across scopes',
      category: 'Possible Errors',
    },
    fixable: 'code',
    messages: {
      saveNotRestored: 'Found a block that has more context.save() calls than context.restore() calls',
      uselessRestore: 'Found a context.restore() call with no context.save() prior to it',
    },
    schema: [],  // no options
  },
  defaultOptions: [],
  create: function(context) {
    // To track canvas calls across scopes we keep a stack which we push nodes on with every new scope that we find.
    // When we then leave a scope, we can check all the calls in that scope and see if they align or not.
    let stack: Node[] = [];

    // The key is a node's range as a string. The value is a stack of
    // context.save calls. When we see a restore, we pop the stack. Therefore,
    // if we get to the end of a scope and the stack is not empty, it means the
    // user has not balanced their calls correctly.
    const scopeToCanvasCalls = new Map<string, CanvasCall[]>();

    function nodeToKeyForMap(node: Node): string {
      return JSON.stringify(node.range);
    }

    function enterScope(node: Node): void {
      stack.push(node);
    }

    /**
     * Pops the last block scope and checks it for a mismatch of save and restore calls.
     */
    function exitScope(): void {
      const lastScope = stack.pop();
      if (!lastScope) {
        return;
      }

      const stackForCurrentScope = scopeToCanvasCalls.get(
          nodeToKeyForMap(lastScope),
      );

      // We have no issues to report if:
      // 1. No calls to save() or restore().
      // 2. The amount of save() and restore() calls balanced perfectly, leaving the stack empty.
      if (!stackForCurrentScope || stackForCurrentScope.length === 0) {
        return;
      }

      // If we got here it means the stack for the scope has items in, which means that it is unbalanced.
      context.report({
        node: lastScope,
        messageId: 'saveNotRestored',
      });
    }

    /**
     * Updates the counter for the current scope.
     * @param {'save'|'restore'} methodName
     **/
    function trackContextCall(methodName: CanvasCall): void {
      const currentScopeNode = stack.at(-1);
      if (!currentScopeNode) {
        return;
      }
      const currentScopeKey = nodeToKeyForMap(currentScopeNode);
      const stackForCurrentScope = scopeToCanvasCalls.get(currentScopeKey) || [];

      if (methodName === 'save') {
        stackForCurrentScope.push('save');
      } else if (methodName === 'restore') {
        // If we get a restore() call but the stack is empty, this means that
        // we have nothing to restore as we did not save anything in this
        // scope. Either the user has forgotten a save() call, or this
        // restore() has been accidentally left behind after a refactor and
        // should be removed.
        if (stackForCurrentScope.length === 0) {
          context.report({
            messageId: 'uselessRestore',
            // Report on the specific call if possible, otherwise the scope.
            // The original code reported on currentScopeNode.
            node: currentScopeNode,
          });
        } else {
          // Pop the stack, so that the last save() is accounted for.
          stackForCurrentScope.pop();
        }
      }

      scopeToCanvasCalls.set(
          currentScopeKey,
          stackForCurrentScope,
      );
    }

    return {
      Program(node) {
        stack = [node];
        // Initialize map for the program scope
        scopeToCanvasCalls.set(nodeToKeyForMap(node), []);
      },
      MemberExpression(node: MemberExpression) {
        const methodCallsToTrack = ['save', 'restore'];
        if (node.object.type === 'Identifier' && node.object?.name === 'context' &&
            node.property.type === 'Identifier' &&
            // Use type assertion because .includes doesn't narrow the type
            methodCallsToTrack.includes(node.property?.name as CanvasCall)) {
          trackContextCall(node.property.name as CanvasCall);
        }
      },
      // All the different types of scope we have to deal with.
      BlockStatement: (node: BlockStatement) => enterScope(node),
      'BlockStatement:exit': exitScope,
      ForStatement: (node: ForStatement) => enterScope(node),
      'ForStatement:exit': exitScope,
      ForInStatement: (node: ForInStatement) => enterScope(node),
      'ForInStatement:exit': exitScope,
      ForOfStatement: (node: ForOfStatement) => enterScope(node),
      'ForOfStatement:exit': exitScope,
      SwitchStatement: (node: SwitchStatement) => enterScope(node),
      'SwitchStatement:exit': exitScope,
      CatchClause: (node: CatchClause) => enterScope(node),
      'CatchClause:exit': exitScope,
      StaticBlock: (node: StaticBlock) => enterScope(node),
      'StaticBlock:exit': exitScope,
    };
  },
});
