/**
 * 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 {CompilerError} from '..';
import {
  DeclarationId,
  InstructionId,
  InstructionKind,
  Place,
  ReactiveBlock,
  ReactiveFunction,
  ReactiveScope,
  ReactiveScopeBlock,
  ReactiveScopeDependencies,
  ReactiveScopeDependency,
  ReactiveStatement,
  Type,
  areEqualPaths,
  makeInstructionId,
} from '../HIR';
import {
  BuiltInArrayId,
  BuiltInFunctionId,
  BuiltInJsxId,
  BuiltInObjectId,
} from '../HIR/ObjectShape';
import {eachInstructionLValue} from '../HIR/visitors';
import {assertExhaustive, Iterable_some} from '../Utils/utils';
import {printReactiveScopeSummary} from './PrintReactiveFunction';
import {
  ReactiveFunctionTransform,
  ReactiveFunctionVisitor,
  Transformed,
  visitReactiveFunction,
} from './visitors';

/*
 * The primary goal of this pass is to reduce memoization overhead, specifically:
 * - Use fewer memo slots
 * - Reduce the number of comparisons and other memoization-related instructions
 *
 * The algorithm merges in two main cases: consecutive scopes that invalidate together
 * or nested scopes that invalidate together
 *
 * ## Consecutive Scopes
 *
 * The idea is that if two consecutive scopes would always invalidate together,
 * it's more efficient to group the scopes together to save on memoization overhead.
 *
 * This optimization is necessarily somewhat limited. First, we only merge
 * scopes that are in the same (reactive) block, ie we don't merge across
 * control-flow or block-scoping boundaries. Second, we can only merge scopes
 * so long as any intermediate instructions are safe to memoize — specifically,
 * as long as the values created by those instructions are only referenced by
 * the second scope and not elsewhere. This is to avoid changing control-flow
 * and to avoid increasing the number of scope outputs (which defeats the optimization).
 *
 * With that in mind we can apply the optimization in two cases. Given a block with
 * scope A, some safe-to-memoize instructions I, and scope B, we can merge scopes when:
 * - A and B have identical dependencies. This means they will invalidate together, so
 *    by merging the scopes we can avoid duplicate cache slots and duplicate checks of
 *    those dependencies.
 * - The output of A is the input to B. Any invalidation of A will change its output
 *    which invalidates B, so we can similarly merge scopes. Note that this optimization
 *    may not be beneficial if the outupts of A are not guaranteed to change if its input
 *    changes, but in practice this is generally the case.
 *
 * ## Nested Scopes
 *
 * In this case, if an inner scope has the same dependencies as its parent, then we can
 * flatten away the inner scope since it will always invalidate at the same time.
 *
 * Note that PropagateScopeDependencies propagates scope dependencies upwards. This ensures
 * that parent scopes have the union of their own direct dependencies as well as those of
 * their (transitive) children. As a result nested scopes may have the same or fewer
 * dependencies than their parents, but not more dependencies. If they have fewer dependncies,
 * it means that the inner scope does not always invalidate with the parent and we should not
 * flatten. If they inner scope has the exact same dependencies, however, then it's always
 * better to flatten.
 */
export function mergeReactiveScopesThatInvalidateTogether(
  fn: ReactiveFunction,
): void {
  const lastUsageVisitor = new FindLastUsageVisitor();
  visitReactiveFunction(fn, lastUsageVisitor, undefined);
  visitReactiveFunction(fn, new Transform(lastUsageVisitor.lastUsage), null);
}

const DEBUG: boolean = false;
function log(msg: string): void {
  if (DEBUG) {
    console.log(msg);
  }
}

class FindLastUsageVisitor extends ReactiveFunctionVisitor<void> {
  /*
   * TODO LeaveSSA: use IdentifierId for more precise tracking
   * Using DeclarationId is necessary for compatible output but produces suboptimal results
   * in cases where a scope defines a variable, but that version is never read and always
   * overwritten later.
   * see reassignment-separate-scopes.js for example
   */
  lastUsage: Map<DeclarationId, InstructionId> = new Map();

  override visitPlace(id: InstructionId, place: Place, _state: void): void {
    const previousUsage = this.lastUsage.get(place.identifier.declarationId);
    const lastUsage =
      previousUsage !== undefined
        ? makeInstructionId(Math.max(previousUsage, id))
        : id;
    this.lastUsage.set(place.identifier.declarationId, lastUsage);
  }
}

class Transform extends ReactiveFunctionTransform<ReactiveScopeDependencies | null> {
  lastUsage: Map<DeclarationId, InstructionId>;

  constructor(lastUsage: Map<DeclarationId, InstructionId>) {
    super();
    this.lastUsage = lastUsage;
  }

  override transformScope(
    scopeBlock: ReactiveScopeBlock,
    state: ReactiveScopeDependencies | null,
  ): Transformed<ReactiveStatement> {
    this.visitScope(scopeBlock, scopeBlock.scope.dependencies);
    if (
      state !== null &&
      areEqualDependencies(state, scopeBlock.scope.dependencies)
    ) {
      return {kind: 'replace-many', value: scopeBlock.instructions};
    } else {
      return {kind: 'keep'};
    }
  }

  override visitBlock(
    block: ReactiveBlock,
    state: ReactiveScopeDependencies | null,
  ): void {
    // Pass 1: visit nested blocks to potentially merge their scopes
    this.traverseBlock(block, state);

    // Pass 2: identify scopes for merging
    type MergedScope = {
      block: ReactiveScopeBlock;
      from: number;
      to: number;
      lvalues: Set<DeclarationId>;
    };
    let current: MergedScope | null = null;
    const merged: Array<MergedScope> = [];
    function reset(): void {
      CompilerError.invariant(current !== null, {
        loc: null,
        reason:
          'MergeConsecutiveScopes: expected current scope to be non-null if reset()',
        suggestions: null,
        description: null,
      });
      if (current.to > current.from + 1) {
        merged.push(current);
      }
      current = null;
    }
    for (let i = 0; i < block.length; i++) {
      const instr = block[i]!;
      switch (instr.kind) {
        case 'terminal': {
          // For now we don't merge across terminals
          if (current !== null) {
            log(
              `Reset scope @${current.block.scope.id} from terminal [${instr.terminal.id}]`,
            );
            reset();
          }
          break;
        }
        case 'pruned-scope': {
          // For now we don't merge across pruned scopes
          if (current !== null) {
            log(
              `Reset scope @${current.block.scope.id} from pruned scope @${instr.scope.id}`,
            );
            reset();
          }
          break;
        }
        case 'instruction': {
          switch (instr.instruction.value.kind) {
            case 'BinaryExpression':
            case 'ComputedLoad':
            case 'JSXText':
            case 'LoadGlobal':
            case 'LoadLocal':
            case 'Primitive':
            case 'PropertyLoad':
            case 'TemplateLiteral':
            case 'UnaryExpression': {
              /*
               * We can merge two scopes if there are intervening instructions, but:
               * - Only if the instructions are simple and it's okay to make them
               *   execute conditionally (hence allowing a conservative subset of value kinds)
               * - The values produced are used at or before the next scope. If they are used
               *   later and we move them into the scope, then they wouldn't be accessible to
               *   subsequent code wo expanding the set of declarations, which we want to avoid
               */
              if (current !== null && instr.instruction.lvalue !== null) {
                current.lvalues.add(
                  instr.instruction.lvalue.identifier.declarationId,
                );
              }
              break;
            }
            case 'StoreLocal': {
              /**
               * It's safe to have intervening StoreLocal instructions _if_ they are const
               * and the last usage of the variable is at or before the next scope. This is
               * similar to the case above for simple instructions.
               *
               * Reassignments are *not* safe to merge since they are a side-effect that we
               * don't want to make conditional.
               */
              if (current !== null) {
                if (
                  instr.instruction.value.lvalue.kind === InstructionKind.Const
                ) {
                  for (const lvalue of eachInstructionLValue(
                    instr.instruction,
                  )) {
                    current.lvalues.add(lvalue.identifier.declarationId);
                  }
                } else {
                  log(
                    `Reset scope @${current.block.scope.id} from StoreLocal in [${instr.instruction.id}]`,
                  );
                  reset();
                }
              }
              break;
            }
            default: {
              // Other instructions are known to prevent merging, so we reset the scope if present
              if (current !== null) {
                log(
                  `Reset scope @${current.block.scope.id} from instruction [${instr.instruction.id}]`,
                );
                reset();
              }
            }
          }
          break;
        }
        case 'scope': {
          if (
            current !== null &&
            canMergeScopes(current.block, instr) &&
            areLValuesLastUsedByScope(
              instr.scope,
              current.lvalues,
              this.lastUsage,
            )
          ) {
            // The current and next scopes can merge!
            log(
              `Can merge scope @${current.block.scope.id} with @${instr.scope.id}`,
            );
            // Update the merged scope's range
            current.block.scope.range.end = makeInstructionId(
              Math.max(current.block.scope.range.end, instr.scope.range.end),
            );
            // Add declarations
            for (const [key, value] of instr.scope.declarations) {
              current.block.scope.declarations.set(key, value);
            }
            /*
             * Then prune declarations - this removes declarations from the earlier
             * scope that are last-used at or before the newly merged subsequent scope
             */
            updateScopeDeclarations(current.block.scope, this.lastUsage);
            current.to = i + 1;
            /*
             * We already checked that intermediate values were used at-or-before the merged
             * scoped, so we can reset
             */
            current.lvalues.clear();

            if (!scopeIsEligibleForMerging(instr)) {
              /*
               * The subsequent scope that we just merged isn't guaranteed to invalidate if its
               * inputs change, so it is not a candidate for future merging
               */
              log(
                `  but scope @${instr.scope.id} doesnt guaranteed invalidate so it cannot merge further`,
              );
              reset();
            }
          } else {
            // No previous scope, or the scope cannot merge
            if (current !== null) {
              // Reset if necessary
              log(
                `Reset scope @${current.block.scope.id}, not mergeable with subsequent scope @${instr.scope.id}`,
              );
              reset();
            }
            // Only set a new merge candidate if the scope is guaranteed to invalidate on changes
            if (scopeIsEligibleForMerging(instr)) {
              current = {
                block: instr,
                from: i,
                to: i + 1,
                lvalues: new Set(),
              };
            } else {
              log(
                `scope @${instr.scope.id} doesnt guaranteed invalidate so it cannot merge further`,
              );
            }
          }
          break;
        }
        default: {
          assertExhaustive(
            instr,
            `Unexpected instruction kind \`${(instr as any).kind}\``,
          );
        }
      }
    }
    if (current !== null) {
      reset();
    }
    if (merged.length) {
      log(`merged ${merged.length} scopes:`);
      for (const entry of merged) {
        log(
          printReactiveScopeSummary(entry.block.scope) +
            ` from=${entry.from} to=${entry.to}`,
        );
      }
    }

    // Pass 3: optional: if scopes can be merged, merge them and update the block
    if (merged.length === 0) {
      // Nothing merged, nothing to do!
      return;
    }
    const nextInstructions = [];
    let index = 0;
    for (const entry of merged) {
      if (index < entry.from) {
        nextInstructions.push(...block.slice(index, entry.from));
        index = entry.from;
      }
      const mergedScope = block[entry.from]!;
      CompilerError.invariant(mergedScope.kind === 'scope', {
        loc: null,
        reason:
          'MergeConsecutiveScopes: Expected scope starting index to be a scope',
        description: null,
        suggestions: null,
      });
      nextInstructions.push(mergedScope);
      index++;
      while (index < entry.to) {
        const instr = block[index++]!;
        if (instr.kind === 'scope') {
          mergedScope.instructions.push(...instr.instructions);
          mergedScope.scope.merged.add(instr.scope.id);
        } else {
          mergedScope.instructions.push(instr);
        }
      }
    }
    while (index < block.length) {
      nextInstructions.push(block[index++]!);
    }
    block.length = 0;
    block.push(...nextInstructions);
  }
}

/*
 * Updates @param scope's declarations to remove any declarations that are not
 * used after the scope, based on the scope's updated range post-merging.
 */
function updateScopeDeclarations(
  scope: ReactiveScope,
  lastUsage: Map<DeclarationId, InstructionId>,
): void {
  for (const [id, decl] of scope.declarations) {
    const lastUsedAt = lastUsage.get(decl.identifier.declarationId)!;
    if (lastUsedAt < scope.range.end) {
      scope.declarations.delete(id);
    }
  }
}

/*
 * Returns whether the given @param scope is the last usage of all
 * the given @param lvalues. Returns false if any of the lvalues
 * are used again after the scope.
 */
function areLValuesLastUsedByScope(
  scope: ReactiveScope,
  lvalues: Set<DeclarationId>,
  lastUsage: Map<DeclarationId, InstructionId>,
): boolean {
  for (const lvalue of lvalues) {
    const lastUsedAt = lastUsage.get(lvalue)!;
    if (lastUsedAt >= scope.range.end) {
      log(`  lvalue ${lvalue} used after scope @${scope.id}, cannot merge`);
      return false;
    }
  }
  return true;
}

function canMergeScopes(
  current: ReactiveScopeBlock,
  next: ReactiveScopeBlock,
): boolean {
  // Don't merge scopes with reassignments
  if (
    current.scope.reassignments.size !== 0 ||
    next.scope.reassignments.size !== 0
  ) {
    log(`  cannot merge, has reassignments`);
    return false;
  }
  // Merge scopes whose dependencies are identical
  if (
    areEqualDependencies(current.scope.dependencies, next.scope.dependencies)
  ) {
    log(`  canMergeScopes: dependencies are equal`);
    return true;
  }
  /*
   * Merge scopes where the outputs of the previous scope are the inputs
   * of the subsequent scope. Note that the output of a scope is not
   * guaranteed to change when its inputs change, for example `foo(x)`
   * may not change when `x` changes, for example `foo(x) { return x < 10}`
   * will not change as x changes from 0 -> 1.
   * Therefore we check that the outputs of the previous scope are of a type
   * that is guaranteed to invalidate with its inputs, and only merge in this case.
   */
  if (
    areEqualDependencies(
      new Set(
        [...current.scope.declarations.values()].map(declaration => ({
          identifier: declaration.identifier,
          reactive: true,
          path: [],
        })),
      ),
      next.scope.dependencies,
    ) ||
    (next.scope.dependencies.size !== 0 &&
      [...next.scope.dependencies].every(
        dep =>
          isAlwaysInvalidatingType(dep.identifier.type) &&
          Iterable_some(
            current.scope.declarations.values(),
            decl =>
              decl.identifier.declarationId === dep.identifier.declarationId,
          ),
      ))
  ) {
    log(`  outputs of prev are input to current`);
    return true;
  }
  log(`  cannot merge scopes:`);
  log(`  ${printReactiveScopeSummary(current.scope)}`);
  log(`  ${printReactiveScopeSummary(next.scope)}`);
  return false;
}

function isAlwaysInvalidatingType(type: Type): boolean {
  switch (type.kind) {
    case 'Object': {
      switch (type.shapeId) {
        case BuiltInArrayId:
        case BuiltInObjectId:
        case BuiltInFunctionId:
        case BuiltInJsxId: {
          return true;
        }
      }
      break;
    }
    case 'Function': {
      return true;
    }
  }
  return false;
}

function areEqualDependencies(
  a: Set<ReactiveScopeDependency>,
  b: Set<ReactiveScopeDependency>,
): boolean {
  if (a.size !== b.size) {
    return false;
  }
  for (const aValue of a) {
    let found = false;
    for (const bValue of b) {
      if (
        aValue.identifier.declarationId === bValue.identifier.declarationId &&
        areEqualPaths(aValue.path, bValue.path)
      ) {
        found = true;
        break;
      }
    }
    if (!found) {
      return false;
    }
  }
  return true;
}

/**
 * Is this scope eligible for merging with subsequent scopes? In general this
 * is only true if the scope's output values are guaranteed to change when its
 * input changes. When the output may not change, it's better to avoid merging
 * with subsequent scopes so that they can compare the input and avoid updating
 * when there are no changes.
 *
 * A special-case is if the scope has no dependencies, then its output will
 * *never* change and it's also eligible for merging.
 */
function scopeIsEligibleForMerging(scopeBlock: ReactiveScopeBlock): boolean {
  if (scopeBlock.scope.dependencies.size === 0) {
    /*
     * Regardless of the type of value produced, if the scope has no dependencies
     * then its value will never change.
     */
    return true;
  }
  return [...scopeBlock.scope.declarations].some(([, decl]) =>
    isAlwaysInvalidatingType(decl.identifier.type),
  );
}