/**
 * @license
 * Copyright Google Inc. 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 { Path, dirname, getSystemPath, normalize, resolve, virtualFs } from '@angular-devkit/core';
import { ChildProcess, ForkOptions, fork } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as ts from 'typescript';
import { Compiler, compilation } from 'webpack';
import { time, timeEnd } from './benchmark';
import { WebpackCompilerHost, workaroundResolve } from './compiler_host';
import { resolveEntryModuleFromMain } from './entry_resolver';
import { gatherDiagnostics, hasErrors } from './gather_diagnostics';
import { LazyRouteMap, findLazyRoutes } from './lazy_routes';
import {
  CompilerCliIsSupported,
  CompilerHost,
  CompilerOptions,
  DEFAULT_ERROR_CODE,
  Diagnostic,
  EmitFlags,
  Program,
  SOURCE,
  UNKNOWN_ERROR_CODE,
  VERSION,
  __NGTOOLS_PRIVATE_API_2,
  createCompilerHost,
  createProgram,
  formatDiagnostics,
  readConfiguration,
} from './ngtools_api';
import { resolveWithPaths } from './paths-plugin';
import { WebpackResourceLoader } from './resource_loader';
import {
  exportLazyModuleMap,
  exportNgFactory,
  findResources,
  registerLocaleData,
  removeDecorators,
  replaceBootstrap,
  replaceResources,
  replaceServerBootstrap,
} from './transformers';
import { collectDeepNodes } from './transformers/ast_helpers';
import { AUTO_START_ARG, InitMessage, UpdateMessage } from './type_checker';
import {
  VirtualFileSystemDecorator,
  VirtualWatchFileSystemDecorator,
} from './virtual_file_system_decorator';
import {
  Callback,
  InputFileSystem,
  NodeWatchFileSystemInterface,
  NormalModuleFactoryRequest,
} from './webpack';
import { WebpackInputHost } from './webpack-input-host';

const treeKill = require('tree-kill');

export interface ContextElementDependency {}

export interface ContextElementDependencyConstructor {
  new(modulePath: string, name: string): ContextElementDependency;
}

/**
 * Option Constants
 */
export interface AngularCompilerPluginOptions {
  sourceMap?: boolean;
  tsConfigPath: string;
  basePath?: string;
  entryModule?: string;
  mainPath?: string;
  skipCodeGeneration?: boolean;
  hostReplacementPaths?: { [path: string]: string } | ((path: string) => string);
  forkTypeChecker?: boolean;
  // TODO: remove singleFileIncludes for 2.0, this is just to support old projects that did not
  // include 'polyfills.ts' in `tsconfig.spec.json'.
  singleFileIncludes?: string[];
  i18nInFile?: string;
  i18nInFormat?: string;
  i18nOutFile?: string;
  i18nOutFormat?: string;
  locale?: string;
  missingTranslation?: string;
  platform?: PLATFORM;
  nameLazyFiles?: boolean;

  // added to the list of lazy routes
  additionalLazyModules?: { [module: string]: string };

  // The ContextElementDependency of correct Webpack compilation.
  // This is needed when there are multiple Webpack installs.
  contextElementDependencyConstructor?: ContextElementDependencyConstructor;

  // Use tsconfig to include path globs.
  compilerOptions?: ts.CompilerOptions;

  host?: virtualFs.Host<fs.Stats>;
  platformTransformers?: ts.TransformerFactory<ts.SourceFile>[];
}

export enum PLATFORM {
  Browser,
  Server,
}

export class AngularCompilerPlugin {
  private _options: AngularCompilerPluginOptions;

  // TS compilation.
  private _compilerOptions: CompilerOptions;
  private _rootNames: string[];
  private _singleFileIncludes: string[] = [];
  private _program: (ts.Program | Program) | null;
  private _compilerHost: WebpackCompilerHost & CompilerHost;
  private _moduleResolutionCache: ts.ModuleResolutionCache;
  private _resourceLoader: WebpackResourceLoader;
  // Contains `moduleImportPath#exportName` => `fullModulePath`.
  private _lazyRoutes: LazyRouteMap = Object.create(null);
  private _tsConfigPath: string;
  private _entryModule: string | null;
  private _mainPath: string | undefined;
  private _basePath: string;
  private _transformers: ts.TransformerFactory<ts.SourceFile>[] = [];
  private _platformTransformers: ts.TransformerFactory<ts.SourceFile>[] | null = null;
  private _platform: PLATFORM;
  private _JitMode = false;
  private _emitSkipped = true;
  private _changedFileExtensions = new Set(['ts', 'html', 'css']);

  // Webpack plugin.
  private _firstRun = true;
  private _donePromise: Promise<void> | null;
  private _normalizedLocale: string | null;
  private _warnings: (string | Error)[] = [];
  private _errors: (string | Error)[] = [];
  private _contextElementDependencyConstructor: ContextElementDependencyConstructor;

  // TypeChecker process.
  private _forkTypeChecker = true;
  private _typeCheckerProcess: ChildProcess | null;
  private _forkedTypeCheckerInitialized = false;

  private get _ngCompilerSupportsNewApi() {
    if (this._JitMode) {
      return false;
    } else {
      return !!(this._program as Program).listLazyRoutes;
    }
  }

  constructor(options: AngularCompilerPluginOptions) {
    CompilerCliIsSupported();
    this._options = Object.assign({}, options);
    this._setupOptions(this._options);
  }

  get options() { return this._options; }
  get done() { return this._donePromise; }
  get entryModule() {
    if (!this._entryModule) {
      return null;
    }
    const splitted = this._entryModule.split(/(#[a-zA-Z_]([\w]+))$/);
    const path = splitted[0];
    const className = !!splitted[1] ? splitted[1].substring(1) : 'default';

    return { path, className };
  }

  get typeChecker(): ts.TypeChecker | null {
    const tsProgram = this._getTsProgram();

    return tsProgram ? tsProgram.getTypeChecker() : null;
  }

  static isSupported() {
    return VERSION && parseInt(VERSION.major) >= 5;
  }

  private _setupOptions(options: AngularCompilerPluginOptions) {
    time('AngularCompilerPlugin._setupOptions');
    // Fill in the missing options.
    if (!options.hasOwnProperty('tsConfigPath')) {
      throw new Error('Must specify "tsConfigPath" in the configuration of @ngtools/webpack.');
    }
    // TS represents paths internally with '/' and expects the tsconfig path to be in this format
    this._tsConfigPath = options.tsConfigPath.replace(/\\/g, '/');

    // Check the base path.
    const maybeBasePath = path.resolve(process.cwd(), this._tsConfigPath);
    let basePath = maybeBasePath;
    if (fs.statSync(maybeBasePath).isFile()) {
      basePath = path.dirname(basePath);
    }
    if (options.basePath !== undefined) {
      basePath = path.resolve(process.cwd(), options.basePath);
    }

    if (options.singleFileIncludes !== undefined) {
      this._singleFileIncludes.push(...options.singleFileIncludes);
    }

    // Parse the tsconfig contents.
    const config = readConfiguration(this._tsConfigPath);
    if (config.errors && config.errors.length) {
      throw new Error(formatDiagnostics(config.errors));
    }

    this._rootNames = config.rootNames.concat(...this._singleFileIncludes);
    this._compilerOptions = { ...config.options, ...options.compilerOptions };
    this._basePath = config.options.basePath || basePath || '';

    // Overwrite outDir so we can find generated files next to their .ts origin in compilerHost.
    this._compilerOptions.outDir = '';
    this._compilerOptions.suppressOutputPathCheck = true;

    // Default plugin sourceMap to compiler options setting.
    if (!options.hasOwnProperty('sourceMap')) {
      options.sourceMap = this._compilerOptions.sourceMap || false;
    }

    // Force the right sourcemap options.
    if (options.sourceMap) {
      this._compilerOptions.sourceMap = true;
      this._compilerOptions.inlineSources = true;
      this._compilerOptions.inlineSourceMap = false;
      this._compilerOptions.mapRoot = undefined;
      // We will set the source to the full path of the file in the loader, so we don't
      // need sourceRoot here.
      this._compilerOptions.sourceRoot = undefined;
    } else {
      this._compilerOptions.sourceMap = false;
      this._compilerOptions.sourceRoot = undefined;
      this._compilerOptions.inlineSources = undefined;
      this._compilerOptions.inlineSourceMap = undefined;
      this._compilerOptions.mapRoot = undefined;
      this._compilerOptions.sourceRoot = undefined;
    }

    // We want to allow emitting with errors so that imports can be added
    // to the webpack dependency tree and rebuilds triggered by file edits.
    this._compilerOptions.noEmitOnError = false;

    // Set JIT (no code generation) or AOT mode.
    if (options.skipCodeGeneration !== undefined) {
      this._JitMode = options.skipCodeGeneration;
    }

    // Process i18n options.
    if (options.i18nInFile !== undefined) {
      this._compilerOptions.i18nInFile = options.i18nInFile;
    }
    if (options.i18nInFormat !== undefined) {
      this._compilerOptions.i18nInFormat = options.i18nInFormat;
    }
    if (options.i18nOutFile !== undefined) {
      this._compilerOptions.i18nOutFile = options.i18nOutFile;
    }
    if (options.i18nOutFormat !== undefined) {
      this._compilerOptions.i18nOutFormat = options.i18nOutFormat;
    }
    if (options.locale !== undefined) {
      this._compilerOptions.i18nInLocale = options.locale;
      this._compilerOptions.i18nOutLocale = options.locale;
      this._normalizedLocale = this._validateLocale(options.locale);
    }
    if (options.missingTranslation !== undefined) {
      this._compilerOptions.i18nInMissingTranslations =
        options.missingTranslation as 'error' | 'warning' | 'ignore';
    }

    // Process forked type checker options.
    if (options.forkTypeChecker !== undefined) {
      this._forkTypeChecker = options.forkTypeChecker;
    }

    // Add custom platform transformers.
    if (options.platformTransformers !== undefined) {
      this._platformTransformers = options.platformTransformers;
    }

    // Default ContextElementDependency to the one we can import from here.
    // Failing to use the right ContextElementDependency will throw the error below:
    // "No module factory available for dependency type: ContextElementDependency"
    // Hoisting together with peer dependencies can make it so the imported
    // ContextElementDependency does not come from the same Webpack instance that is used
    // in the compilation. In that case, we can pass the right one as an option to the plugin.
    this._contextElementDependencyConstructor = options.contextElementDependencyConstructor
      || require('webpack/lib/dependencies/ContextElementDependency');

    // Use entryModule if available in options, otherwise resolve it from mainPath after program
    // creation.
    if (this._options.entryModule) {
      this._entryModule = this._options.entryModule;
    } else if (this._compilerOptions.entryModule) {
      this._entryModule = path.resolve(this._basePath,
        this._compilerOptions.entryModule as string); // temporary cast for type issue
    }

    // Set platform.
    this._platform = options.platform || PLATFORM.Browser;

    // Make transformers.
    this._makeTransformers();

    timeEnd('AngularCompilerPlugin._setupOptions');
  }

  private _getTsProgram() {
    return this._JitMode ? this._program as ts.Program : (this._program as Program).getTsProgram();
  }

  private _getChangedTsFiles() {
    return this._compilerHost.getChangedFilePaths()
      .filter(k => (k.endsWith('.ts') || k.endsWith('.tsx')) && !k.endsWith('.d.ts'))
      .filter(k => this._compilerHost.fileExists(k));
  }

  updateChangedFileExtensions(extension: string) {
    if (extension) {
      this._changedFileExtensions.add(extension);
    }
  }

  private _getChangedCompilationFiles() {
    return this._compilerHost.getChangedFilePaths()
      .filter(k => {
        for (const ext of this._changedFileExtensions) {
          if (k.endsWith(ext)) {
            return true;
          }
        }

        return false;
      });
  }

  private _createOrUpdateProgram() {
    return Promise.resolve()
      .then(() => {
        // Get the root files from the ts config.
        // When a new root name (like a lazy route) is added, it won't be available from
        // following imports on the existing files, so we need to get the new list of root files.
        const config = readConfiguration(this._tsConfigPath);
        this._rootNames = config.rootNames.concat(...this._singleFileIncludes);

        // Update the forked type checker with all changed compilation files.
        // This includes templates, that also need to be reloaded on the type checker.
        if (this._forkTypeChecker && this._typeCheckerProcess && !this._firstRun) {
          this._updateForkedTypeChecker(this._rootNames, this._getChangedCompilationFiles());
        }

        // Use an identity function as all our paths are absolute already.
        this._moduleResolutionCache = ts.createModuleResolutionCache(this._basePath, x => x);

        if (this._JitMode) {
          // Create the TypeScript program.
          time('AngularCompilerPlugin._createOrUpdateProgram.ts.createProgram');
          this._program = ts.createProgram(
            this._rootNames,
            this._compilerOptions,
            this._compilerHost,
            this._program as ts.Program,
          );
          timeEnd('AngularCompilerPlugin._createOrUpdateProgram.ts.createProgram');

          return Promise.resolve();
        } else {
          time('AngularCompilerPlugin._createOrUpdateProgram.ng.createProgram');
          // Create the Angular program.
          this._program = createProgram({
            rootNames: this._rootNames,
            options: this._compilerOptions,
            host: this._compilerHost,
            oldProgram: this._program as Program,
          });
          timeEnd('AngularCompilerPlugin._createOrUpdateProgram.ng.createProgram');

          time('AngularCompilerPlugin._createOrUpdateProgram.ng.loadNgStructureAsync');

          return this._program.loadNgStructureAsync()
            .then(() => {
              timeEnd('AngularCompilerPlugin._createOrUpdateProgram.ng.loadNgStructureAsync');
            });
        }
      })
      .then(() => {
        // If there's still no entryModule try to resolve from mainPath.
        if (!this._entryModule && this._mainPath) {
          time('AngularCompilerPlugin._make.resolveEntryModuleFromMain');
          this._entryModule = resolveEntryModuleFromMain(
            this._mainPath, this._compilerHost, this._getTsProgram());
          timeEnd('AngularCompilerPlugin._make.resolveEntryModuleFromMain');
        }
      });
  }

  private _getLazyRoutesFromNgtools() {
    try {
      time('AngularCompilerPlugin._getLazyRoutesFromNgtools');
      const result = __NGTOOLS_PRIVATE_API_2.listLazyRoutes({
        program: this._getTsProgram(),
        host: this._compilerHost,
        angularCompilerOptions: Object.assign({}, this._compilerOptions, {
          // genDir seems to still be needed in @angular\compiler-cli\src\compiler_host.js:226.
          genDir: '',
        }),
        // TODO: fix compiler-cli typings; entryModule should not be string, but also optional.
        // tslint:disable-next-line:no-non-null-assertion
        entryModule: this._entryModule !,
      });
      timeEnd('AngularCompilerPlugin._getLazyRoutesFromNgtools');

      return result;
    } catch (err) {
      // We silence the error that the @angular/router could not be found. In that case, there is
      // basically no route supported by the app itself.
      if (err.message.startsWith('Could not resolve module @angular/router')) {
        return {};
      } else {
        throw err;
      }
    }
  }

  private _findLazyRoutesInAst(changedFilePaths: string[]): LazyRouteMap {
    time('AngularCompilerPlugin._findLazyRoutesInAst');
    const result: LazyRouteMap = Object.create(null);
    for (const filePath of changedFilePaths) {
      const fileLazyRoutes = findLazyRoutes(filePath, this._compilerHost, undefined,
        this._compilerOptions);
      for (const routeKey of Object.keys(fileLazyRoutes)) {
        const route = fileLazyRoutes[routeKey];
        result[routeKey] = route;
      }
    }
    timeEnd('AngularCompilerPlugin._findLazyRoutesInAst');

    return result;
  }

  private _listLazyRoutesFromProgram(): LazyRouteMap {
    const ngProgram = this._program as Program;
    if (!ngProgram.listLazyRoutes) {
      throw new Error('_listLazyRoutesFromProgram was called with an old program.');
    }

    const lazyRoutes = ngProgram.listLazyRoutes();

    return lazyRoutes.reduce(
      (acc, curr) => {
        const ref = curr.route;
        if (ref in acc && acc[ref] !== curr.referencedModule.filePath) {
          throw new Error(
            + `Duplicated path in loadChildren detected: "${ref}" is used in 2 loadChildren, `
            + `but they point to different modules "(${acc[ref]} and `
            + `"${curr.referencedModule.filePath}"). Webpack cannot distinguish on context and `
            + 'would fail to load the proper one.',
          );
        }
        acc[ref] = curr.referencedModule.filePath;

        return acc;
      },
      {} as LazyRouteMap,
    );
  }

  // Process the lazy routes discovered, adding then to _lazyRoutes.
  // TODO: find a way to remove lazy routes that don't exist anymore.
  // This will require a registry of known references to a lazy route, removing it when no
  // module references it anymore.
  private _processLazyRoutes(discoveredLazyRoutes: LazyRouteMap) {
    Object.keys(discoveredLazyRoutes)
      .forEach(lazyRouteKey => {
        const [lazyRouteModule, moduleName] = lazyRouteKey.split('#');

        if (!lazyRouteModule) {
          return;
        }

        const lazyRouteTSFile = discoveredLazyRoutes[lazyRouteKey].replace(/\\/g, '/');
        let modulePath: string, moduleKey: string;

        if (this._JitMode) {
          modulePath = lazyRouteTSFile;
          moduleKey = `${lazyRouteModule}${moduleName ? '#' + moduleName : ''}`;
        } else {
          modulePath = lazyRouteTSFile.replace(/(\.d)?\.tsx?$/, '');
          modulePath += '.ngfactory.js';
          const factoryModuleName = moduleName ? `#${moduleName}NgFactory` : '';
          moduleKey = `${lazyRouteModule}.ngfactory${factoryModuleName}`;
        }

        modulePath = workaroundResolve(modulePath);

        if (moduleKey in this._lazyRoutes) {
          if (this._lazyRoutes[moduleKey] !== modulePath) {
            // Found a duplicate, this is an error.
            this._warnings.push(
              new Error(`Duplicated path in loadChildren detected during a rebuild. `
                + `We will take the latest version detected and override it to save rebuild time. `
                + `You should perform a full build to validate that your routes don't overlap.`),
            );
          }
        } else {
          // Found a new route, add it to the map.
          this._lazyRoutes[moduleKey] = modulePath;
        }
      });
  }

  private _createForkedTypeChecker() {
    // Bootstrap type checker is using local CLI.
    const g: any = typeof global !== 'undefined' ? global : {};  // tslint:disable-line:no-any
    const typeCheckerFile: string = g['_DevKitIsLocal']
      ? './type_checker_bootstrap.js'
      : './type_checker_worker.js';

    const debugArgRegex = /--inspect(?:-brk|-port)?|--debug(?:-brk|-port)/;

    const execArgv = process.execArgv.filter((arg) => {
      // Remove debug args.
      // Workaround for https://github.com/nodejs/node/issues/9435
      return !debugArgRegex.test(arg);
    });
    // Signal the process to start listening for messages
    // Solves https://github.com/angular/angular-cli/issues/9071
    const forkArgs = [AUTO_START_ARG];
    const forkOptions: ForkOptions = { execArgv };

    this._typeCheckerProcess = fork(
      path.resolve(__dirname, typeCheckerFile),
      forkArgs,
      forkOptions);

    // Handle child process exit.
    this._typeCheckerProcess.once('exit', (_, signal) => {
      this._typeCheckerProcess = null;

      // If process exited not because of SIGTERM (see _killForkedTypeChecker), than something
      // went wrong and it should fallback to type checking on the main thread.
      if (signal !== 'SIGTERM') {
        this._forkTypeChecker = false;
        const msg = 'AngularCompilerPlugin: Forked Type Checker exited unexpectedly. ' +
          'Falling back to type checking on main thread.';
        this._warnings.push(msg);
      }
    });
  }

  private _killForkedTypeChecker() {
    if (this._typeCheckerProcess && this._typeCheckerProcess.pid) {
      treeKill(this._typeCheckerProcess.pid, 'SIGTERM');
      this._typeCheckerProcess = null;
    }
  }

  private _updateForkedTypeChecker(rootNames: string[], changedCompilationFiles: string[]) {
    if (this._typeCheckerProcess) {
      if (!this._forkedTypeCheckerInitialized) {
        this._typeCheckerProcess.send(new InitMessage(this._compilerOptions, this._basePath,
          this._JitMode, this._rootNames));
        this._forkedTypeCheckerInitialized = true;
      }
      this._typeCheckerProcess.send(new UpdateMessage(rootNames, changedCompilationFiles));
    }
  }

  // Registration hook for webpack plugin.
  apply(compiler: Compiler) {
    // Decorate inputFileSystem to serve contents of CompilerHost.
    // Use decorated inputFileSystem in watchFileSystem.
    compiler.hooks.environment.tap('angular-compiler', () => {
      // The webpack types currently do not include these
      const compilerWithFileSystems = compiler as Compiler & {
        inputFileSystem: InputFileSystem,
        watchFileSystem: NodeWatchFileSystemInterface,
      };

      let host: virtualFs.Host<fs.Stats> = this._options.host || new WebpackInputHost(
        compilerWithFileSystems.inputFileSystem,
      );

      let replacements: Map<Path, Path> | ((path: Path) => Path) | undefined;
      if (this._options.hostReplacementPaths) {
        if (typeof this._options.hostReplacementPaths == 'function') {
          const replacementResolver = this._options.hostReplacementPaths;
          replacements = path => normalize(replacementResolver(getSystemPath(path)));
          host = new class extends virtualFs.ResolverHost<fs.Stats> {
            _resolve(path: Path) {
              return normalize(replacementResolver(getSystemPath(path)));
            }
          }(host);
        } else {
          replacements = new Map();
          const aliasHost = new virtualFs.AliasHost(host);
          for (const from in this._options.hostReplacementPaths) {
            const normalizedFrom = resolve(normalize(this._basePath), normalize(from));
            const normalizedWith = resolve(
              normalize(this._basePath),
              normalize(this._options.hostReplacementPaths[from]),
            );
            aliasHost.aliases.set(normalizedFrom, normalizedWith);
            replacements.set(normalizedFrom, normalizedWith);
          }
          host = aliasHost;
        }
      }

      // Create the webpack compiler host.
      const webpackCompilerHost = new WebpackCompilerHost(
        this._compilerOptions,
        this._basePath,
        host,
      );
      webpackCompilerHost.enableCaching();

      // Create and set a new WebpackResourceLoader.
      this._resourceLoader = new WebpackResourceLoader();
      webpackCompilerHost.setResourceLoader(this._resourceLoader);

      // Use the WebpackCompilerHost with a resource loader to create an AngularCompilerHost.
      this._compilerHost = createCompilerHost({
        options: this._compilerOptions,
        tsHost: webpackCompilerHost,
      }) as CompilerHost & WebpackCompilerHost;

      // Resolve mainPath if provided.
      if (this._options.mainPath) {
        this._mainPath = this._compilerHost.resolve(this._options.mainPath);
      }

      const inputDecorator = new VirtualFileSystemDecorator(
        compilerWithFileSystems.inputFileSystem,
        this._compilerHost,
      );
      compilerWithFileSystems.inputFileSystem = inputDecorator;
      compilerWithFileSystems.watchFileSystem = new VirtualWatchFileSystemDecorator(
        inputDecorator,
        replacements,
      );
    });

    // Add lazy modules to the context module for @angular/core
    compiler.hooks.contextModuleFactory.tap('angular-compiler', cmf => {
      const angularCorePackagePath = require.resolve('@angular/core/package.json');

      // APFv6 does not have single FESM anymore. Instead of verifying if we're pointing to
      // FESMs, we resolve the `@angular/core` path and verify that the path for the
      // module starts with it.

      // This may be slower but it will be compatible with both APF5, 6 and potential future
      // versions (until the dynamic import appears outside of core I suppose).
      // We resolve any symbolic links in order to get the real path that would be used in webpack.
      const angularCoreDirname = fs.realpathSync(path.dirname(angularCorePackagePath));

      cmf.hooks.afterResolve.tapPromise('angular-compiler', async result => {
        // Alter only request from Angular.
        if (!result || !this.done || !result.resource.startsWith(angularCoreDirname)) {
          return result;
        }

        return this.done.then(
          () => {
            // This folder does not exist, but we need to give webpack a resource.
            // TODO: check if we can't just leave it as is (angularCoreModuleDir).
            result.resource = path.join(this._basePath, '$$_lazy_route_resource');
            // tslint:disable-next-line:no-any
            result.dependencies.forEach((d: any) => d.critical = false);
            // tslint:disable-next-line:no-any
            result.resolveDependencies = (_fs: any, options: any, callback: Callback) => {
              const dependencies = Object.keys(this._lazyRoutes)
                .map((key) => {
                  const modulePath = this._lazyRoutes[key];
                  const importPath = key.split('#')[0];
                  if (modulePath !== null) {
                    const name = importPath.replace(/(\.ngfactory)?\.(js|ts)$/, '');

                    return new this._contextElementDependencyConstructor(modulePath, name);
                  } else {
                    return null;
                  }
                })
                .filter(x => !!x);

              if (this._options.nameLazyFiles) {
                options.chunkName = '[request]';
              }

              callback(null, dependencies);
            };

            return result;
          },
          () => undefined,
        );
      });
    });

    // Create and destroy forked type checker on watch mode.
    compiler.hooks.watchRun.tap('angular-compiler', () => {
      if (this._forkTypeChecker && !this._typeCheckerProcess) {
        this._createForkedTypeChecker();
      }
    });
    compiler.hooks.watchClose.tap('angular-compiler', () => this._killForkedTypeChecker());

    // Remake the plugin on each compilation.
    compiler.hooks.make.tapPromise('angular-compiler', compilation => this._make(compilation));
    compiler.hooks.invalid.tap('angular-compiler', () => this._firstRun = false);
    compiler.hooks.afterEmit.tap('angular-compiler', compilation => {
      // tslint:disable-next-line:no-any
      (compilation as any)._ngToolsWebpackPluginInstance = null;
    });
    compiler.hooks.done.tap('angular-compiler', () => {
      this._donePromise = null;
    });

    compiler.hooks.afterResolvers.tap('angular-compiler', compiler => {
      compiler.hooks.normalModuleFactory.tap('angular-compiler', nmf => {
        // Virtual file system.
        // TODO: consider if it's better to remove this plugin and instead make it wait on the
        // VirtualFileSystemDecorator.
        // Wait for the plugin to be done when requesting `.ts` files directly (entry points), or
        // when the issuer is a `.ts` or `.ngfactory.js` file.
        nmf.hooks.beforeResolve.tapPromise(
          'angular-compiler',
          async (request?: NormalModuleFactoryRequest) => {
            if (this.done && request) {
              const name = request.request;
              const issuer = request.contextInfo.issuer;
              if (name.endsWith('.ts') || name.endsWith('.tsx')
                  || (issuer && /\.ts|ngfactory\.js$/.test(issuer))) {
                try {
                  await this.done;
                } catch {}
              }
            }

            return request;
          },
        );
      });
    });

    compiler.hooks.normalModuleFactory.tap('angular-compiler', nmf => {
      nmf.hooks.beforeResolve.tapAsync(
        'angular-compiler',
        (request: NormalModuleFactoryRequest, callback: Callback<NormalModuleFactoryRequest>) => {
          resolveWithPaths(
            request,
            callback,
            this._compilerOptions,
            this._compilerHost,
            this._moduleResolutionCache,
          );
        },
      );
    });
  }

  private async _make(compilation: compilation.Compilation) {
    time('AngularCompilerPlugin._make');
    this._emitSkipped = true;
    // tslint:disable-next-line:no-any
    if ((compilation as any)._ngToolsWebpackPluginInstance) {
      throw new Error('An @ngtools/webpack plugin already exist for this compilation.');
    }

    // Set a private variable for this plugin instance.
    // tslint:disable-next-line:no-any
    (compilation as any)._ngToolsWebpackPluginInstance = this;

    // Update the resource loader with the new webpack compilation.
    this._resourceLoader.update(compilation);

    return this._donePromise = Promise.resolve()
      .then(() => this._update())
      .then(() => {
        this.pushCompilationErrors(compilation);
        timeEnd('AngularCompilerPlugin._make');
      }, err => {
        compilation.errors.push(err);
        this.pushCompilationErrors(compilation);
        timeEnd('AngularCompilerPlugin._make');
      });
  }

  private pushCompilationErrors(compilation: compilation.Compilation) {
    compilation.errors.push(...this._errors);
    compilation.warnings.push(...this._warnings);
    this._errors = [];
    this._warnings = [];
  }

  private _makeTransformers() {
    const isAppPath = (fileName: string) =>
      !fileName.endsWith('.ngfactory.ts') && !fileName.endsWith('.ngstyle.ts');
    const isMainPath = (fileName: string) => fileName === (
      this._mainPath ? workaroundResolve(this._mainPath) : this._mainPath
    );
    const getEntryModule = () => this.entryModule
      ? { path: workaroundResolve(this.entryModule.path), className: this.entryModule.className }
      : this.entryModule;
    const getLazyRoutes = () => this._lazyRoutes;
    const getTypeChecker = () => this._getTsProgram().getTypeChecker();

    if (this._JitMode) {
      // Replace resources in JIT.
      this._transformers.push(replaceResources(isAppPath));
    } else {
      // Remove unneeded angular decorators.
      this._transformers.push(removeDecorators(isAppPath, getTypeChecker));
    }

    if (this._platformTransformers !== null) {
      this._transformers.push(...this._platformTransformers);
    } else {
      if (this._platform === PLATFORM.Browser) {
        // If we have a locale, auto import the locale data file.
        // This transform must go before replaceBootstrap because it looks for the entry module
        // import, which will be replaced.
        if (this._normalizedLocale) {
          this._transformers.push(registerLocaleData(isAppPath, getEntryModule,
            this._normalizedLocale));
        }

        if (!this._JitMode) {
          // Replace bootstrap in browser AOT.
          this._transformers.push(replaceBootstrap(isAppPath, getEntryModule, getTypeChecker));
        }
      } else if (this._platform === PLATFORM.Server) {
        this._transformers.push(exportLazyModuleMap(isMainPath, getLazyRoutes));
        if (!this._JitMode) {
          this._transformers.push(
            exportNgFactory(isMainPath, getEntryModule),
            replaceServerBootstrap(isMainPath, getEntryModule, getTypeChecker));
        }
      }
    }
  }

  private _update() {
    time('AngularCompilerPlugin._update');
    // We only want to update on TS and template changes, but all kinds of files are on this
    // list, like package.json and .ngsummary.json files.
    const changedFiles = this._getChangedCompilationFiles();

    // If nothing we care about changed and it isn't the first run, don't do anything.
    if (changedFiles.length === 0 && !this._firstRun) {
      return Promise.resolve();
    }

    return Promise.resolve()
      // Make a new program and load the Angular structure.
      .then(() => this._createOrUpdateProgram())
      .then(() => {
        if (this.entryModule) {
          // Try to find lazy routes if we have an entry module.
          // We need to run the `listLazyRoutes` the first time because it also navigates libraries
          // and other things that we might miss using the (faster) findLazyRoutesInAst.
          // Lazy routes modules will be read with compilerHost and added to the changed files.
          const changedTsFiles = this._getChangedTsFiles();
          if (this._ngCompilerSupportsNewApi) {
            this._processLazyRoutes(this._listLazyRoutesFromProgram());
          } else if (this._firstRun) {
            this._processLazyRoutes(this._getLazyRoutesFromNgtools());
          } else if (changedTsFiles.length > 0) {
            this._processLazyRoutes(this._findLazyRoutesInAst(changedTsFiles));
          }
          if (this._options.additionalLazyModules) {
            this._processLazyRoutes(this._options.additionalLazyModules);
          }
        }
      })
      .then(() => {
        // Emit and report errors.

        // We now have the final list of changed TS files.
        // Go through each changed file and add transforms as needed.
        const sourceFiles = this._getChangedTsFiles()
          .map((fileName) => this._getTsProgram().getSourceFile(fileName))
          // At this point we shouldn't need to filter out undefined files, because any ts file
          // that changed should be emitted.
          // But due to hostReplacementPaths there can be files (the environment files)
          // that changed but aren't part of the compilation, specially on `ng test`.
          // So we ignore missing source files files here.
          // hostReplacementPaths needs to be fixed anyway to take care of the following issue.
          // https://github.com/angular/angular-cli/issues/7305#issuecomment-332150230
          .filter((x) => !!x) as ts.SourceFile[];

        // Emit files.
        time('AngularCompilerPlugin._update._emit');
        const { emitResult, diagnostics } = this._emit(sourceFiles);
        timeEnd('AngularCompilerPlugin._update._emit');

        // Report diagnostics.
        const errors = diagnostics
          .filter((diag) => diag.category === ts.DiagnosticCategory.Error);
        const warnings = diagnostics
          .filter((diag) => diag.category === ts.DiagnosticCategory.Warning);

        if (errors.length > 0) {
          const message = formatDiagnostics(errors);
          this._errors.push(new Error(message));
        }

        if (warnings.length > 0) {
          const message = formatDiagnostics(warnings);
          this._warnings.push(message);
        }

        this._emitSkipped = !emitResult || emitResult.emitSkipped;

        // Reset changed files on successful compilation.
        if (!this._emitSkipped && this._errors.length === 0) {
          this._compilerHost.resetChangedFileTracker();
        }
        timeEnd('AngularCompilerPlugin._update');
      });
  }

  writeI18nOutFile() {
    function _recursiveMkDir(p: string) {
      if (!fs.existsSync(p)) {
        _recursiveMkDir(path.dirname(p));
        fs.mkdirSync(p);
      }
    }

    // Write the extracted messages to disk.
    if (this._compilerOptions.i18nOutFile) {
      const i18nOutFilePath = path.resolve(this._basePath, this._compilerOptions.i18nOutFile);
      const i18nOutFileContent = this._compilerHost.readFile(i18nOutFilePath);
      if (i18nOutFileContent) {
        _recursiveMkDir(path.dirname(i18nOutFilePath));
        fs.writeFileSync(i18nOutFilePath, i18nOutFileContent);
      }
    }
  }

  getCompiledFile(fileName: string) {
    const outputFile = fileName.replace(/.tsx?$/, '.js');
    let outputText: string;
    let sourceMap: string | undefined;
    let errorDependencies: string[] = [];

    if (this._emitSkipped) {
      const text = this._compilerHost.readFile(outputFile);
      if (text) {
        // If the compilation didn't emit files this time, try to return the cached files from the
        // last compilation and let the compilation errors show what's wrong.
        outputText = text;
        sourceMap = this._compilerHost.readFile(outputFile + '.map');
      } else {
        // There's nothing we can serve. Return an empty string to prevent lenghty webpack errors,
        // add the rebuild warning if it's not there yet.
        // We also need to all changed files as dependencies of this file, so that all of them
        // will be watched and trigger a rebuild next time.
        outputText = '';
        errorDependencies = this._getChangedCompilationFiles()
          // These paths are used by the loader so we must denormalize them.
          .map((p) => this._compilerHost.denormalizePath(p));
      }
    } else {
      // Check if the TS input file and the JS output file exist.
      if (((fileName.endsWith('.ts') || fileName.endsWith('.tsx'))
        && !this._compilerHost.fileExists(fileName, false))
        || !this._compilerHost.fileExists(outputFile, false)) {
        let msg = `${fileName} is missing from the TypeScript compilation. `
          + `Please make sure it is in your tsconfig via the 'files' or 'include' property.`;

        if (/(\\|\/)node_modules(\\|\/)/.test(fileName)) {
          msg += '\nThe missing file seems to be part of a third party library. '
            + 'TS files in published libraries are often a sign of a badly packaged library. '
            + 'Please open an issue in the library repository to alert its author and ask them '
            + 'to package the library using the Angular Package Format (https://goo.gl/jB3GVv).';
        }

        throw new Error(msg);
      }

      outputText = this._compilerHost.readFile(outputFile) || '';
      sourceMap = this._compilerHost.readFile(outputFile + '.map');
    }

    return { outputText, sourceMap, errorDependencies };
  }

  getDependencies(fileName: string): string[] {
    const resolvedFileName = this._compilerHost.resolve(fileName);
    const sourceFile = this._compilerHost.getSourceFile(resolvedFileName, ts.ScriptTarget.Latest);
    if (!sourceFile) {
      return [];
    }

    const options = this._compilerOptions;
    const host = this._compilerHost;
    const cache = this._moduleResolutionCache;

    const esImports = collectDeepNodes<ts.ImportDeclaration>(sourceFile,
      ts.SyntaxKind.ImportDeclaration)
      .map(decl => {
        const moduleName = (decl.moduleSpecifier as ts.StringLiteral).text;
        const resolved = ts.resolveModuleName(moduleName, resolvedFileName, options, host, cache);

        if (resolved.resolvedModule) {
          return resolved.resolvedModule.resolvedFileName;
        } else {
          return null;
        }
      })
      .filter(x => x);

    const resourceImports = findResources(sourceFile)
      .map((resourceReplacement) => resourceReplacement.resourcePaths)
      .reduce((prev, curr) => prev.concat(curr), [])
      .map((resourcePath) => resolve(dirname(resolvedFileName), normalize(resourcePath)));

    // These paths are meant to be used by the loader so we must denormalize them.
    const uniqueDependencies = new Set([
      ...esImports,
      ...resourceImports,
      ...this.getResourceDependencies(this._compilerHost.denormalizePath(resolvedFileName)),
    ].map((p) => p && this._compilerHost.denormalizePath(p)));

    return [...uniqueDependencies]
      .filter(x => !!x) as string[];
  }

  getResourceDependencies(fileName: string): string[] {
    return this._resourceLoader.getResourceDependencies(fileName);
  }

  // This code mostly comes from `performCompilation` in `@angular/compiler-cli`.
  // It skips the program creation because we need to use `loadNgStructureAsync()`,
  // and uses CustomTransformers.
  private _emit(sourceFiles: ts.SourceFile[]) {
    time('AngularCompilerPlugin._emit');
    const program = this._program;
    const allDiagnostics: Array<ts.Diagnostic | Diagnostic> = [];

    let emitResult: ts.EmitResult | undefined;
    try {
      if (this._JitMode) {
        const tsProgram = program as ts.Program;

        if (this._firstRun) {
          // Check parameter diagnostics.
          time('AngularCompilerPlugin._emit.ts.getOptionsDiagnostics');
          allDiagnostics.push(...tsProgram.getOptionsDiagnostics());
          timeEnd('AngularCompilerPlugin._emit.ts.getOptionsDiagnostics');
        }

        if ((this._firstRun || !this._forkTypeChecker) && this._program) {
          allDiagnostics.push(...gatherDiagnostics(this._program, this._JitMode,
            'AngularCompilerPlugin._emit.ts'));
        }

        if (!hasErrors(allDiagnostics)) {
          sourceFiles.forEach((sf) => {
            const timeLabel = `AngularCompilerPlugin._emit.ts+${sf.fileName}+.emit`;
            time(timeLabel);
            emitResult = tsProgram.emit(sf, undefined, undefined, undefined,
              { before: this._transformers },
            );
            allDiagnostics.push(...emitResult.diagnostics);
            timeEnd(timeLabel);
          });
        }
      } else {
        const angularProgram = program as Program;

        // Check Angular structural diagnostics.
        time('AngularCompilerPlugin._emit.ng.getNgStructuralDiagnostics');
        allDiagnostics.push(...angularProgram.getNgStructuralDiagnostics());
        timeEnd('AngularCompilerPlugin._emit.ng.getNgStructuralDiagnostics');

        if (this._firstRun) {
          // Check TypeScript parameter diagnostics.
          time('AngularCompilerPlugin._emit.ng.getTsOptionDiagnostics');
          allDiagnostics.push(...angularProgram.getTsOptionDiagnostics());
          timeEnd('AngularCompilerPlugin._emit.ng.getTsOptionDiagnostics');

          // Check Angular parameter diagnostics.
          time('AngularCompilerPlugin._emit.ng.getNgOptionDiagnostics');
          allDiagnostics.push(...angularProgram.getNgOptionDiagnostics());
          timeEnd('AngularCompilerPlugin._emit.ng.getNgOptionDiagnostics');
        }

        if ((this._firstRun || !this._forkTypeChecker) && this._program) {
          allDiagnostics.push(...gatherDiagnostics(this._program, this._JitMode,
            'AngularCompilerPlugin._emit.ng'));
        }

        if (!hasErrors(allDiagnostics)) {
          time('AngularCompilerPlugin._emit.ng.emit');
          const extractI18n = !!this._compilerOptions.i18nOutFile;
          const emitFlags = extractI18n ? EmitFlags.I18nBundle : EmitFlags.Default;
          emitResult = angularProgram.emit({
            emitFlags, customTransformers: {
              beforeTs: this._transformers,
            },
          });
          allDiagnostics.push(...emitResult.diagnostics);
          if (extractI18n) {
            this.writeI18nOutFile();
          }
          timeEnd('AngularCompilerPlugin._emit.ng.emit');
        }
      }
    } catch (e) {
      time('AngularCompilerPlugin._emit.catch');
      // This function is available in the import below, but this way we avoid the dependency.
      // import { isSyntaxError } from '@angular/compiler';
      function isSyntaxError(error: Error): boolean {
        return (error as any)['ngSyntaxError'];  // tslint:disable-line:no-any
      }

      let errMsg: string;
      let code: number;
      if (isSyntaxError(e)) {
        // don't report the stack for syntax errors as they are well known errors.
        errMsg = e.message;
        code = DEFAULT_ERROR_CODE;
      } else {
        errMsg = e.stack;
        // It is not a syntax error we might have a program with unknown state, discard it.
        this._program = null;
        code = UNKNOWN_ERROR_CODE;
      }
      allDiagnostics.push(
        { category: ts.DiagnosticCategory.Error, messageText: errMsg, code, source: SOURCE });
      timeEnd('AngularCompilerPlugin._emit.catch');
    }
    timeEnd('AngularCompilerPlugin._emit');

    return { program, emitResult, diagnostics: allDiagnostics };
  }

  private _validateLocale(locale: string): string | null {
    // Get the path of the common module.
    const commonPath = path.dirname(require.resolve('@angular/common/package.json'));
    // Check if the locale file exists
    if (!fs.existsSync(path.resolve(commonPath, 'locales', `${locale}.js`))) {
      // Check for an alternative locale (if the locale id was badly formatted).
      const locales = fs.readdirSync(path.resolve(commonPath, 'locales'))
        .filter(file => file.endsWith('.js'))
        .map(file => file.replace('.js', ''));

      let newLocale;
      const normalizedLocale = locale.toLowerCase().replace(/_/g, '-');
      for (const l of locales) {
        if (l.toLowerCase() === normalizedLocale) {
          newLocale = l;
          break;
        }
      }

      if (newLocale) {
        locale = newLocale;
      } else {
        // Check for a parent locale
        const parentLocale = normalizedLocale.split('-')[0];
        if (locales.indexOf(parentLocale) !== -1) {
          locale = parentLocale;
        } else {
          this._warnings.push(`AngularCompilerPlugin: Unable to load the locale data file ` +
            `"@angular/common/locales/${locale}", ` +
            `please check that "${locale}" is a valid locale id.
            If needed, you can use "registerLocaleData" manually.`);

          return null;
        }
      }
    }

    return locale;
  }
}
