/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.io/license
 */

import * as ts from 'typescript';

import {HelperFunction} from './helpers';
import {findImportSpecifier} from './util';

/** A call expression that is based on a property access. */
type PropertyAccessCallExpression = ts.CallExpression&{expression: ts.PropertyAccessExpression};

/** Replaces an import inside an import statement with a different one. */
export function replaceImport(node: ts.NamedImports, oldImport: string, newImport: string) {
  const isAlreadyImported = findImportSpecifier(node.elements, newImport);

  if (isAlreadyImported) {
    return node;
  }

  const existingImport = findImportSpecifier(node.elements, oldImport);

  if (!existingImport) {
    throw new Error(`Could not find an import to replace using ${oldImport}.`);
  }

  return ts.updateNamedImports(node, [
    ...node.elements.filter(current => current !== existingImport),
    // Create a new import while trying to preserve the alias of the old one.
    ts.createImportSpecifier(
        existingImport.propertyName ? ts.createIdentifier(newImport) : undefined,
        existingImport.propertyName ? existingImport.name : ts.createIdentifier(newImport))
  ]);
}

/**
 * Migrates a function call expression from `Renderer` to `Renderer2`.
 * Returns null if the expression should be dropped.
 */
export function migrateExpression(node: ts.CallExpression, typeChecker: ts.TypeChecker):
    {node: ts.Node|null, requiredHelpers?: HelperFunction[]} {
  if (isPropertyAccessCallExpression(node)) {
    switch (node.expression.name.getText()) {
      case 'setElementProperty':
        return {node: renameMethodCall(node, 'setProperty')};
      case 'setText':
        return {node: renameMethodCall(node, 'setValue')};
      case 'listenGlobal':
        return {node: renameMethodCall(node, 'listen')};
      case 'selectRootElement':
        return {node: migrateSelectRootElement(node)};
      case 'setElementClass':
        return {node: migrateSetElementClass(node)};
      case 'setElementStyle':
        return {node: migrateSetElementStyle(node, typeChecker)};
      case 'invokeElementMethod':
        return {node: migrateInvokeElementMethod(node)};
      case 'setBindingDebugInfo':
        return {node: null};
      case 'createViewRoot':
        return {node: migrateCreateViewRoot(node)};
      case 'setElementAttribute':
        return {
          node: switchToHelperCall(node, HelperFunction.setElementAttribute, node.arguments),
          requiredHelpers: [
            HelperFunction.any, HelperFunction.splitNamespace, HelperFunction.setElementAttribute
          ]
        };
      case 'createElement':
        return {
          node: switchToHelperCall(node, HelperFunction.createElement, node.arguments.slice(0, 2)),
          requiredHelpers:
              [HelperFunction.any, HelperFunction.splitNamespace, HelperFunction.createElement]
        };
      case 'createText':
        return {
          node: switchToHelperCall(node, HelperFunction.createText, node.arguments.slice(0, 2)),
          requiredHelpers: [HelperFunction.any, HelperFunction.createText]
        };
      case 'createTemplateAnchor':
        return {
          node: switchToHelperCall(
              node, HelperFunction.createTemplateAnchor, node.arguments.slice(0, 1)),
          requiredHelpers: [HelperFunction.any, HelperFunction.createTemplateAnchor]
        };
      case 'projectNodes':
        return {
          node: switchToHelperCall(node, HelperFunction.projectNodes, node.arguments),
          requiredHelpers: [HelperFunction.any, HelperFunction.projectNodes]
        };
      case 'animate':
        return {
          node: migrateAnimateCall(),
          requiredHelpers: [HelperFunction.any, HelperFunction.animate]
        };
      case 'destroyView':
        return {
          node: switchToHelperCall(node, HelperFunction.destroyView, [node.arguments[1]]),
          requiredHelpers: [HelperFunction.any, HelperFunction.destroyView]
        };
      case 'detachView':
        return {
          node: switchToHelperCall(node, HelperFunction.detachView, [node.arguments[0]]),
          requiredHelpers: [HelperFunction.any, HelperFunction.detachView]
        };
      case 'attachViewAfter':
        return {
          node: switchToHelperCall(node, HelperFunction.attachViewAfter, node.arguments),
          requiredHelpers: [HelperFunction.any, HelperFunction.attachViewAfter]
        };
    }
  }

  return {node};
}

/** Checks whether a node is a PropertyAccessExpression. */
function isPropertyAccessCallExpression(node: ts.Node): node is PropertyAccessCallExpression {
  return ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression);
}

/** Renames a method call while keeping all of the parameters in place. */
function renameMethodCall(node: PropertyAccessCallExpression, newName: string): ts.CallExpression {
  const newExpression = ts.updatePropertyAccess(
      node.expression, node.expression.expression, ts.createIdentifier(newName));

  return ts.updateCall(node, newExpression, node.typeArguments, node.arguments);
}

/**
 * Migrates a `selectRootElement` call by removing the last argument which is no longer supported.
 */
function migrateSelectRootElement(node: ts.CallExpression): ts.Node {
  // The only thing we need to do is to drop the last argument
  // (`debugInfo`), if the consumer was passing it in.
  if (node.arguments.length > 1) {
    return ts.updateCall(node, node.expression, node.typeArguments, [node.arguments[0]]);
  }

  return node;
}

/**
 * Migrates a call to `setElementClass` either to a call to `addClass` or `removeClass`, or
 * to an expression like `isAdd ? addClass(el, className) : removeClass(el, className)`.
 */
function migrateSetElementClass(node: PropertyAccessCallExpression): ts.Node {
  // Clone so we don't mutate by accident. Note that we assume that
  // the user's code is providing all three required arguments.
  const outputMethodArgs = node.arguments.slice();
  const isAddArgument = outputMethodArgs.pop()!;
  const createRendererCall = (isAdd: boolean) => {
    const innerExpression = node.expression.expression;
    const topExpression =
        ts.createPropertyAccess(innerExpression, isAdd ? 'addClass' : 'removeClass');
    return ts.createCall(topExpression, [], node.arguments.slice(0, 2));
  };

  // If the call has the `isAdd` argument as a literal boolean, we can map it directly to
  // `addClass` or `removeClass`. Note that we can't use the type checker here, because it
  // won't tell us whether the value resolves to true or false.
  if (isAddArgument.kind === ts.SyntaxKind.TrueKeyword ||
      isAddArgument.kind === ts.SyntaxKind.FalseKeyword) {
    return createRendererCall(isAddArgument.kind === ts.SyntaxKind.TrueKeyword);
  }

  // Otherwise create a ternary on the variable.
  return ts.createConditional(isAddArgument, createRendererCall(true), createRendererCall(false));
}

/**
 * Migrates a call to `setElementStyle` call either to a call to
 * `setStyle` or `removeStyle`. or to an expression like
 * `value == null ? removeStyle(el, key) : setStyle(el, key, value)`.
 */
function migrateSetElementStyle(
    node: PropertyAccessCallExpression, typeChecker: ts.TypeChecker): ts.Node {
  const args = node.arguments;
  const addMethodName = 'setStyle';
  const removeMethodName = 'removeStyle';
  const lastArgType = args[2] ?
      typeChecker.typeToString(
          typeChecker.getTypeAtLocation(args[2]), node, ts.TypeFormatFlags.AddUndefined) :
      null;

  // Note that for a literal null, TS considers it a `NullKeyword`,
  // whereas a literal `undefined` is just an Identifier.
  if (args.length === 2 || lastArgType === 'null' || lastArgType === 'undefined') {
    // If we've got a call with two arguments, or one with three arguments where the last one is
    // `undefined` or `null`, we can safely switch to a `removeStyle` call.
    const innerExpression = node.expression.expression;
    const topExpression = ts.createPropertyAccess(innerExpression, removeMethodName);
    return ts.createCall(topExpression, [], args.slice(0, 2));
  } else if (args.length === 3) {
    // We need the checks for string literals, because the type of something
    // like `"blue"` is the literal `blue`, not `string`.
    if (lastArgType === 'string' || lastArgType === 'number' || ts.isStringLiteral(args[2]) ||
        ts.isNoSubstitutionTemplateLiteral(args[2]) || ts.isNumericLiteral(args[2])) {
      // If we've got three arguments and the last one is a string literal or a number, we
      // can safely rename to `setStyle`.
      return renameMethodCall(node, addMethodName);
    } else {
      // Otherwise migrate to a ternary that looks like:
      // `value == null ? removeStyle(el, key) : setStyle(el, key, value)`
      const condition = ts.createBinary(args[2], ts.SyntaxKind.EqualsEqualsToken, ts.createNull());
      const whenNullCall = renameMethodCall(
          ts.createCall(node.expression, [], args.slice(0, 2)) as PropertyAccessCallExpression,
          removeMethodName);
      return ts.createConditional(condition, whenNullCall, renameMethodCall(node, addMethodName));
    }
  }

  return node;
}

/**
 * Migrates a call to `invokeElementMethod(target, method, [arg1, arg2])` either to
 * `target.method(arg1, arg2)` or `(target as any)[method].apply(target, [arg1, arg2])`.
 */
function migrateInvokeElementMethod(node: ts.CallExpression): ts.Node {
  const [target, name, args] = node.arguments;
  const isNameStatic = ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name);
  const isArgsStatic = !args || ts.isArrayLiteralExpression(args);

  if (isNameStatic && isArgsStatic) {
    // If the name is a static string and the arguments are an array literal,
    // we can safely convert the node into a call expression.
    const expression = ts.createPropertyAccess(
        target, (name as ts.StringLiteral | ts.NoSubstitutionTemplateLiteral).text);
    const callArguments = args ? (args as ts.ArrayLiteralExpression).elements : [];
    return ts.createCall(expression, [], callArguments);
  } else {
    // Otherwise create an expression in the form of `(target as any)[name].apply(target, args)`.
    const asExpression = ts.createParen(
        ts.createAsExpression(target, ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)));
    const elementAccess = ts.createElementAccess(asExpression, name);
    const applyExpression = ts.createPropertyAccess(elementAccess, 'apply');
    return ts.createCall(applyExpression, [], args ? [target, args] : [target]);
  }
}

/** Migrates a call to `createViewRoot` to whatever node was passed in as the first argument. */
function migrateCreateViewRoot(node: ts.CallExpression): ts.Node {
  return node.arguments[0];
}

/** Migrates a call to `migrate` a direct call to the helper. */
function migrateAnimateCall() {
  return ts.createCall(ts.createIdentifier(HelperFunction.animate), [], []);
}

/**
 * Switches out a call to the `Renderer` to a call to one of our helper functions.
 * Most of the helpers accept an instance of `Renderer2` as the first argument and all
 * subsequent arguments differ.
 * @param node Node of the original method call.
 * @param helper Name of the helper with which to replace the original call.
 * @param args Arguments that should be passed into the helper after the renderer argument.
 */
function switchToHelperCall(
    node: PropertyAccessCallExpression, helper: HelperFunction,
    args: ts.Expression[]|ts.NodeArray<ts.Expression>): ts.Node {
  return ts.createCall(ts.createIdentifier(helper), [], [node.expression.expression, ...args]);
}
