/**
 * @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 {AotCompilerHost, AotCompilerOptions, createAotCompiler, GeneratedFile, toTypeScript} from '@angular/compiler';
import {MetadataBundlerHost} from '@angular/compiler-cli/src/metadata/bundler';
import {MetadataCollector} from '@angular/compiler-cli/src/metadata/collector';
import {ModuleMetadata} from '@angular/compiler-cli/src/metadata/index';
import {getCachedSourceFile} from '@angular/compiler-cli/test/helpers';
import {newArray} from '@angular/compiler/src/util';
import * as fs from 'fs';
import * as path from 'path';
import * as ts from 'typescript';

export interface MetadataProvider {
  getMetadata(source: ts.SourceFile): ModuleMetadata|undefined;
}

let nodeModulesPath: string;
let angularSourcePath: string;
let rootPath: string;

calcPathsOnDisc();

export type MockFileOrDirectory = string|MockDirectory;

export type MockDirectory = {
  [name: string]: MockFileOrDirectory|undefined;
};

export function isDirectory(data: MockFileOrDirectory|undefined): data is MockDirectory {
  return typeof data !== 'string';
}

const rxjs = /\/rxjs\//;
export const settings: ts.CompilerOptions = {
  target: ts.ScriptTarget.ES5,
  declaration: true,
  module: ts.ModuleKind.CommonJS,
  moduleResolution: ts.ModuleResolutionKind.NodeJs,
  emitDecoratorMetadata: true,
  experimentalDecorators: true,
  removeComments: false,
  noImplicitAny: false,
  skipLibCheck: true,
  strictNullChecks: true,
  lib: ['lib.es2015.d.ts', 'lib.dom.d.ts'],
  types: []
};

export interface EmitterOptions {
  emitMetadata: boolean;
  mockData?: MockDirectory;
  context?: Map<string, string>;
}

function calcPathsOnDisc() {
  const moduleFilename = module.filename.replace(/\\/g, '/');
  const distIndex = moduleFilename.indexOf('/dist/all');
  if (distIndex >= 0) {
    rootPath = moduleFilename.substr(0, distIndex);
    nodeModulesPath = path.join(rootPath, 'node_modules');
    angularSourcePath = path.join(rootPath, 'packages');
  }
}


export class EmittingCompilerHost implements ts.CompilerHost {
  private addedFiles = new Map<string, string>();
  private writtenFiles = new Map<string, string>();
  private scriptNames: string[];
  private root = '/';
  private collector = new MetadataCollector();
  private cachedAddedDirectories: Set<string>|undefined;

  constructor(scriptNames: string[], private options: EmitterOptions) {
    // Rewrite references to scripts with '@angular' to its corresponding location in
    // the source tree.
    this.scriptNames = scriptNames.map(f => this.effectiveName(f));
    this.root = rootPath || this.root;
    if (options.context) {
      this.addedFiles = mergeMaps(options.context);
    }
  }

  public writtenAngularFiles(target = new Map<string, string>()): Map<string, string> {
    this.written.forEach((value, key) => {
      const path = `/node_modules/@angular${key.substring(angularSourcePath.length)}`;
      target.set(path, value);
    });
    return target;
  }

  public addScript(fileName: string, content: string) {
    const scriptName = this.effectiveName(fileName);
    this.addedFiles.set(scriptName, content);
    this.cachedAddedDirectories = undefined;
    this.scriptNames.push(scriptName);
  }

  public override(fileName: string, content: string) {
    const scriptName = this.effectiveName(fileName);
    this.addedFiles.set(scriptName, content);
    this.cachedAddedDirectories = undefined;
  }

  public addFiles(map: Map<string, string>) {
    for (const [name, content] of Array.from(map.entries())) {
      this.addedFiles.set(name, content);
    }
  }

  public addWrittenFile(fileName: string, content: string) {
    this.writtenFiles.set(this.effectiveName(fileName), content);
  }

  public getWrittenFiles(): {name: string, content: string}[] {
    return Array.from(this.writtenFiles).map(f => ({name: f[0], content: f[1]}));
  }

  public get scripts(): string[] {
    return this.scriptNames;
  }

  public get written(): Map<string, string> {
    return this.writtenFiles;
  }

  public effectiveName(fileName: string): string {
    const prefix = '@angular/';
    return angularSourcePath && fileName.startsWith(prefix) ?
        path.join(angularSourcePath, fileName.substr(prefix.length)) :
        fileName;
  }

  // ts.ModuleResolutionHost
  fileExists(fileName: string): boolean {
    return this.addedFiles.has(fileName) || open(fileName, this.options.mockData) != null ||
        fs.existsSync(fileName);
  }

  readFile(fileName: string): string {
    const result = this.addedFiles.get(fileName) || open(fileName, this.options.mockData);
    if (result) return result;

    let basename = path.basename(fileName);
    if (/^lib.*\.d\.ts$/.test(basename)) {
      let libPath = ts.getDefaultLibFilePath(settings);
      return fs.readFileSync(path.join(path.dirname(libPath), basename), 'utf8');
    }
    return fs.readFileSync(fileName, 'utf8');
  }

  directoryExists(directoryName: string): boolean {
    return directoryExists(directoryName, this.options.mockData) ||
        this.getAddedDirectories().has(directoryName) ||
        (fs.existsSync(directoryName) && fs.statSync(directoryName).isDirectory());
  }

  getCurrentDirectory(): string {
    return this.root;
  }

  getDirectories(dir: string): string[] {
    const result = open(dir, this.options.mockData);
    if (result && typeof result !== 'string') {
      return Object.keys(result);
    }
    return fs.readdirSync(dir).filter(p => {
      const name = path.join(dir, p);
      const stat = fs.statSync(name);
      return stat && stat.isDirectory();
    });
  }

  // ts.CompilerHost
  getSourceFile(
      fileName: string, languageVersion: ts.ScriptTarget,
      onError?: (message: string) => void): ts.SourceFile {
    const content = this.readFile(fileName);
    if (content) {
      const cachedSf = getCachedSourceFile(fileName, () => content);
      if (cachedSf !== null) {
        return cachedSf;
      }
      return ts.createSourceFile(fileName, content, languageVersion, /* setParentNodes */ true);
    }
    throw new Error(`File not found '${fileName}'.`);
  }

  getDefaultLibFileName(options: ts.CompilerOptions): string {
    return 'lib.d.ts';
  }

  writeFile: ts.WriteFileCallback =
      (fileName: string, data: string, writeByteOrderMark: boolean,
       onError?: (message: string) => void, sourceFiles?: ReadonlyArray<ts.SourceFile>) => {
        this.addWrittenFile(fileName, data);
        if (this.options.emitMetadata && sourceFiles && sourceFiles.length && DTS.test(fileName)) {
          const metadataFilePath = fileName.replace(DTS, '.metadata.json');
          const metadata = this.collector.getMetadata(sourceFiles[0]);
          if (metadata) {
            this.addWrittenFile(metadataFilePath, JSON.stringify(metadata));
          }
        }
      }

  getCanonicalFileName(fileName: string): string {
    return fileName;
  }
  useCaseSensitiveFileNames(): boolean {
    return false;
  }
  getNewLine(): string {
    return '\n';
  }

  private getAddedDirectories(): Set<string> {
    let result = this.cachedAddedDirectories;
    if (!result) {
      const newCache = new Set<string>();
      const addFile = (fileName: string) => {
        const directory = fileName.substr(0, fileName.lastIndexOf('/'));
        if (!newCache.has(directory)) {
          newCache.add(directory);
          addFile(directory);
        }
      };
      Array.from(this.addedFiles.keys()).forEach(addFile);
      this.cachedAddedDirectories = result = newCache;
    }
    return result;
  }
}

export class MockCompilerHost implements ts.CompilerHost {
  scriptNames: string[];

  public overrides = new Map<string, string>();
  public writtenFiles = new Map<string, string>();
  private sourceFiles = new Map<string, ts.SourceFile>();
  private assumeExists = new Set<string>();
  private traces: string[] = [];

  constructor(scriptNames: string[], private data: MockDirectory) {
    this.scriptNames = [...scriptNames];
  }

  // Test API
  override(fileName: string, content: string) {
    if (content) {
      this.overrides.set(fileName, content);
    } else {
      this.overrides.delete(fileName);
    }
    this.sourceFiles.delete(fileName);
  }

  addScript(fileName: string, content: string) {
    this.overrides.set(fileName, content);
    this.scriptNames.push(fileName);
    this.sourceFiles.delete(fileName);
  }

  assumeFileExists(fileName: string) {
    this.assumeExists.add(fileName);
  }

  remove(files: string[]) {
    // Remove the files from the list of scripts.
    const fileSet = new Set(files);
    this.scriptNames = this.scriptNames.filter(f => fileSet.has(f));

    // Remove files from written files
    files.forEach(f => this.writtenFiles.delete(f));
  }

  // ts.ModuleResolutionHost
  fileExists(fileName: string): boolean {
    if (this.overrides.has(fileName) || this.writtenFiles.has(fileName) ||
        this.assumeExists.has(fileName)) {
      return true;
    }
    const effectiveName = this.getEffectiveName(fileName);
    if (effectiveName == fileName) {
      return open(fileName, this.data) != null;
    }
    if (fileName.match(rxjs)) {
      return fs.existsSync(effectiveName);
    }
    return false;
  }

  readFile(fileName: string): string {
    return this.getFileContent(fileName)!;
  }

  trace(s: string): void {
    this.traces.push(s);
  }

  getCurrentDirectory(): string {
    return '/';
  }

  getDirectories(dir: string): string[] {
    const effectiveName = this.getEffectiveName(dir);
    if (effectiveName === dir) {
      const data = find(dir, this.data);
      if (isDirectory(data)) {
        return Object.keys(data).filter(k => isDirectory(data[k]));
      }
    }
    return [];
  }

  // ts.CompilerHost
  getSourceFile(
      fileName: string, languageVersion: ts.ScriptTarget,
      onError?: (message: string) => void): ts.SourceFile|undefined {
    let result = this.sourceFiles.get(fileName);
    if (!result) {
      const content = this.getFileContent(fileName);
      const cachedSf = getCachedSourceFile(fileName, () => content);
      if (cachedSf !== null) {
        return cachedSf;
      }
      if (content) {
        result = ts.createSourceFile(fileName, content, languageVersion);
        this.sourceFiles.set(fileName, result);
      }
    }
    return result;
  }

  getDefaultLibFileName(options: ts.CompilerOptions): string {
    return 'lib.d.ts';
  }

  writeFile: ts.WriteFileCallback =
      (fileName: string, data: string, writeByteOrderMark: boolean) => {
        this.writtenFiles.set(fileName, data);
        this.sourceFiles.delete(fileName);
      }

  getCanonicalFileName(fileName: string): string {
    return fileName;
  }
  useCaseSensitiveFileNames(): boolean {
    return false;
  }
  getNewLine(): string {
    return '\n';
  }

  // Private methods
  private getFileContent(fileName: string): string|undefined {
    if (this.overrides.has(fileName)) {
      return this.overrides.get(fileName);
    }
    if (this.writtenFiles.has(fileName)) {
      return this.writtenFiles.get(fileName);
    }
    let basename = path.basename(fileName);
    if (/^lib.*\.d\.ts$/.test(basename)) {
      let libPath = ts.getDefaultLibFilePath(settings);
      return fs.readFileSync(path.join(path.dirname(libPath), basename), 'utf8');
    }
    let effectiveName = this.getEffectiveName(fileName);
    if (effectiveName === fileName) {
      return open(fileName, this.data);
    }
    if (fileName.match(rxjs) && fs.existsSync(fileName)) {
      return fs.readFileSync(fileName, 'utf8');
    }
  }

  private getEffectiveName(name: string): string {
    const node_modules = 'node_modules';
    const rxjs = '/rxjs';
    if (name.startsWith('/' + node_modules)) {
      if (nodeModulesPath && name.startsWith('/' + node_modules + rxjs)) {
        return path.join(nodeModulesPath, name.substr(node_modules.length + 1));
      }
    }
    return name;
  }
}

const EXT = /(\.ts|\.d\.ts|\.js|\.jsx|\.tsx)$/;
const DTS = /\.d\.ts$/;
const GENERATED_FILES = /\.ngfactory\.ts$|\.ngstyle\.ts$/;

export class MockAotCompilerHost implements AotCompilerHost {
  private metadataVisible: boolean = true;
  private dtsAreSource: boolean = true;
  private resolveModuleNameHost: ts.ModuleResolutionHost;

  constructor(
      private tsHost: MockCompilerHost,
      private metadataProvider: MetadataProvider = new MetadataCollector()) {
    this.resolveModuleNameHost = Object.create(tsHost);
    this.resolveModuleNameHost.fileExists = (fileName) => {
      fileName = stripNgResourceSuffix(fileName);
      return tsHost.fileExists(fileName);
    };
  }

  hideMetadata() {
    this.metadataVisible = false;
  }

  tsFilesOnly() {
    this.dtsAreSource = false;
  }

  // StaticSymbolResolverHost
  getMetadataFor(modulePath: string): {[key: string]: any}[]|undefined {
    if (!this.tsHost.fileExists(modulePath)) {
      return undefined;
    }
    if (DTS.test(modulePath)) {
      if (this.metadataVisible) {
        const metadataPath = modulePath.replace(DTS, '.metadata.json');
        if (this.tsHost.fileExists(metadataPath)) {
          let result = JSON.parse(this.tsHost.readFile(metadataPath));
          return Array.isArray(result) ? result : [result];
        }
      }
    } else {
      const sf = this.tsHost.getSourceFile(modulePath, ts.ScriptTarget.Latest);
      const metadata = sf && this.metadataProvider.getMetadata(sf);
      return metadata ? [metadata] : [];
    }
    return undefined;
  }

  moduleNameToFileName(moduleName: string, containingFile: string): string|null {
    if (!containingFile || !containingFile.length) {
      if (moduleName.indexOf('.') === 0) {
        throw new Error('Resolution of relative paths requires a containing file.');
      }
      // Any containing file gives the same result for absolute imports
      containingFile = path.join('/', 'index.ts');
    }
    moduleName = moduleName.replace(EXT, '');
    const resolved = ts.resolveModuleName(
                           moduleName, containingFile.replace(/\\/g, '/'),
                           {baseDir: '/', genDir: '/'}, this.resolveModuleNameHost)
                         .resolvedModule;
    return resolved ? resolved.resolvedFileName : null;
  }

  getOutputName(filePath: string) {
    return filePath;
  }

  resourceNameToFileName(resourceName: string, containingFile: string) {
    // Note: we convert package paths into relative paths to be compatible with the the
    // previous implementation of UrlResolver.
    if (resourceName && resourceName.charAt(0) !== '.' && !path.isAbsolute(resourceName)) {
      resourceName = `./${resourceName}`;
    }
    const filePathWithNgResource =
        this.moduleNameToFileName(addNgResourceSuffix(resourceName), containingFile);
    return filePathWithNgResource ? stripNgResourceSuffix(filePathWithNgResource) : null;
  }

  // AotSummaryResolverHost
  loadSummary(filePath: string): string|null {
    return this.tsHost.readFile(filePath);
  }

  isSourceFile(sourceFilePath: string): boolean {
    return !GENERATED_FILES.test(sourceFilePath) &&
        (this.dtsAreSource || !DTS.test(sourceFilePath));
  }

  toSummaryFileName(filePath: string): string {
    return filePath.replace(EXT, '') + '.d.ts';
  }

  fromSummaryFileName(filePath: string): string {
    return filePath;
  }

  // AotCompilerHost
  fileNameToModuleName(importedFile: string, containingFile: string): string {
    return importedFile.replace(EXT, '');
  }

  loadResource(path: string): string {
    if (this.tsHost.fileExists(path)) {
      return this.tsHost.readFile(path);
    } else {
      throw new Error(`Resource ${path} not found.`);
    }
  }
}

export class MockMetadataBundlerHost implements MetadataBundlerHost {
  private collector = new MetadataCollector();

  constructor(private host: ts.CompilerHost) {}

  getMetadataFor(moduleName: string): ModuleMetadata|undefined {
    const source = this.host.getSourceFile(moduleName + '.ts', ts.ScriptTarget.Latest);
    return source && this.collector.getMetadata(source);
  }
}

function find(fileName: string, data: MockFileOrDirectory|undefined): MockFileOrDirectory|
    undefined {
  if (!data) return undefined;
  const names = fileName.split('/');
  if (names.length && !names[0].length) names.shift();
  let current: MockFileOrDirectory|undefined = data;
  for (const name of names) {
    if (typeof current !== 'object') {
      return undefined;
    }
    current = current[name];
  }
  return current;
}

function open(fileName: string, data: MockFileOrDirectory|undefined): string|undefined {
  let result = find(fileName, data);
  if (typeof result === 'string') {
    return result;
  }
  return undefined;
}

function directoryExists(dirname: string, data: MockFileOrDirectory|undefined): boolean {
  let result = find(dirname, data);
  return !!result && typeof result !== 'string';
}

export type MockFileArray = {
  fileName: string,
  content: string
}[];

export type MockData = MockDirectory|Map<string, string>|(MockDirectory|Map<string, string>)[];

export function toMockFileArray(data: MockData, target: MockFileArray = []): MockFileArray {
  if (data instanceof Map) {
    mapToMockFileArray(data, target);
  } else if (Array.isArray(data)) {
    data.forEach(entry => toMockFileArray(entry, target));
  } else {
    mockDirToFileArray(data, '', target);
  }
  return target;
}

function mockDirToFileArray(dir: MockDirectory, path: string, target: MockFileArray) {
  Object.keys(dir).forEach((localFileName) => {
    const value = dir[localFileName]!;
    const fileName = `${path}/${localFileName}`;
    if (typeof value === 'string') {
      target.push({fileName, content: value});
    } else {
      mockDirToFileArray(value, fileName, target);
    }
  });
}

function mapToMockFileArray(files: Map<string, string>, target: MockFileArray) {
  files.forEach((content, fileName) => {
    target.push({fileName, content});
  });
}

export function arrayToMockMap(arr: MockFileArray): Map<string, string> {
  const map = new Map<string, string>();
  arr.forEach(({fileName, content}) => {
    map.set(fileName, content);
  });
  return map;
}

export function arrayToMockDir(arr: MockFileArray): MockDirectory {
  const rootDir: MockDirectory = {};
  arr.forEach(({fileName, content}) => {
    let pathParts = fileName.split('/');
    // trim trailing slash
    let startIndex = pathParts[0] ? 0 : 1;
    // get/create the directory
    let currentDir = rootDir;
    for (let i = startIndex; i < pathParts.length - 1; i++) {
      const pathPart = pathParts[i];
      let localDir = <MockDirectory>currentDir[pathPart];
      if (!localDir) {
        currentDir[pathPart] = localDir = {};
      }
      currentDir = localDir;
    }
    // write the file
    currentDir[pathParts[pathParts.length - 1]] = content;
  });
  return rootDir;
}

const minCoreIndex = `
  export * from './src/application_module';
  export * from './src/change_detection';
  export * from './src/metadata';
  export * from './src/di/metadata';
  export * from './src/di/injectable';
  export * from './src/di/injector';
  export * from './src/di/injection_token';
  export * from './src/linker';
  export * from './src/render';
  export * from './src/codegen_private_exports';
`;

function readBazelWrittenFilesFrom(
    bazelPackageRoot: string, packageName: string, map: Map<string, string>,
    skip: (name: string, fullName: string) => boolean = () => false) {
  function processDirectory(dir: string, dest: string) {
    const entries = fs.readdirSync(dir);
    for (const name of entries) {
      const fullName = path.posix.join(dir, name);
      const destName = path.posix.join(dest, name);
      const stat = fs.statSync(fullName);
      if (!skip(name, fullName)) {
        if (stat.isDirectory()) {
          processDirectory(fullName, destName);
        } else {
          const content = fs.readFileSync(fullName, 'utf8');
          map.set(destName, content);
        }
      }
    }
  }
  try {
    processDirectory(bazelPackageRoot, path.posix.join('/node_modules/@angular', packageName));
    // todo: check why we always need an index.d.ts
    if (fs.existsSync(path.join(bazelPackageRoot, `${packageName}.d.ts`))) {
      const content = fs.readFileSync(path.join(bazelPackageRoot, `${packageName}.d.ts`), 'utf8');
      map.set(path.posix.join('/node_modules/@angular', packageName, 'index.d.ts'), content);
    }
  } catch (e) {
    console.error(`Consider adding //packages/${
        packageName} as a data dependency in the BUILD.bazel rule for the failing test`);
    throw e;
  }
}

export function isInBazel(): boolean {
  return process.env.TEST_SRCDIR != null;
}

export function setup(options: {
  compileAngular: boolean,
  compileFakeCore?: boolean, compileAnimations: boolean,
  compileCommon?: boolean
} = {
  compileAngular: true,
  compileAnimations: true,
  compileCommon: false,
  compileFakeCore: false,
}) {
  let angularFiles = new Map<string, string>();

  beforeAll(() => {
    const sources = process.env.TEST_SRCDIR;
    if (sources) {
      // If running under bazel then we get the compiled version of the files from the bazel package
      // output.
      const bundles = new Set([
        'bundles', 'esm2015', 'esm5', 'testing', 'testing.d.ts', 'testing.metadata.json', 'browser',
        'browser.d.ts'
      ]);
      const skipDirs = (name: string) => bundles.has(name);
      if (options.compileAngular) {
        // If this fails please add //packages/core:npm_package as a test data dependency.
        readBazelWrittenFilesFrom(
            resolveNpmTreeArtifact('angular/packages/core/npm_package'), 'core', angularFiles,
            skipDirs);
      }
      if (options.compileFakeCore) {
        readBazelWrittenFilesFrom(
            resolveNpmTreeArtifact(
                'angular/packages/compiler-cli/test/ngtsc/fake_core/npm_package'),
            'core', angularFiles, skipDirs);
      }
      if (options.compileAnimations) {
        // If this fails please add //packages/animations:npm_package as a test data dependency.
        readBazelWrittenFilesFrom(
            resolveNpmTreeArtifact('angular/packages/animations/npm_package'), 'animations',
            angularFiles, skipDirs);
      }
      if (options.compileCommon) {
        // If this fails please add //packages/common:npm_package as a test data dependency.
        readBazelWrittenFilesFrom(
            resolveNpmTreeArtifact('angular/packages/common/npm_package'), 'common', angularFiles,
            skipDirs);
      }
      return;
    }

    if (options.compileAngular) {
      const emittingHost = new EmittingCompilerHost([], {emitMetadata: true});
      emittingHost.addScript('@angular/core/index.ts', minCoreIndex);
      const emittingProgram = ts.createProgram(emittingHost.scripts, settings, emittingHost);
      emittingProgram.emit();
      emittingHost.writtenAngularFiles(angularFiles);
    }
    if (options.compileCommon) {
      const emittingHost =
          new EmittingCompilerHost(['@angular/common/index.ts'], {emitMetadata: true});
      const emittingProgram = ts.createProgram(emittingHost.scripts, settings, emittingHost);
      emittingProgram.emit();
      emittingHost.writtenAngularFiles(angularFiles);
    }
    if (options.compileAnimations) {
      const emittingHost =
          new EmittingCompilerHost(['@angular/animations/index.ts'], {emitMetadata: true});
      const emittingProgram = ts.createProgram(emittingHost.scripts, settings, emittingHost);
      emittingProgram.emit();
      emittingHost.writtenAngularFiles(angularFiles);
    }
  });

  return angularFiles;
}

export function expectNoDiagnostics(program: ts.Program) {
  function fileInfo(diagnostic: ts.Diagnostic): string {
    if (diagnostic.file) {
      return `${diagnostic.file.fileName}(${diagnostic.start}): `;
    }
    return '';
  }

  function chars(len: number, ch: string): string {
    return newArray(len, ch).join('');
  }

  function lineNoOf(offset: number, text: string): number {
    let result = 1;
    for (let i = 0; i < offset; i++) {
      if (text[i] == '\n') result++;
    }
    return result;
  }

  function lineInfo(diagnostic: ts.Diagnostic): string {
    if (diagnostic.file) {
      const start = diagnostic.start!;
      let end = diagnostic.start! + diagnostic.length!;
      const source = diagnostic.file.text;
      let lineStart = start;
      let lineEnd = end;
      while (lineStart > 0 && source[lineStart] != '\n') lineStart--;
      if (lineStart < start) lineStart++;
      while (lineEnd < source.length && source[lineEnd] != '\n') lineEnd++;
      let line = source.substring(lineStart, lineEnd);
      const lineIndex = line.indexOf('/n');
      if (lineIndex > 0) {
        line = line.substr(0, lineIndex);
        end = start + lineIndex;
      }
      const lineNo = lineNoOf(start, source) + ': ';
      return '\n' + lineNo + line + '\n' + chars(start - lineStart + lineNo.length, ' ') +
          chars(end - start, '^');
    }
    return '';
  }

  function expectNoDiagnostics(diagnostics: ReadonlyArray<ts.Diagnostic>) {
    if (diagnostics && diagnostics.length) {
      throw new Error(
          'Errors from TypeScript:\n' +
          diagnostics
              .map(
                  d => `${fileInfo(d)}${ts.flattenDiagnosticMessageText(d.messageText, '\n')}${
                      lineInfo(d)}`)
              .join(' \n'));
    }
  }
  expectNoDiagnostics(program.getOptionsDiagnostics());
  expectNoDiagnostics(program.getSyntacticDiagnostics());
  expectNoDiagnostics(program.getSemanticDiagnostics());
}

export function isSource(fileName: string): boolean {
  return !isDts(fileName) && /\.ts$/.test(fileName);
}

function isDts(fileName: string): boolean {
  return /\.d.ts$/.test(fileName);
}

function isSourceOrDts(fileName: string): boolean {
  return /\.ts$/.test(fileName) && !/(ngfactory|ngstyle|ngsummary).d.ts$/.test(fileName);
}

function resolveNpmTreeArtifact(manifestPath: string, resolveFile = 'package.json') {
  return path.dirname(require.resolve(path.posix.join(manifestPath, resolveFile)));
}

export function compile(
    rootDirs: MockData, options: {
      emit?: boolean,
      useSummaries?: boolean,
      preCompile?: (program: ts.Program) => void,
      postCompile?: (program: ts.Program) => void,
    }&AotCompilerOptions = {},
    tsOptions: ts.CompilerOptions = {}): {genFiles: GeneratedFile[], outDir: MockDirectory} {
  // when using summaries, always emit so the next step can use the results.
  const emit = options.emit || options.useSummaries;
  const preCompile = options.preCompile || (() => {});
  const postCompile = options.postCompile || expectNoDiagnostics;
  const rootDirArr = toMockFileArray(rootDirs);
  const scriptNames = rootDirArr.map(entry => entry.fileName)
                          .filter(options.useSummaries ? isSource : isSourceOrDts);

  const host = new MockCompilerHost(scriptNames, arrayToMockDir(rootDirArr));
  const aotHost = new MockAotCompilerHost(host);
  if (options.useSummaries) {
    aotHost.hideMetadata();
    aotHost.tsFilesOnly();
  }
  const tsSettings = {...settings, ...tsOptions};
  const program = ts.createProgram([...host.scriptNames], tsSettings, host);
  preCompile(program);
  const {compiler, reflector} = createAotCompiler(aotHost, options, (err) => {
    throw err;
  });
  const analyzedModules =
      compiler.analyzeModulesSync(program.getSourceFiles().map(sf => sf.fileName));
  const genFiles = compiler.emitAllImpls(analyzedModules);
  genFiles.forEach((file) => {
    const source = file.source || toTypeScript(file);
    if (isSource(file.genFileUrl)) {
      host.addScript(file.genFileUrl, source);
    } else {
      host.override(file.genFileUrl, source);
    }
  });
  const newProgram = ts.createProgram([...host.scriptNames], tsSettings, host);
  postCompile(newProgram);
  if (emit) {
    newProgram.emit();
  }
  let outDir: MockDirectory = {};
  if (emit) {
    const dtsFilesWithGenFiles = new Set<string>(genFiles.map(gf => gf.srcFileUrl).filter(isDts));
    outDir =
        arrayToMockDir(toMockFileArray([host.writtenFiles, host.overrides])
                           .filter((entry) => !isSource(entry.fileName))
                           .concat(rootDirArr.filter(e => dtsFilesWithGenFiles.has(e.fileName))));
  }
  return {genFiles, outDir};
}

function stripNgResourceSuffix(fileName: string): string {
  return fileName.replace(/\.\$ngresource\$.*/, '');
}

function addNgResourceSuffix(fileName: string): string {
  return `${fileName}.$ngresource$`;
}

function extractFileNames(directory: MockDirectory): string[] {
  const result: string[] = [];
  const scan = (directory: MockDirectory, prefix: string) => {
    for (let name of Object.getOwnPropertyNames(directory)) {
      const entry = directory[name];
      const fileName = `${prefix}/${name}`;
      if (typeof entry === 'string') {
        result.push(fileName);
      } else if (entry) {
        scan(entry, fileName);
      }
    }
  };
  scan(directory, '');
  return result;
}

export function emitLibrary(
    context: Map<string, string>, mockData: MockDirectory,
    scriptFiles?: string[]): Map<string, string> {
  const emittingHost = new EmittingCompilerHost(
      scriptFiles || extractFileNames(mockData), {emitMetadata: true, mockData, context});
  const emittingProgram = ts.createProgram(emittingHost.scripts, settings, emittingHost);
  expectNoDiagnostics(emittingProgram);
  emittingProgram.emit();
  return emittingHost.written;
}

export function mergeMaps<K, V>(...maps: Map<K, V>[]): Map<K, V> {
  const result = new Map<K, V>();

  for (const map of maps) {
    for (const [key, value] of Array.from(map.entries())) {
      result.set(key, value);
    }
  }

  return result;
}
