/**
 * @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 {setup} from '@angular/compiler-cli/test/test_support';
import * as fs from 'fs';
import * as path from 'path';
import * as ts from 'typescript';

import {Diagnostic, DiagnosticMessageChain, Diagnostics, Span} from '../src/types';

export type MockData = string | MockDirectory;

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

const angularts = /@angular\/(\w|\/|-)+\.tsx?$/;
const rxjsts = /rxjs\/(\w|\/)+\.tsx?$/;
const rxjsmetadata = /rxjs\/(\w|\/)+\.metadata\.json?$/;
const tsxfile = /\.tsx$/;

/* The missing cache does two things. First it improves performance of the
   tests as it reduces the number of OS calls made during testing. Also it
   improves debugging experience as fewer exceptions are raised allow you
   to use stopping on all exceptions. */
const missingCache = new Map<string, boolean>();
const cacheUsed = new Set<string>();
const reportedMissing = new Set<string>();

/**
 * The cache is valid if all the returned entries are empty.
 */
export function validateCache(): {exists: string[], unused: string[], reported: string[]} {
  const exists: string[] = [];
  const unused: string[] = [];
  for (const fileName of iterableToArray(missingCache.keys())) {
    if (fs.existsSync(fileName)) {
      exists.push(fileName);
    }
    if (!cacheUsed.has(fileName)) {
      unused.push(fileName);
    }
  }
  return {exists, unused, reported: iterableToArray(reportedMissing.keys())};
}

missingCache.set('/node_modules/@angular/core.d.ts', true);
missingCache.set('/node_modules/@angular/animations.d.ts', true);
missingCache.set('/node_modules/@angular/platform-browser/animations.d.ts', true);
missingCache.set('/node_modules/@angular/common.d.ts', true);
missingCache.set('/node_modules/@angular/forms.d.ts', true);
missingCache.set('/node_modules/@angular/core/src/di/provider.metadata.json', true);
missingCache.set(
    '/node_modules/@angular/core/src/change_detection/pipe_transform.metadata.json', true);
missingCache.set('/node_modules/@angular/core/src/reflection/types.metadata.json', true);
missingCache.set(
    '/node_modules/@angular/core/src/reflection/platform_reflection_capabilities.metadata.json',
    true);
missingCache.set('/node_modules/@angular/forms/src/directives/form_interface.metadata.json', true);

export class MockTypescriptHost implements ts.LanguageServiceHost {
  private angularPath: string|undefined;
  // TODO(issue/24571): remove '!'.
  private nodeModulesPath !: string;
  private scriptVersion = new Map<string, number>();
  private overrides = new Map<string, string>();
  private projectVersion = 0;
  private options: ts.CompilerOptions;
  private overrideDirectory = new Set<string>();

  constructor(
      private scriptNames: string[], private data: MockData,
      private node_modules: string = 'node_modules', private myPath: typeof path = path) {
    const support = setup();
    this.nodeModulesPath = path.join(support.basePath, 'node_modules');
    this.angularPath = path.join(this.nodeModulesPath, '@angular');
    this.options = {
      target: ts.ScriptTarget.ES5,
      module: ts.ModuleKind.CommonJS,
      moduleResolution: ts.ModuleResolutionKind.NodeJs,
      emitDecoratorMetadata: true,
      experimentalDecorators: true,
      removeComments: false,
      noImplicitAny: false,
      lib: ['lib.es2015.d.ts', 'lib.dom.d.ts'],
    };
  }

  override(fileName: string, content: string) {
    this.scriptVersion.set(fileName, (this.scriptVersion.get(fileName) || 0) + 1);
    if (fileName.endsWith('.ts')) {
      this.projectVersion++;
    }
    if (content) {
      this.overrides.set(fileName, content);
      this.overrideDirectory.add(path.dirname(fileName));
    } else {
      this.overrides.delete(fileName);
    }
  }

  addScript(fileName: string, content: string) {
    this.projectVersion++;
    this.overrides.set(fileName, content);
    this.overrideDirectory.add(path.dirname(fileName));
    this.scriptNames.push(fileName);
  }

  forgetAngular() { this.angularPath = undefined; }

  overrideOptions(cb: (options: ts.CompilerOptions) => ts.CompilerOptions) {
    this.options = cb((Object as any).assign({}, this.options));
    this.projectVersion++;
  }

  getCompilationSettings(): ts.CompilerOptions { return this.options; }

  getProjectVersion(): string { return this.projectVersion.toString(); }

  getScriptFileNames(): string[] { return this.scriptNames; }

  getScriptVersion(fileName: string): string {
    return (this.scriptVersion.get(fileName) || 0).toString();
  }

  getScriptSnapshot(fileName: string): ts.IScriptSnapshot|undefined {
    const content = this.getFileContent(fileName);
    if (content) return ts.ScriptSnapshot.fromString(content);
    return undefined;
  }

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

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

  directoryExists(directoryName: string): boolean {
    if (this.overrideDirectory.has(directoryName)) return true;
    let effectiveName = this.getEffectiveName(directoryName);
    if (effectiveName === directoryName) {
      return directoryExists(directoryName, this.data);
    } else if (effectiveName == '/' + this.node_modules) {
      return true;
    } else {
      return fs.existsSync(effectiveName);
    }
  }

  fileExists(fileName: string): boolean { return this.getRawFileContent(fileName) != null; }

  readFile(path: string): string|undefined { return this.getRawFileContent(path); }

  getMarkerLocations(fileName: string): {[name: string]: number}|undefined {
    let content = this.getRawFileContent(fileName);
    if (content) {
      return getLocationMarkers(content);
    }
  }

  getReferenceMarkers(fileName: string): ReferenceResult|undefined {
    let content = this.getRawFileContent(fileName);
    if (content) {
      return getReferenceMarkers(content);
    }
  }

  getFileContent(fileName: string): string|undefined {
    const content = this.getRawFileContent(fileName);
    if (content) return removeReferenceMarkers(removeLocationMarkers(content));
  }

  private getRawFileContent(fileName: string): string|undefined {
    if (this.overrides.has(fileName)) {
      return this.overrides.get(fileName);
    }
    let basename = path.basename(fileName);
    if (/^lib.*\.d\.ts$/.test(basename)) {
      let libPath = ts.getDefaultLibFilePath(this.getCompilationSettings());
      return fs.readFileSync(this.myPath.join(path.dirname(libPath), basename), 'utf8');
    } else {
      if (missingCache.has(fileName)) {
        cacheUsed.add(fileName);
        return undefined;
      }
      let effectiveName = this.getEffectiveName(fileName);
      if (effectiveName === fileName)
        return open(fileName, this.data);
      else if (
          !fileName.match(angularts) && !fileName.match(rxjsts) && !fileName.match(rxjsmetadata) &&
          !fileName.match(tsxfile)) {
        if (fs.existsSync(effectiveName)) {
          return fs.readFileSync(effectiveName, 'utf8');
        } else {
          missingCache.set(fileName, true);
          reportedMissing.add(fileName);
          cacheUsed.add(fileName);
        }
      }
    }
  }

  private getEffectiveName(name: string): string {
    const node_modules = this.node_modules;
    const at_angular = '/@angular';
    if (name.startsWith('/' + node_modules)) {
      if (this.nodeModulesPath && !name.startsWith('/' + node_modules + at_angular)) {
        let result = this.myPath.join(this.nodeModulesPath, name.substr(node_modules.length + 1));
        if (!name.match(rxjsts))
          if (fs.existsSync(result)) {
            return result;
          }
      }
      if (this.angularPath && name.startsWith('/' + node_modules + at_angular)) {
        return this.myPath.join(
            this.angularPath, name.substr(node_modules.length + at_angular.length + 1));
      }
    }
    return name;
  }
}

function iterableToArray<T>(iterator: IterableIterator<T>) {
  const result: T[] = [];
  while (true) {
    const next = iterator.next();
    if (next.done) break;
    result.push(next.value);
  }
  return result;
}

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

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

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

const locationMarker = /\~\{(\w+(-\w+)*)\}/g;

function removeLocationMarkers(value: string): string {
  return value.replace(locationMarker, '');
}

function getLocationMarkers(value: string): {[name: string]: number} {
  value = removeReferenceMarkers(value);
  let result: {[name: string]: number} = {};
  let adjustment = 0;
  value.replace(locationMarker, (match: string, name: string, _: any, index: number): string => {
    result[name] = index - adjustment;
    adjustment += match.length;
    return '';
  });
  return result;
}

const referenceMarker = /«(((\w|\-)+)|([^Δ]*Δ(\w+)Δ.[^»]*))»/g;
const definitionMarkerGroup = 1;
const nameMarkerGroup = 2;

export type ReferenceMarkers = {
  [name: string]: Span[]
};
export interface ReferenceResult {
  text: string;
  definitions: ReferenceMarkers;
  references: ReferenceMarkers;
}

function getReferenceMarkers(value: string): ReferenceResult {
  const references: ReferenceMarkers = {};
  const definitions: ReferenceMarkers = {};
  value = removeLocationMarkers(value);

  let adjustment = 0;
  const text = value.replace(
      referenceMarker, (match: string, text: string, reference: string, _: string,
                        definition: string, definitionName: string, index: number): string => {
        const result = reference ? text : text.replace(/Δ/g, '');
        const span: Span = {start: index - adjustment, end: index - adjustment + result.length};
        const markers = reference ? references : definitions;
        const name = reference || definitionName;
        (markers[name] = (markers[name] || [])).push(span);
        adjustment += match.length - result.length;
        return result;
      });

  return {text, definitions, references};
}

function removeReferenceMarkers(value: string): string {
  return value.replace(referenceMarker, (match, text) => text.replace(/Δ/g, ''));
}

export function noDiagnostics(diagnostics: Diagnostics) {
  if (diagnostics && diagnostics.length) {
    throw new Error(`Unexpected diagnostics: \n  ${diagnostics.map(d => d.message).join('\n  ')}`);
  }
}

export function diagnosticMessageContains(
    message: string | DiagnosticMessageChain, messageFragment: string): boolean {
  if (typeof message == 'string') {
    return message.indexOf(messageFragment) >= 0;
  }
  if (message.message.indexOf(messageFragment) >= 0) {
    return true;
  }
  if (message.next) {
    return diagnosticMessageContains(message.next, messageFragment);
  }
  return false;
}

export function findDiagnostic(diagnostics: Diagnostic[], messageFragment: string): Diagnostic|
    undefined {
  return diagnostics.find(d => diagnosticMessageContains(d.message, messageFragment));
}

export function includeDiagnostic(
    diagnostics: Diagnostics, message: string, text?: string, len?: string): void;
export function includeDiagnostic(
    diagnostics: Diagnostics, message: string, at?: number, len?: number): void;
export function includeDiagnostic(diagnostics: Diagnostics, message: string, p1?: any, p2?: any) {
  expect(diagnostics).toBeDefined();
  if (diagnostics) {
    const diagnostic = findDiagnostic(diagnostics, message);
    expect(diagnostic).toBeDefined(`no diagnostic contains '${message}`);
    if (diagnostic && p1 != null) {
      const at = typeof p1 === 'number' ? p1 : p2.indexOf(p1);
      const len = typeof p2 === 'number' ? p2 : p1.length;
      expect(diagnostic.span.start)
          .toEqual(
              at,
              `expected message '${message}' was reported at ${diagnostic.span.start} but should be ${at}`);
      if (len != null) {
        expect(diagnostic.span.end - diagnostic.span.start)
            .toEqual(len, `expected '${message}'s span length to be ${len}`);
      }
    }
  }
}
