/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

import {
  InstructionId,
  makeInstructionId,
  Place,
  ReactiveBlock,
  ReactiveFunction,
  ReactiveInstruction,
  ReactiveScope,
  ScopeId,
} from '../HIR';
import DisjointSet from '../Utils/DisjointSet';
import {retainWhere} from '../Utils/utils';
import {getPlaceScope} from './BuildReactiveBlocks';
import {ReactiveFunctionVisitor, visitReactiveFunction} from './visitors';

/*
 * Note: this is the 3rd of 4 passes that determine how to break a function into discrete
 * reactive scopes (independently memoizeable units of code):
 * 1. InferReactiveScopeVariables (on HIR) determines operands that mutate together and assigns
 *     them a unique reactive scope.
 * 2. AlignReactiveScopesToBlockScopes (on ReactiveFunction) aligns reactive scopes
 *     to block scopes.
 * 3. MergeOverlappingReactiveScopes (this pass, on ReactiveFunction) ensures that reactive
 *     scopes do not overlap, merging any such scopes.
 * 4. BuildReactiveBlocks (on ReactiveFunction) groups the statements for each scope into
 *     a ReactiveScopeBlock.
 *
 * Previous passes may leave "overlapping" scopes, ie where one or more instructions are within
 * the mutable range of multiple reactive scopes. We prefer to avoid executing instructions twice
 * for performance reasons (side effects are less of a concern bc components are required to be
 * idempotent), so we cannot simply repeat the instruction once for each scope. Instead, the only
 * option is to combine the two scopes into one. This is an area where an eventual Forget IDE
 * could provide real-time feedback to the developer that two computations are accidentally merged.
 *
 * ## Detailed Walkthrough
 *
 * Two scopes overlap if there is one or more instruction that is inside the range
 * of both scopes. In general, overlapping scopes are merged togther. The only
 * exception to this is when one scope *shadows* another scope. For example:
 *
 * ```javascript
 * function foo(cond, a) {
 *                                  ⌵ scope for x
 *    let x = [];                   ⌝
 *    if (cond) {                   ⎮
 *                    ⌵ scope for y ⎮
 *      let y = [];   ⌝             ⎮
 *      if (b) {      ⎮             ⎮
 *        y.push(b);  ⌟             ⎮
 *      }                           ⎮
 *      x.push(<div>{y}</div>);     ⎮
 *    }                             ⌟
 * }
 * ```
 *
 * In this example the two scopes overlap, but mutation of the two scopes is not
 * interleaved. Specifically within the y scope there are no instructions that
 * modify any other scope: the inner scope "shadows" the outer one. This category
 * of overlap does *NOT* merge the scopes together.
 *
 * The implementation is inspired by the Rust notion of "stacked borrows". We traverse
 * the control-flow graph in tree form, at each point keeping track of which scopes are
 * active. So initially we see
 *
 * `let x = []`
 * active scopes: [x]
 *
 * and mark the x scope as active.
 *
 * Then we later encounter
 *
 * `let y = [];`
 * active scopes: [x, y]
 *
 * Here we first check to see if 'y' is already in the list of active scopes. It isn't,
 * so we push it to the stop of the stack.
 *
 * Then
 *
 * `y.push(b)`
 * active scopes: [x, y]
 *
 * Mutates y, so we check if y is the top of the stack. It is, so no merging must occur.
 *
 * If instead we saw eg
 *
 * `x.push(b)`
 * active scopes: [x, y]
 *
 * Then we would see that 'x' is active, but that it is shadowed. The two scopes would have
 * to be merged.
 */
export function mergeOverlappingReactiveScopes(fn: ReactiveFunction): void {
  const context = new Context();
  visitReactiveFunction(fn, new Visitor(), context);
  context.complete();
}

class Visitor extends ReactiveFunctionVisitor<Context> {
  override visitID(id: InstructionId, state: Context): void {
    state.visitId(id);
  }
  override visitPlace(id: InstructionId, place: Place, state: Context): void {
    state.visitPlace(id, place);
  }
  override visitLValue(id: InstructionId, lvalue: Place, state: Context): void {
    state.visitPlace(id, lvalue);
  }
  override visitBlock(block: ReactiveBlock, state: Context): void {
    state.enter(() => {
      this.traverseBlock(block, state);
    });
  }
  override visitInstruction(
    instruction: ReactiveInstruction,
    state: Context,
  ): void {
    if (
      instruction.value.kind === 'ConditionalExpression' ||
      instruction.value.kind === 'LogicalExpression' ||
      instruction.value.kind === 'OptionalExpression'
    ) {
      state.enter(() => {
        super.visitInstruction(instruction, state);
      });
    } else {
      super.visitInstruction(instruction, state);
    }
  }
}

class BlockScope {
  seen: Set<ScopeId> = new Set();
  scopes: Array<ShadowableReactiveScope> = [];
}

type ShadowableReactiveScope = {
  scope: ReactiveScope;
  shadowedBy: ReactiveScope | null;
};

class Context {
  scopes: Array<BlockScope> = [];
  seenScopes: Set<ScopeId> = new Set();
  joinedScopes: DisjointSet<ReactiveScope> = new DisjointSet();
  operandScopes: Map<Place, ReactiveScope> = new Map();

  visitId(id: InstructionId): void {
    const currentBlock = this.scopes[this.scopes.length - 1]!;
    retainWhere(currentBlock.scopes, pending => {
      if (pending.scope.range.end > id) {
        return true;
      } else {
        currentBlock.seen.delete(pending.scope.id);
        return false;
      }
    });
  }

  visitPlace(id: InstructionId, place: Place): void {
    const scope = getPlaceScope(id, place);
    if (scope === null) {
      return;
    }
    this.operandScopes.set(place, scope);
    const currentBlock = this.scopes[this.scopes.length - 1]!;
    // Fast-path for the first time we see a new scope
    if (!this.seenScopes.has(scope.id)) {
      this.seenScopes.add(scope.id);
      currentBlock.seen.add(scope.id);
      currentBlock.scopes.push({shadowedBy: null, scope});
      return;
    }
    // Scope has already been seen, find it in the current block or a parent
    let index = this.scopes.length - 1;
    let nextBlock = currentBlock;
    while (!nextBlock.seen.has(scope.id)) {
      /*
       * scopes that cross control-flow boundaries are merged with overlapping
       * scopes
       */
      this.joinedScopes.union([scope, ...nextBlock.scopes.map(s => s.scope)]);
      index--;
      if (index < 0) {
        /*
         * TODO: handle reassignments in multiple branches. these create new identifiers that
         * add an entry to this.seenScopes but which are then removed when their blocks exit.
         * this is also wrong for codegen, different versions of an identifier could be cached
         * differently and so a reassigned version of a variable needs a separate declaration.
         * console.log(`scope ${scope.id} not found`);
         */

        /*
         * for (let i = this.scopes.length - 1; i > index; i--) {
         *   const s = this.scopes[i];
         *   console.log(
         *     JSON.stringify(
         *       {
         *         seen: Array.from(s.seen),
         *         scopes: s.scopes,
         *       },
         *       null,
         *       2
         *     )
         *   );
         * }
         */
        currentBlock.seen.add(scope.id);
        currentBlock.scopes.push({shadowedBy: null, scope});
        return;
      }
      nextBlock = this.scopes[index]!;
    }

    // Handle interleaving within a given block scope
    let found = false;
    for (let i = 0; i < nextBlock.scopes.length; i++) {
      const current = nextBlock.scopes[i]!;
      if (current.scope.id === scope.id) {
        found = true;
        if (current.shadowedBy !== null) {
          this.joinedScopes.union([current.shadowedBy, current.scope]);
        }
      } else if (found && current.shadowedBy === null) {
        // `scope` is shadowing `current` and may interleave
        current.shadowedBy = scope;
        if (current.scope.range.end > scope.range.end) {
          /*
           * Current is shadowed by `scope`, and we know that `current` will mutate
           * again (per its range), so the scopes are already known to interleave.
           *
           * Eagerly extend the ranges of the scopes so that we don't prematurely end
           * a scope relative to its eventual post-merge mutable range
           */
          const end = makeInstructionId(
            Math.max(current.scope.range.end, scope.range.end),
          );
          current.scope.range.end = end;
          scope.range.end = end;
          this.joinedScopes.union([current.scope, scope]);
        }
      }
    }
    if (!currentBlock.seen.has(scope.id)) {
      currentBlock.seen.add(scope.id);
      currentBlock.scopes.push({shadowedBy: null, scope});
    }
  }

  enter(fn: () => void): void {
    this.scopes.push(new BlockScope());
    fn();
    this.scopes.pop();
  }

  complete(): void {
    this.joinedScopes.forEach((scope, groupScope) => {
      if (scope !== groupScope) {
        groupScope.range.start = makeInstructionId(
          Math.min(groupScope.range.start, scope.range.start),
        );
        groupScope.range.end = makeInstructionId(
          Math.max(groupScope.range.end, scope.range.end),
        );
      }
    });
    for (const [operand, originalScope] of this.operandScopes) {
      const mergedScope = this.joinedScopes.find(originalScope);
      if (mergedScope !== null) {
        operand.identifier.scope = mergedScope;
      }
    }
  }
}