/**
 * @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
 */
// tslint:disable:no-implicit-dependencies
import { logging } from '@angular-devkit/core';
import * as glob from 'glob';
import * as Istanbul from 'istanbul';
import 'jasmine';
import { SpecReporter as JasmineSpecReporter } from 'jasmine-spec-reporter';
import { ParsedArgs } from 'minimist';
import { join, relative } from 'path';
import { Position, SourceMapConsumer } from 'source-map';
import * as ts from 'typescript';
import { packages } from '../lib/packages';

const codeMap = require('../lib/istanbul-local').codeMap;
const Jasmine = require('jasmine');

const projectBaseDir = join(__dirname, '..');
require('source-map-support').install({
  hookRequire: true,
});


interface CoverageLocation {
  start: Position;
  end: Position;
}

type CoverageType = any;  // tslint:disable-line:no-any
declare const global: {
  __coverage__: CoverageType;
};


// Add the Istanbul (not Constantinople) reporter.
const istanbulCollector = new Istanbul.Collector({});
const istanbulReporter = new Istanbul.Reporter(undefined, 'coverage/');
istanbulReporter.addAll(['json', 'lcov']);


class IstanbulReporter implements jasmine.CustomReporter {
  // Update a location object from a SourceMap. Will ignore the location if the sourcemap does
  // not have a valid mapping.
  private _updateLocation(consumer: SourceMapConsumer, location: CoverageLocation) {
    const start = consumer.originalPositionFor(location.start);
    const end = consumer.originalPositionFor(location.end);

    // Filter invalid original positions.
    if (start.line !== null && start.column !== null) {
      // Filter unwanted properties.
      location.start = { line: start.line, column: start.column };
    }
    if (end.line !== null && end.column !== null) {
      location.end = { line: end.line, column: end.column };
    }
  }

  private _updateCoverageJsonSourceMap(coverageJson: CoverageType) {
    // Update the coverageJson with the SourceMap.
    for (const path of Object.keys(coverageJson)) {
      const entry = codeMap.get(path);
      if (!entry) {
        continue;
      }

      const consumer = entry.map;
      const coverage = coverageJson[path];

      // Update statement maps.
      for (const branchId of Object.keys(coverage.branchMap)) {
        const branch = coverage.branchMap[branchId];
        let line: number | null = null;
        let column = 0;
        do {
          line = consumer.originalPositionFor({ line: branch.line, column: column++ }).line;
        } while (line === null && column < 100);

        branch.line = line;

        for (const location of branch.locations) {
          this._updateLocation(consumer, location);
        }
      }

      for (const id of Object.keys(coverage.statementMap)) {
        const location = coverage.statementMap[id];
        this._updateLocation(consumer, location);
      }

      for (const id of Object.keys(coverage.fnMap)) {
        const fn = coverage.fnMap[id];
        fn.line = consumer.originalPositionFor({ line: fn.line, column: 0 }).line;
        this._updateLocation(consumer, fn.loc);
      }
    }
  }

  jasmineDone(_runDetails: jasmine.RunDetails): void {
    if (global.__coverage__) {
      this._updateCoverageJsonSourceMap(global.__coverage__);
      istanbulCollector.add(global.__coverage__);

      istanbulReporter.write(istanbulCollector, true, () => {});
    }
  }
}


// Create a Jasmine runner and configure it.
const runner = new Jasmine({ projectBaseDir: projectBaseDir });

if (process.argv.indexOf('--spec-reporter') != -1) {
  runner.env.clearReporters();
  runner.env.addReporter(new JasmineSpecReporter({
    stacktrace: {
      // Filter all JavaScript files that appear after a TypeScript file (callers) from the stack
      // trace.
      filter: (x: string) => {
        return x.substr(0, x.indexOf('\n', x.indexOf('\n', x.lastIndexOf('.ts:')) + 1));
      },
    },
    spec: {
      displayDuration: true,
    },
    suite: {
      displayNumber: true,
    },
    summary: {
      displayStacktrace: true,
      displayErrorMessages: true,
      displayDuration: true,
    },
  }));
}


// Manually set exit code (needed with custom reporters)
runner.onComplete((success: boolean) => {
  process.exitCode = success ? 0 : 1;
  if (process.platform.startsWith('win')) {
    // TODO(filipesilva): finish figuring out why this happens.
    // We should not need to force exit here, but when:
    // - on windows
    // - running webpack-dev-server
    // - with ngtools/webpack on the compilation
    // Something seems to hang and the process never exists.
    // This does not happen on linux, nor with webpack on watch mode.
    // Until this is figured out, we need to exit the process manually after tests finish
    // otherwise appveyor will hang until it timeouts.
    process.exit();
  }
});


glob.sync('packages/**/*.spec.ts')
  .filter(p => !/\/schematics\/.*\/(other-)?files\//.test(p))
  .forEach(path => {
    console.error(`Invalid spec file name: ${path}. You're using the old convention.`);
  });

export default function (args: ParsedArgs, logger: logging.Logger) {
  const specGlob = args.large ? '*_spec_large.ts' : '*_spec.ts';
  const regex = args.glob ? args.glob : `packages/**/${specGlob}`;

  if (args['code-coverage']) {
    runner.env.addReporter(new IstanbulReporter());
  }

  if (args.large) {
    // Default timeout for large specs is 2.5 minutes.
    jasmine.DEFAULT_TIMEOUT_INTERVAL = 150000;
  }

  // Run the tests.
  const allTests =
    glob.sync(regex)
      .map(p => relative(projectBaseDir, p));

  const tsConfigPath = join(__dirname, '../tsconfig.json');
  const tsConfig = ts.readConfigFile(tsConfigPath, ts.sys.readFile);
  const pattern = '^('
                  + (tsConfig.config.exclude as string[])
                    .map(ex => '('
                      + ex.split(/[\/\\]/g).map(f => f
                          .replace(/[\-\[\]{}()+?.^$|]/g, '\\$&')
                          .replace(/^\*\*/g, '(.+?)?')
                          .replace(/\*/g, '[^/\\\\]*'))
                        .join('[\/\\\\]')
                      + ')')
                    .join('|')
                  + ')($|/|\\\\)';
  const excludeRe = new RegExp(pattern);
  let tests = allTests.filter(x => !excludeRe.test(x));

  if (!args.full) {
    // Remove the tests from packages that haven't changed.
    tests = tests
      .filter(p => Object.keys(packages).some(name => {
        const relativeRoot = relative(projectBaseDir, packages[name].root);

        return p.startsWith(relativeRoot) && packages[name].dirty;
      }));

    logger.info(`Found ${tests.length} spec files, out of ${allTests.length}.`);
  }

  if (args.shard !== undefined) {
    // Remove tests that are not part of this shard.
    const shardId = args['shard'];
    const nbShards = args['nb-shards'] || 2;
    tests = tests.filter((name, i) => (i % nbShards) == shardId);
  }

  return new Promise(resolve => {
    runner.onComplete((passed: boolean) => resolve(passed ? 0 : 1));
    runner.execute(tests);
  });
}
