/**
 * @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 { SchematicsException, Tree, UpdateRecorder } from '@angular-devkit/schematics';
import { dirname, join } from 'path';
import ts from '../third_party/github.com/Microsoft/TypeScript/lib/typescript';
import { insertImport } from '../utility/ast-utils';
import { InsertChange } from '../utility/change';

/** App config that was resolved to its source node. */
interface ResolvedAppConfig {
  /** Tree-relative path of the file containing the app config. */
  filePath: string;

  /** Node defining the app config. */
  node: ts.ObjectLiteralExpression;
}

/**
 * Checks whether the providers from a module are being imported in a `bootstrapApplication` call.
 * @param tree File tree of the project.
 * @param filePath Path of the file in which to check.
 * @param className Class name of the module to search for.
 * @deprecated Private utility that will be removed. Use `addRootImport` or `addRootProvider` from
 * `@schematics/angular/utility` instead.
 */
export function importsProvidersFrom(tree: Tree, filePath: string, className: string): boolean {
  const sourceFile = createSourceFile(tree, filePath);
  const bootstrapCall = findBootstrapApplicationCall(sourceFile);
  const appConfig = bootstrapCall ? findAppConfig(bootstrapCall, tree, filePath) : null;
  const importProvidersFromCall = appConfig ? findImportProvidersFromCall(appConfig.node) : null;

  return !!importProvidersFromCall?.arguments.some(
    (arg) => ts.isIdentifier(arg) && arg.text === className,
  );
}

/**
 * Checks whether a providers function is being called in a `bootstrapApplication` call.
 * @param tree File tree of the project.
 * @param filePath Path of the file in which to check.
 * @param functionName Name of the function to search for.
 * @deprecated Private utility that will be removed. Use `addRootImport` or `addRootProvider` from
 * `@schematics/angular/utility` instead.
 */
export function callsProvidersFunction(
  tree: Tree,
  filePath: string,
  functionName: string,
): boolean {
  const sourceFile = createSourceFile(tree, filePath);
  const bootstrapCall = findBootstrapApplicationCall(sourceFile);
  const appConfig = bootstrapCall ? findAppConfig(bootstrapCall, tree, filePath) : null;
  const providersLiteral = appConfig ? findProvidersLiteral(appConfig.node) : null;

  return !!providersLiteral?.elements.some(
    (el) =>
      ts.isCallExpression(el) &&
      ts.isIdentifier(el.expression) &&
      el.expression.text === functionName,
  );
}

/**
 * Adds an `importProvidersFrom` call to the `bootstrapApplication` call.
 * @param tree File tree of the project.
 * @param filePath Path to the file that should be updated.
 * @param moduleName Name of the module that should be imported.
 * @param modulePath Path from which to import the module.
 * @deprecated Private utility that will be removed. Use `addRootImport` or `addRootProvider` from
 * `@schematics/angular/utility` instead.
 */
export function addModuleImportToStandaloneBootstrap(
  tree: Tree,
  filePath: string,
  moduleName: string,
  modulePath: string,
) {
  const sourceFile = createSourceFile(tree, filePath);
  const bootstrapCall = findBootstrapApplicationCall(sourceFile);
  const addImports = (file: ts.SourceFile, recorder: UpdateRecorder) => {
    const sourceText = file.getText();

    [
      insertImport(file, sourceText, moduleName, modulePath),
      insertImport(file, sourceText, 'importProvidersFrom', '@angular/core'),
    ].forEach((change) => {
      if (change instanceof InsertChange) {
        recorder.insertLeft(change.pos, change.toAdd);
      }
    });
  };

  if (!bootstrapCall) {
    throw new SchematicsException(`Could not find bootstrapApplication call in ${filePath}`);
  }

  const importProvidersCall = ts.factory.createCallExpression(
    ts.factory.createIdentifier('importProvidersFrom'),
    [],
    [ts.factory.createIdentifier(moduleName)],
  );

  // If there's only one argument, we have to create a new object literal.
  if (bootstrapCall.arguments.length === 1) {
    const recorder = tree.beginUpdate(filePath);
    addNewAppConfigToCall(bootstrapCall, importProvidersCall, recorder);
    addImports(sourceFile, recorder);
    tree.commitUpdate(recorder);

    return;
  }

  // If the config is a `mergeApplicationProviders` call, add another config to it.
  if (isMergeAppConfigCall(bootstrapCall.arguments[1])) {
    const recorder = tree.beginUpdate(filePath);
    addNewAppConfigToCall(bootstrapCall.arguments[1], importProvidersCall, recorder);
    addImports(sourceFile, recorder);
    tree.commitUpdate(recorder);

    return;
  }

  // Otherwise attempt to merge into the current config.
  const appConfig = findAppConfig(bootstrapCall, tree, filePath);

  if (!appConfig) {
    throw new SchematicsException(
      `Could not statically analyze config in bootstrapApplication call in ${filePath}`,
    );
  }

  const { filePath: configFilePath, node: config } = appConfig;
  const recorder = tree.beginUpdate(configFilePath);
  const importCall = findImportProvidersFromCall(config);

  addImports(config.getSourceFile(), recorder);

  if (importCall) {
    // If there's an `importProvidersFrom` call already, add the module to it.
    recorder.insertRight(
      importCall.arguments[importCall.arguments.length - 1].getEnd(),
      `, ${moduleName}`,
    );
  } else {
    const providersLiteral = findProvidersLiteral(config);

    if (providersLiteral) {
      // If there's a `providers` array, add the import to it.
      addElementToArray(providersLiteral, importProvidersCall, recorder);
    } else {
      // Otherwise add a `providers` array to the existing object literal.
      addProvidersToObjectLiteral(config, importProvidersCall, recorder);
    }
  }

  tree.commitUpdate(recorder);
}

/**
 * Adds a providers function call to the `bootstrapApplication` call.
 * @param tree File tree of the project.
 * @param filePath Path to the file that should be updated.
 * @param functionName Name of the function that should be called.
 * @param importPath Path from which to import the function.
 * @param args Arguments to use when calling the function.
 * @returns The file path that the provider was added to.
 * @deprecated Private utility that will be removed. Use `addRootImport` or `addRootProvider` from
 * `@schematics/angular/utility` instead.
 */
export function addFunctionalProvidersToStandaloneBootstrap(
  tree: Tree,
  filePath: string,
  functionName: string,
  importPath: string,
  args: ts.Expression[] = [],
): string {
  const sourceFile = createSourceFile(tree, filePath);
  const bootstrapCall = findBootstrapApplicationCall(sourceFile);
  const addImports = (file: ts.SourceFile, recorder: UpdateRecorder) => {
    const change = insertImport(file, file.getText(), functionName, importPath);

    if (change instanceof InsertChange) {
      recorder.insertLeft(change.pos, change.toAdd);
    }
  };

  if (!bootstrapCall) {
    throw new SchematicsException(`Could not find bootstrapApplication call in ${filePath}`);
  }

  const providersCall = ts.factory.createCallExpression(
    ts.factory.createIdentifier(functionName),
    undefined,
    args,
  );

  // If there's only one argument, we have to create a new object literal.
  if (bootstrapCall.arguments.length === 1) {
    const recorder = tree.beginUpdate(filePath);
    addNewAppConfigToCall(bootstrapCall, providersCall, recorder);
    addImports(sourceFile, recorder);
    tree.commitUpdate(recorder);

    return filePath;
  }

  // If the config is a `mergeApplicationProviders` call, add another config to it.
  if (isMergeAppConfigCall(bootstrapCall.arguments[1])) {
    const recorder = tree.beginUpdate(filePath);
    addNewAppConfigToCall(bootstrapCall.arguments[1], providersCall, recorder);
    addImports(sourceFile, recorder);
    tree.commitUpdate(recorder);

    return filePath;
  }

  // Otherwise attempt to merge into the current config.
  const appConfig = findAppConfig(bootstrapCall, tree, filePath);

  if (!appConfig) {
    throw new SchematicsException(
      `Could not statically analyze config in bootstrapApplication call in ${filePath}`,
    );
  }

  const { filePath: configFilePath, node: config } = appConfig;
  const recorder = tree.beginUpdate(configFilePath);
  const providersLiteral = findProvidersLiteral(config);

  addImports(config.getSourceFile(), recorder);

  if (providersLiteral) {
    // If there's a `providers` array, add the import to it.
    addElementToArray(providersLiteral, providersCall, recorder);
  } else {
    // Otherwise add a `providers` array to the existing object literal.
    addProvidersToObjectLiteral(config, providersCall, recorder);
  }

  tree.commitUpdate(recorder);

  return configFilePath;
}

/**
 * Finds the call to `bootstrapApplication` within a file.
 * @deprecated Private utility that will be removed. Use `addRootImport` or `addRootProvider` from
 * `@schematics/angular/utility` instead.
 */
export function findBootstrapApplicationCall(sourceFile: ts.SourceFile): ts.CallExpression | null {
  const localName = findImportLocalName(
    sourceFile,
    'bootstrapApplication',
    '@angular/platform-browser',
  );

  if (!localName) {
    return null;
  }

  let result: ts.CallExpression | null = null;

  sourceFile.forEachChild(function walk(node) {
    if (
      ts.isCallExpression(node) &&
      ts.isIdentifier(node.expression) &&
      node.expression.text === localName
    ) {
      result = node;
    }

    if (!result) {
      node.forEachChild(walk);
    }
  });

  return result;
}

/** Find a call to `importProvidersFrom` within an application config. */
function findImportProvidersFromCall(config: ts.ObjectLiteralExpression): ts.CallExpression | null {
  const importProvidersName = findImportLocalName(
    config.getSourceFile(),
    'importProvidersFrom',
    '@angular/core',
  );
  const providersLiteral = findProvidersLiteral(config);

  if (providersLiteral && importProvidersName) {
    for (const element of providersLiteral.elements) {
      // Look for an array element that calls the `importProvidersFrom` function.
      if (
        ts.isCallExpression(element) &&
        ts.isIdentifier(element.expression) &&
        element.expression.text === importProvidersName
      ) {
        return element;
      }
    }
  }

  return null;
}

/** Finds the `providers` array literal within an application config. */
function findProvidersLiteral(
  config: ts.ObjectLiteralExpression,
): ts.ArrayLiteralExpression | null {
  for (const prop of config.properties) {
    if (
      ts.isPropertyAssignment(prop) &&
      ts.isIdentifier(prop.name) &&
      prop.name.text === 'providers' &&
      ts.isArrayLiteralExpression(prop.initializer)
    ) {
      return prop.initializer;
    }
  }

  return null;
}

/**
 * Resolves the node that defines the app config from a bootstrap call.
 * @param bootstrapCall Call for which to resolve the config.
 * @param tree File tree of the project.
 * @param filePath File path of the bootstrap call.
 */
function findAppConfig(
  bootstrapCall: ts.CallExpression,
  tree: Tree,
  filePath: string,
): ResolvedAppConfig | null {
  if (bootstrapCall.arguments.length > 1) {
    const config = bootstrapCall.arguments[1];

    if (ts.isObjectLiteralExpression(config)) {
      return { filePath, node: config };
    }

    if (ts.isIdentifier(config)) {
      return resolveAppConfigFromIdentifier(config, tree, filePath);
    }
  }

  return null;
}

/**
 * Resolves the app config from an identifier referring to it.
 * @param identifier Identifier referring to the app config.
 * @param tree File tree of the project.
 * @param bootstapFilePath Path of the bootstrap call.
 */
function resolveAppConfigFromIdentifier(
  identifier: ts.Identifier,
  tree: Tree,
  bootstapFilePath: string,
): ResolvedAppConfig | null {
  const sourceFile = identifier.getSourceFile();

  for (const node of sourceFile.statements) {
    // Only look at relative imports. This will break if the app uses a path
    // mapping to refer to the import, but in order to resolve those, we would
    // need knowledge about the entire program.
    if (
      !ts.isImportDeclaration(node) ||
      !node.importClause?.namedBindings ||
      !ts.isNamedImports(node.importClause.namedBindings) ||
      !ts.isStringLiteralLike(node.moduleSpecifier) ||
      !node.moduleSpecifier.text.startsWith('.')
    ) {
      continue;
    }

    for (const specifier of node.importClause.namedBindings.elements) {
      if (specifier.name.text !== identifier.text) {
        continue;
      }

      // Look for a variable with the imported name in the file. Note that ideally we would use
      // the type checker to resolve this, but we can't because these utilities are set up to
      // operate on individual files, not the entire program.
      const filePath = join(dirname(bootstapFilePath), node.moduleSpecifier.text + '.ts');
      const importedSourceFile = createSourceFile(tree, filePath);
      const resolvedVariable = findAppConfigFromVariableName(
        importedSourceFile,
        (specifier.propertyName || specifier.name).text,
      );

      if (resolvedVariable) {
        return { filePath, node: resolvedVariable };
      }
    }
  }

  const variableInSameFile = findAppConfigFromVariableName(sourceFile, identifier.text);

  return variableInSameFile ? { filePath: bootstapFilePath, node: variableInSameFile } : null;
}

/**
 * Finds an app config within the top-level variables of a file.
 * @param sourceFile File in which to search for the config.
 * @param variableName Name of the variable containing the config.
 */
function findAppConfigFromVariableName(
  sourceFile: ts.SourceFile,
  variableName: string,
): ts.ObjectLiteralExpression | null {
  for (const node of sourceFile.statements) {
    if (ts.isVariableStatement(node)) {
      for (const decl of node.declarationList.declarations) {
        if (
          ts.isIdentifier(decl.name) &&
          decl.name.text === variableName &&
          decl.initializer &&
          ts.isObjectLiteralExpression(decl.initializer)
        ) {
          return decl.initializer;
        }
      }
    }
  }

  return null;
}

/**
 * Finds the local name of an imported symbol. Could be the symbol name itself or its alias.
 * @param sourceFile File within which to search for the import.
 * @param name Actual name of the import, not its local alias.
 * @param moduleName Name of the module from which the symbol is imported.
 */
function findImportLocalName(
  sourceFile: ts.SourceFile,
  name: string,
  moduleName: string,
): string | null {
  for (const node of sourceFile.statements) {
    // Only look for top-level imports.
    if (
      !ts.isImportDeclaration(node) ||
      !ts.isStringLiteral(node.moduleSpecifier) ||
      node.moduleSpecifier.text !== moduleName
    ) {
      continue;
    }

    // Filter out imports that don't have the right shape.
    if (
      !node.importClause ||
      !node.importClause.namedBindings ||
      !ts.isNamedImports(node.importClause.namedBindings)
    ) {
      continue;
    }

    // Look through the elements of the declaration for the specific import.
    for (const element of node.importClause.namedBindings.elements) {
      if ((element.propertyName || element.name).text === name) {
        // The local name is always in `name`.
        return element.name.text;
      }
    }
  }

  return null;
}

/** Creates a source file from a file path within a project. */
function createSourceFile(tree: Tree, filePath: string): ts.SourceFile {
  return ts.createSourceFile(filePath, tree.readText(filePath), ts.ScriptTarget.Latest, true);
}

/**
 * Creates a new app config object literal and adds it to a call expression as an argument.
 * @param call Call to which to add the config.
 * @param expression Expression that should inserted into the new config.
 * @param recorder Recorder to which to log the change.
 */
function addNewAppConfigToCall(
  call: ts.CallExpression,
  expression: ts.Expression,
  recorder: UpdateRecorder,
): void {
  const newCall = ts.factory.updateCallExpression(call, call.expression, call.typeArguments, [
    ...call.arguments,
    ts.factory.createObjectLiteralExpression(
      [
        ts.factory.createPropertyAssignment(
          'providers',
          ts.factory.createArrayLiteralExpression([expression]),
        ),
      ],
      true,
    ),
  ]);

  recorder.remove(call.getStart(), call.getWidth());
  recorder.insertRight(
    call.getStart(),
    ts.createPrinter().printNode(ts.EmitHint.Unspecified, newCall, call.getSourceFile()),
  );
}

/**
 * Adds an element to an array literal expression.
 * @param node Array to which to add the element.
 * @param element Element to be added.
 * @param recorder Recorder to which to log the change.
 */
function addElementToArray(
  node: ts.ArrayLiteralExpression,
  element: ts.Expression,
  recorder: UpdateRecorder,
): void {
  const newLiteral = ts.factory.updateArrayLiteralExpression(node, [...node.elements, element]);
  recorder.remove(node.getStart(), node.getWidth());
  recorder.insertRight(
    node.getStart(),
    ts.createPrinter().printNode(ts.EmitHint.Unspecified, newLiteral, node.getSourceFile()),
  );
}

/**
 * Adds a `providers` property to an object literal.
 * @param node Literal to which to add the `providers`.
 * @param expression Provider that should be part of the generated `providers` array.
 * @param recorder Recorder to which to log the change.
 */
function addProvidersToObjectLiteral(
  node: ts.ObjectLiteralExpression,
  expression: ts.Expression,
  recorder: UpdateRecorder,
) {
  const newOptionsLiteral = ts.factory.updateObjectLiteralExpression(node, [
    ...node.properties,
    ts.factory.createPropertyAssignment(
      'providers',
      ts.factory.createArrayLiteralExpression([expression]),
    ),
  ]);
  recorder.remove(node.getStart(), node.getWidth());
  recorder.insertRight(
    node.getStart(),
    ts.createPrinter().printNode(ts.EmitHint.Unspecified, newOptionsLiteral, node.getSourceFile()),
  );
}

/** Checks whether a node is a call to `mergeApplicationConfig`. */
function isMergeAppConfigCall(node: ts.Node): node is ts.CallExpression {
  if (!ts.isCallExpression(node)) {
    return false;
  }

  const localName = findImportLocalName(
    node.getSourceFile(),
    'mergeApplicationConfig',
    '@angular/core',
  );

  return !!localName && ts.isIdentifier(node.expression) && node.expression.text === localName;
}