/**
 * @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.dev/license
 */

import { tags } from '@angular-devkit/core';
import { Rule, SchematicsException, Tree, chain } from '@angular-devkit/schematics';
import ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript';
import { addSymbolToNgModuleMetadata, insertAfterLastOccurrence } from '../ast-utils';
import { InsertChange } from '../change';
import { getAppModulePath, isStandaloneApp } from '../ng-ast-utils';
import { ResolvedAppConfig, findAppConfig } from './app_config';
import { CodeBlock, CodeBlockCallback, PendingCode } from './code_block';
import {
  applyChangesToFile,
  findBootstrapApplicationCall,
  findProvidersLiteral,
  getMainFilePath,
  getSourceFile,
  isMergeAppConfigCall,
} from './util';

/**
 * Adds an import to the root of the project.
 * @param project Name of the project to which to add the import.
 * @param callback Function that generates the code block which should be inserted.
 * @example
 *
 * ```ts
 * import { Rule } from '@angular-devkit/schematics';
 * import { addRootImport } from '@schematics/angular/utility';
 *
 * export default function(): Rule {
 *   return addRootImport('default', ({code, external}) => {
 *     return code`${external('MyModule', '@my/module')}.forRoot({})`;
 *   });
 * }
 * ```
 */
export function addRootImport(project: string, callback: CodeBlockCallback): Rule {
  return getRootInsertionRule(project, callback, 'imports', {
    name: 'importProvidersFrom',
    module: '@angular/core',
  });
}

/**
 * Adds a provider to the root of the project.
 * @param project Name of the project to which to add the import.
 * @param callback Function that generates the code block which should be inserted.
 * @example
 *
 * ```ts
 * import { Rule } from '@angular-devkit/schematics';
 * import { addRootProvider } from '@schematics/angular/utility';
 *
 * export default function(): Rule {
 *   return addRootProvider('default', ({code, external}) => {
 *     return code`${external('provideLibrary', '@my/library')}({})`;
 *   });
 * }
 * ```
 */
export function addRootProvider(project: string, callback: CodeBlockCallback): Rule {
  return getRootInsertionRule(project, callback, 'providers');
}

/**
 * Creates a rule that inserts code at the root of either a standalone or NgModule-based project.
 * @param project Name of the project into which to inser tthe code.
 * @param callback Function that generates the code block which should be inserted.
 * @param ngModuleField Field of the root NgModule into which the code should be inserted, if the
 * app is based on NgModule
 * @param standaloneWrapperFunction Function with which to wrap the code if the app is standalone.
 */
function getRootInsertionRule(
  project: string,
  callback: CodeBlockCallback,
  ngModuleField: string,
  standaloneWrapperFunction?: { name: string; module: string },
): Rule {
  return async (host) => {
    const mainFilePath = await getMainFilePath(host, project);
    const codeBlock = new CodeBlock();

    if (isStandaloneApp(host, mainFilePath)) {
      return (tree) =>
        addProviderToStandaloneBootstrap(
          tree,
          callback(codeBlock),
          mainFilePath,
          standaloneWrapperFunction,
        );
    }

    const modulePath = getAppModulePath(host, mainFilePath);
    const pendingCode = CodeBlock.transformPendingCode(callback(codeBlock), modulePath);

    return chain([
      ...pendingCode.rules,
      (tree) => {
        const changes = addSymbolToNgModuleMetadata(
          getSourceFile(tree, modulePath),
          modulePath,
          ngModuleField,
          pendingCode.code.expression,
          // Explicitly set the import path to null since we deal with imports here separately.
          null,
        );

        applyChangesToFile(tree, modulePath, changes);
      },
    ]);
  };
}

/**
 * Adds a provider to the root of a standalone project.
 * @param host Tree of the root rule.
 * @param pendingCode Code that should be inserted.
 * @param mainFilePath Path to the project's main file.
 * @param wrapperFunction Optional function with which to wrap the provider.
 */
function addProviderToStandaloneBootstrap(
  host: Tree,
  pendingCode: PendingCode,
  mainFilePath: string,
  wrapperFunction?: { name: string; module: string },
): Rule {
  const bootstrapCall = findBootstrapApplicationCall(host, mainFilePath);
  const fileToEdit = findAppConfig(bootstrapCall, host, mainFilePath)?.filePath || mainFilePath;
  const { code, rules } = CodeBlock.transformPendingCode(pendingCode, fileToEdit);

  return chain([
    ...rules,
    () => {
      let wrapped: PendingCode;
      let additionalRules: Rule[];

      if (wrapperFunction) {
        const block = new CodeBlock();
        const result = CodeBlock.transformPendingCode(
          block.code`${block.external(wrapperFunction.name, wrapperFunction.module)}(${
            code.expression
          })`,
          fileToEdit,
        );

        wrapped = result.code;
        additionalRules = result.rules;
      } else {
        wrapped = code;
        additionalRules = [];
      }

      return chain([
        ...additionalRules,
        (tree) => insertStandaloneRootProvider(tree, mainFilePath, wrapped.expression),
      ]);
    },
  ]);
}

/**
 * Inserts a string expression into the root of a standalone project.
 * @param tree File tree used to modify the project.
 * @param mainFilePath Path to the main file of the project.
 * @param expression Code expression to be inserted.
 */
function insertStandaloneRootProvider(tree: Tree, mainFilePath: string, expression: string): void {
  const bootstrapCall = findBootstrapApplicationCall(tree, mainFilePath);
  const appConfig = findAppConfig(bootstrapCall, tree, mainFilePath);

  if (bootstrapCall.arguments.length === 0) {
    throw new SchematicsException(
      `Cannot add provider to invalid bootstrapApplication call in ${
        bootstrapCall.getSourceFile().fileName
      }`,
    );
  }

  if (appConfig) {
    addProvidersExpressionToAppConfig(tree, appConfig, expression);

    return;
  }

  const newAppConfig = `, {\n${tags.indentBy(2)`providers: [${expression}]`}\n}`;
  let targetCall: ts.CallExpression;

  if (bootstrapCall.arguments.length === 1) {
    targetCall = bootstrapCall;
  } else if (isMergeAppConfigCall(bootstrapCall.arguments[1])) {
    targetCall = bootstrapCall.arguments[1];
  } else {
    throw new SchematicsException(
      `Cannot statically analyze bootstrapApplication call in ${
        bootstrapCall.getSourceFile().fileName
      }`,
    );
  }

  applyChangesToFile(tree, mainFilePath, [
    insertAfterLastOccurrence(
      targetCall.arguments,
      newAppConfig,
      mainFilePath,
      targetCall.getEnd() - 1,
    ),
  ]);
}

/**
 * Adds a string expression to an app config object.
 * @param tree File tree used to modify the project.
 * @param appConfig Resolved configuration object of the project.
 * @param expression Code expression to be inserted.
 */
function addProvidersExpressionToAppConfig(
  tree: Tree,
  appConfig: ResolvedAppConfig,
  expression: string,
): void {
  const { node, filePath } = appConfig;
  const configProps = node.properties;
  const providersLiteral = findProvidersLiteral(node);

  // If there's a `providers` property, we can add the provider
  // to it, otherwise we need to declare it ourselves.
  if (providersLiteral) {
    applyChangesToFile(tree, filePath, [
      insertAfterLastOccurrence(
        providersLiteral.elements,
        (providersLiteral.elements.length === 0 ? '' : ', ') + expression,
        filePath,
        providersLiteral.getStart() + 1,
      ),
    ]);
  } else {
    const prop = tags.indentBy(2)`providers: [${expression}]`;
    let toInsert: string;
    let insertPosition: number;

    if (configProps.length === 0) {
      toInsert = '\n' + prop + '\n';
      insertPosition = node.getEnd() - 1;
    } else {
      const hasTrailingComma = configProps.hasTrailingComma;
      toInsert = (hasTrailingComma ? '' : ',') + '\n' + prop;
      insertPosition = configProps[configProps.length - 1].getEnd() + (hasTrailingComma ? 1 : 0);
    }

    applyChangesToFile(tree, filePath, [new InsertChange(filePath, insertPosition, toInsert)]);
  }
}
