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

import * as Acorn from '../../third_party/acorn/acorn.js';

import {ECMA_VERSION} from './AcornTokenizer.js';
import {DefinitionKind} from './FormatterActions.js';
import {ScopeVariableAnalysis} from './ScopeParser.js';

export function substituteExpression(expression: string, nameMap: Map<string, string|null>): string {
  const replacements = computeSubstitution(expression, nameMap);
  return applySubstitution(expression, replacements);
}

interface Replacement {
  from: string;
  to: string;
  offset: number;
  isShorthandAssignmentProperty: boolean;
}

/**
 * Given an |expression| and a mapping from names to new names, the |computeSubstitution|
 * function returns a list of replacements sorted by the offset. The function throws if
 * it cannot parse the expression or the substitution is impossible to perform (for example
 * if the substitution target is 'this' within a function, it would become bound there).
 **/
function computeSubstitution(expression: string, nameMap: Map<string, string|null>): Replacement[] {
  // Parse the expression and find variables and scopes.
  const root = Acorn.parse(expression, {
    ecmaVersion: ECMA_VERSION,
    allowAwaitOutsideFunction: true,
    allowImportExportEverywhere: true,
    checkPrivateFields: false,
    ranges: false,
  } as acorn.Options) as Acorn.ESTree.Node;
  const scopeVariables = new ScopeVariableAnalysis(root, expression);
  scopeVariables.run();
  const freeVariables = scopeVariables.getFreeVariables();
  const result: Replacement[] = [];

  // Prepare the machinery for generating fresh names (to avoid variable captures).
  const allNames = scopeVariables.getAllNames();
  for (const rename of nameMap.values()) {
    if (rename) {
      allNames.add(rename);
    }
  }
  function getNewName(base: string): string {
    let i = 1;
    while (allNames.has(`${base}_${i}`)) {
      i++;
    }
    const newName = `${base}_${i}`;
    allNames.add(newName);
    return newName;
  }

  // Perform the substitutions.
  for (const [name, rename] of nameMap.entries()) {
    const defUse = freeVariables.get(name);
    if (!defUse) {
      continue;
    }

    if (rename === null) {
      throw new Error(`Cannot substitute '${name}' as the underlying variable '${rename}' is unavailable`);
    }

    const binders = [];
    for (const use of defUse) {
      result.push({
        from: name,
        to: rename,
        offset: use.offset,
        isShorthandAssignmentProperty: use.isShorthandAssignmentProperty,
      });
      binders.push(...use.scope.findBinders(rename));
    }
    // If there is a capturing binder, rename the bound variable.
    for (const binder of binders) {
      if (binder.definitionKind === DefinitionKind.FIXED) {
        // If the identifier is bound to a fixed name, such as 'this',
        // then refuse to do the substitution.
        throw new Error(`Cannot avoid capture of '${rename}'`);
      }
      const newName = getNewName(rename);
      for (const use of binder.uses) {
        result.push({
          from: rename,
          to: newName,
          offset: use.offset,
          isShorthandAssignmentProperty: use.isShorthandAssignmentProperty,
        });
      }
    }
  }
  result.sort((l, r) => l.offset - r.offset);
  return result;
}

function applySubstitution(expression: string, replacements: Replacement[]): string {
  const accumulator = [];
  let last = 0;
  for (const r of replacements) {
    accumulator.push(expression.slice(last, r.offset));
    let replacement = r.to;
    if (r.isShorthandAssignmentProperty) {
      // Let us expand the shorthand to full assignment.
      replacement = `${r.from}: ${r.to}`;
    }
    accumulator.push(replacement);
    last = r.offset + r.from.length;
  }
  accumulator.push(expression.slice(last));
  return accumulator.join('');
}
