// Copyright (c) 2016, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:io';

import 'package:args/args.dart';
import 'package:boolean_selector/boolean_selector.dart';

import '../../backend/runtime.dart';
import '../../frontend/timeout.dart';
import '../configuration.dart';
import 'runtime_selection.dart';
import 'reporters.dart';
import 'values.dart';

/// The parser used to parse the command-line arguments.
final ArgParser _parser = (() {
  var parser = new ArgParser(allowTrailingOptions: true);

  var allRuntimes = Runtime.builtIn.toList()..remove(Runtime.vm);
  if (!Platform.isMacOS) allRuntimes.remove(Runtime.safari);
  if (!Platform.isWindows) allRuntimes.remove(Runtime.internetExplorer);

  parser.addFlag("help",
      abbr: "h", negatable: false, help: "Shows this usage information.");
  parser.addFlag("version",
      negatable: false, help: "Shows the package's version.");

  // Note that defaultsTo declarations here are only for documentation purposes.
  // We pass null values rather than defaults to [new Configuration] so that it
  // merges properly with the config file.

  parser.addSeparator("======== Selecting Tests");
  parser.addMultiOption("name",
      abbr: 'n',
      help: 'A substring of the name of the test to run.\n'
          'Regular expression syntax is supported.\n'
          'If passed multiple times, tests must match all substrings.',
      splitCommas: false);
  parser.addMultiOption("plain-name",
      abbr: 'N',
      help: 'A plain-text substring of the name of the test to run.\n'
          'If passed multiple times, tests must match all substrings.',
      splitCommas: false);
  parser.addMultiOption("tags",
      abbr: 't',
      help: 'Run only tests with all of the specified tags.\n'
          'Supports boolean selector syntax.');
  parser.addMultiOption("tag", hide: true);
  parser.addMultiOption("exclude-tags",
      abbr: 'x',
      help: "Don't run tests with any of the specified tags.\n"
          "Supports boolean selector syntax.");
  parser.addMultiOption("exclude-tag", hide: true);
  parser.addFlag("run-skipped",
      help: 'Run skipped tests instead of skipping them.');

  parser.addSeparator("======== Running Tests");

  // The UI term "platform" corresponds with the implementation term "runtime".
  // The [Runtime] class used to be called [TestPlatform], but it was changed to
  // avoid conflicting with [SuitePlatform]. We decided not to also change the
  // UI to avoid a painful migration.
  parser.addMultiOption("platform",
      abbr: 'p',
      help: 'The platform(s) on which to run the tests.\n'
          '[vm (default), '
          '${allRuntimes.map((runtime) => runtime.identifier).join(", ")}]');
  parser.addMultiOption("preset",
      abbr: 'P', help: 'The configuration preset(s) to use.');
  parser.addOption("concurrency",
      abbr: 'j',
      help: 'The number of concurrent test suites run.',
      defaultsTo: defaultConcurrency.toString(),
      valueHelp: 'threads');
  parser.addOption("pub-serve",
      help: 'The port of a pub serve instance serving "test/".',
      valueHelp: 'port');
  parser.addOption("timeout",
      help: 'The default test timeout. For example: 15s, 2x, none',
      defaultsTo: '30s');
  parser.addFlag("pause-after-load",
      help: 'Pauses for debugging before any tests execute.\n'
          'Implies --concurrency=1 and --timeout=none.\n'
          'Currently only supported for browser tests.',
      negatable: false);
  parser.addFlag("chain-stack-traces",
      help: 'Chained stack traces to provide greater exception details\n'
          'especially for asynchronous code. It may be useful to disable\n'
          'to provide improved test performance but at the cost of\n'
          'debuggability.',
      defaultsTo: true);
  parser.addFlag("no-retry",
      help: "Don't re-run tests that have retry set.",
      defaultsTo: false,
      negatable: false);

  var reporterDescriptions = <String, String>{};
  for (var reporter in allReporters.keys) {
    reporterDescriptions[reporter] = allReporters[reporter].description;
  }

  parser.addSeparator("======== Output");
  parser.addOption("reporter",
      abbr: 'r',
      help: 'The runner used to print test results.',
      defaultsTo: defaultReporter,
      allowed: reporterDescriptions.keys.toList(),
      allowedHelp: reporterDescriptions);
  parser.addFlag("verbose-trace",
      negatable: false,
      help: 'Whether to emit stack traces with core library frames.');
  parser.addFlag("js-trace",
      negatable: false,
      help: 'Whether to emit raw JavaScript stack traces for browser tests.');
  parser.addFlag("color",
      help: 'Whether to use terminal colors.\n(auto-detected by default)');

  /// The following options are used only by the internal Google test runner.
  /// They're hidden and not supported as stable API surface outside Google.

  parser.addOption("configuration",
      help: 'The path to the configuration file.', hide: true);
  parser.addOption("dart2js-path",
      help: 'The path to the dart2js executable.', hide: true);
  parser.addMultiOption("dart2js-args",
      help: 'Extra arguments to pass to dart2js.', hide: true);
  parser.addOption("total-shards",
      help: 'The total number of invocations of the test runner being run.',
      hide: true);
  parser.addOption("shard-index",
      help: 'The index of this test runner invocation (of --total-shards).',
      hide: true);

  // If we're running test/dir/my_test.dart, we'll look for
  // test/dir/my_test.dart.html in the precompiled directory.
  parser.addOption("precompiled",
      help: 'The path to a mirror of the package directory containing HTML '
          'that points to precompiled JS.',
      hide: true);

  return parser;
})();

/// The usage string for the command-line arguments.
String get usage => _parser.usage;

/// Parses the configuration from [args].
///
/// Throws a [FormatException] if [args] are invalid.
Configuration parse(List<String> args) => new _Parser(args).parse();

/// A class for parsing an argument list.
///
/// This is used to provide access to the arg results across helper methods.
class _Parser {
  /// The parsed options.
  final ArgResults _options;

  _Parser(List<String> args) : _options = _parser.parse(args);

  /// Returns the parsed configuration.
  Configuration parse() {
    var patterns = (_options['name'] as List<String>)
        .map<Pattern>(
            (value) => _wrapFormatException('name', () => new RegExp(value)))
        .toList()
          ..addAll(_options['plain-name'] as List<String>);

    var includeTagSet = new Set.from(_options['tags'] as Iterable ?? [])
      ..addAll(_options['tag'] as Iterable ?? []);

    var includeTags = includeTagSet.fold(BooleanSelector.all,
        (BooleanSelector selector, tag) {
      var tagSelector = new BooleanSelector.parse(tag as String);
      return selector.intersection(tagSelector);
    });

    var excludeTagSet = new Set.from(_options['exclude-tags'] as Iterable ?? [])
      ..addAll(_options['exclude-tag'] as Iterable ?? []);

    var excludeTags = excludeTagSet.fold(BooleanSelector.none,
        (BooleanSelector selector, tag) {
      var tagSelector = new BooleanSelector.parse(tag as String);
      return selector.union(tagSelector);
    });

    var shardIndex = _parseOption('shard-index', int.parse);
    var totalShards = _parseOption('total-shards', int.parse);
    if ((shardIndex == null) != (totalShards == null)) {
      throw new FormatException(
          "--shard-index and --total-shards may only be passed together.");
    } else if (shardIndex != null) {
      if (shardIndex < 0) {
        throw new FormatException("--shard-index may not be negative.");
      } else if (shardIndex >= totalShards) {
        throw new FormatException(
            "--shard-index must be less than --total-shards.");
      }
    }

    return new Configuration(
        help: _ifParsed('help'),
        version: _ifParsed('version'),
        verboseTrace: _ifParsed('verbose-trace'),
        chainStackTraces: _ifParsed('chain-stack-traces'),
        jsTrace: _ifParsed('js-trace'),
        pauseAfterLoad: _ifParsed('pause-after-load'),
        color: _ifParsed('color'),
        configurationPath: _ifParsed('configuration'),
        dart2jsPath: _ifParsed('dart2js-path'),
        dart2jsArgs: _ifParsed('dart2js-args'),
        precompiledPath: _ifParsed('precompiled'),
        reporter: _ifParsed('reporter'),
        pubServePort: _parseOption('pub-serve', int.parse),
        concurrency: _parseOption('concurrency', int.parse),
        shardIndex: shardIndex,
        totalShards: totalShards,
        timeout: _parseOption('timeout', (value) => new Timeout.parse(value)),
        patterns: patterns,
        runtimes: _ifParsed<List<String>>('platform')
            ?.map((runtime) => new RuntimeSelection(runtime))
            ?.toList(),
        runSkipped: _ifParsed('run-skipped'),
        chosenPresets: _ifParsed('preset'),
        paths: _options.rest.isEmpty ? null : _options.rest,
        includeTags: includeTags,
        excludeTags: excludeTags,
        noRetry: _ifParsed('no-retry'));
  }

  /// Returns the parsed option for [name], or `null` if none was parsed.
  ///
  /// If the user hasn't explicitly chosen a value, we want to pass null values
  /// to [new Configuration] so that it considers those fields unset when
  /// merging with configuration from the config file.
  T _ifParsed<T>(String name) =>
      _options.wasParsed(name) ? _options[name] as T : null;

  /// Runs [parse] on the value of the option [name], and wraps any
  /// [FormatException] it throws with additional information.
  T _parseOption<T>(String name, T parse(String value)) {
    if (!_options.wasParsed(name)) return null;

    var value = _options[name];
    if (value == null) return null;

    return _wrapFormatException(name, () => parse(value as String));
  }

  /// Runs [parse], and wraps any [FormatException] it throws with additional
  /// information.
  T _wrapFormatException<T>(String name, T parse()) {
    try {
      return parse();
    } on FormatException catch (error) {
      throw new FormatException('Couldn\'t parse --$name "${_options[name]}": '
          '${error.message}');
    }
  }
}
