// Copyright (c) 2015, 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:async';
import 'dart:io';

import 'package:boolean_selector/boolean_selector.dart';
import 'package:collection/collection.dart';
import 'package:glob/glob.dart';
import 'package:path/path.dart' as p;
import 'package:source_span/source_span.dart';
import 'package:test_api/scaffolding.dart' show Timeout;
import 'package:test_api/src/backend/platform_selector.dart'; // ignore: implementation_imports
import 'package:test_api/src/backend/runtime.dart'; // ignore: implementation_imports

import '../util/io.dart';
import 'compiler_selection.dart';
import 'configuration/args.dart' as args;
import 'configuration/custom_runtime.dart';
import 'configuration/load.dart';
import 'configuration/reporters.dart';
import 'configuration/runtime_settings.dart';
import 'configuration/utils.dart';
import 'configuration/values.dart';
import 'runtime_selection.dart';
import 'suite.dart';

export 'suite.dart' show TestSelection;

/// The key used to look up [Configuration.current] in a zone.
final _currentKey = Object();

/// A class that encapsulates the command-line configuration of the test runner.
class Configuration {
  /// An empty configuration with only default values.
  ///
  /// Using this is slightly more efficient than manually constructing a new
  /// configuration with no arguments.
  static final empty = Configuration._unsafe();

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

  /// Whether `--help` was passed.
  bool get help => _help ?? false;
  final bool? _help;

  /// Custom HTML template file.
  final String? customHtmlTemplatePath;

  /// Whether `--version` was passed.
  bool get version => _version ?? false;
  final bool? _version;

  /// Whether to pause for debugging after loading each test suite.
  bool get pauseAfterLoad => _pauseAfterLoad ?? false;
  final bool? _pauseAfterLoad;

  /// Whether to run browsers in their respective debug modes
  bool get debug => pauseAfterLoad || (_debug ?? false) || coverage != null;
  final bool? _debug;

  /// The output folder for coverage gathering
  final String? coverage;

  /// The path to the file from which to load more configuration information.
  ///
  /// This is *not* resolved automatically.
  String get configurationPath => _configurationPath ?? 'dart_test.yaml';
  final String? _configurationPath;

  /// The name of the reporter to use to display results.
  String get reporter => _reporter ?? defaultReporter;
  final String? _reporter;

  /// The map of file reporters where the key is the name of the reporter and
  /// the value is the filepath to which its output should be written.
  final Map<String, String> fileReporters;

  /// Whether to disable retries of tests.
  bool get noRetry => _noRetry ?? false;
  final bool? _noRetry;

  /// Whether to use command-line color escapes.
  bool get color => _color ?? canUseSpecialChars;
  final bool? _color;

  /// How many tests to run concurrently.
  int get concurrency =>
      pauseAfterLoad ? 1 : (_concurrency ?? defaultConcurrency);
  final int? _concurrency;

  /// The index of the current shard, if sharding is in use, or `null` if it's
  /// not.
  ///
  /// Sharding is a technique that allows the Google internal test framework to
  /// easily split a test run across multiple workers without requiring the
  /// tests to be modified by the user. When sharding is in use, the runner gets
  /// a shard index (this field) and a total number of shards, and is expected
  /// to provide the following guarantees:
  ///
  /// * Running the same invocation of the runner, with the same shard index and
  ///   total shards, will run the same set of tests.
  /// * Across all shards, each test must be run exactly once.
  ///
  /// In addition, tests should be balanced across shards as much as possible.
  final int? shardIndex;

  /// The total number of shards, if sharding is in use, or `null` if it's not.
  ///
  /// See [shardIndex] for details.
  final int? totalShards;

  /// The list of packages to fold when producing [StackTrace]s.
  Set<String> get foldTraceExcept => _foldTraceExcept ?? {};
  final Set<String>? _foldTraceExcept;

  /// If non-empty, all packages not in this list will be folded when producing
  /// [StackTrace]s.
  Set<String> get foldTraceOnly => _foldTraceOnly ?? {};
  final Set<String>? _foldTraceOnly;

  /// The paths from which to load tests, and the test cases to run.
  Map<String, Set<TestSelection>> get testSelections =>
      _testSelections ??
      const {
        'test': {TestSelection()}
      };
  final Map<String, Set<TestSelection>>? _testSelections;

  /// Whether the load paths were passed explicitly or the default was used.
  bool get explicitPaths => _testSelections != null;

  /// The glob matching the basename of tests to run.
  ///
  /// This is used to find tests within a directory.
  Glob get filename => _filename ?? defaultFilename;
  final Glob? _filename;

  /// The set of presets to use.
  ///
  /// Any chosen presets for the parent configuration are added to the chosen
  /// preset sets for child configurations as well.
  ///
  /// Note that the order of this set matters.
  final Set<String> chosenPresets;

  /// The set of tags that have been declared in any way in this configuration.
  late final Set<String> knownTags = UnmodifiableSetView({
    ...includeTags.variables,
    ...excludeTags.variables,
    ...suiteDefaults.knownTags,
    for (var configuration in presets.values) ...configuration.knownTags
  });

  /// Only run tests whose tags match this selector.
  ///
  /// When [merge]d, this is intersected with the other configuration's included
  /// tags.
  final BooleanSelector includeTags;

  /// Do not run tests whose tags match this selector.
  ///
  /// When [merge]d, this is unioned with the other configuration's
  /// excluded tags.
  final BooleanSelector excludeTags;

  /// Configuration presets.
  ///
  /// These are configurations that can be explicitly selected by the user via
  /// the command line. Preset configuration takes precedence over the base
  /// configuration.
  ///
  /// This is guaranteed not to have any keys that match [chosenPresets]; those
  /// are resolved when the configuration is constructed.
  final Map<String, Configuration> presets;

  /// All preset names that are known to be valid.
  ///
  /// This includes presets that have already been resolved.
  Set<String> get knownPresets => _knownPresets ??= UnmodifiableSetView({
        ...presets.keys,
        for (var configuration in presets.values) ...configuration.knownPresets
      });
  Set<String>? _knownPresets;

  /// Built-in runtimes whose settings are overridden by the user.
  final Map<String, RuntimeSettings> overrideRuntimes;

  /// Runtimes defined by the user in terms of existing runtimes.
  final Map<String, CustomRuntime> defineRuntimes;

  /// The default suite-level configuration.
  final SuiteConfiguration suiteDefaults;

  /// The set of patterns to check test names against in all suites that run.
  final Set<Pattern> globalPatterns;

  /// The seed used to generate randomness for test case shuffling.
  ///
  /// If null or zero no shuffling will occur.
  /// The same seed will shuffle the tests in the same way every time.
  final int? testRandomizeOrderingSeed;

  final bool? _stopOnFirstFailure;

  /// Whether to stop running subsequent tests after a test fails.
  bool get stopOnFirstFailure => _stopOnFirstFailure ?? false;

  /// Returns the current configuration, or a default configuration if no
  /// current configuration is set.
  ///
  /// The current configuration is set using [asCurrent].
  static Configuration get current =>
      Zone.current[_currentKey] as Configuration? ?? Configuration._unsafe();

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

  /// Loads configuration from [path].
  ///
  /// If [global] is `true`, this restricts the configuration to rules that are
  /// supported globally.
  ///
  /// Throws an [IOException] if [path] does not exist or cannot be read. Throws
  /// a [FormatException] if the file contents are invalid.
  factory Configuration.load(String path, {bool global = false}) {
    final content = File(path).readAsStringSync();
    final sourceUrl = p.toUri(path);
    return parse(content, global: global, sourceUrl: sourceUrl);
  }

  /// Parses configuration from YAML formatted [content].
  ///
  /// If [global] is `true`, this restricts the configuration to rules that are
  /// supported globally.
  ///
  /// If [sourceUrl] is provided it will be set as the source url for the yaml
  /// document.
  ///
  /// Throws a [FormatException] if the content is invalid.
  factory Configuration.loadFromString(String content,
          {bool global = false, Uri? sourceUrl}) =>
      parse(content, global: global, sourceUrl: sourceUrl);

  factory Configuration(
      {required bool? help,
      required String? customHtmlTemplatePath,
      required bool? version,
      required bool? pauseAfterLoad,
      required bool? debug,
      required bool? color,
      required String? configurationPath,
      required String? reporter,
      required Map<String, String>? fileReporters,
      required String? coverage,
      required int? concurrency,
      required int? shardIndex,
      required int? totalShards,
      required Map<String, Set<TestSelection>>? testSelections,
      required Iterable<String>? foldTraceExcept,
      required Iterable<String>? foldTraceOnly,
      required Glob? filename,
      required Iterable<String>? chosenPresets,
      required Map<String, Configuration>? presets,
      required Map<String, RuntimeSettings>? overrideRuntimes,
      required Map<String, CustomRuntime>? defineRuntimes,
      required bool? noRetry,
      required int? testRandomizeOrderingSeed,
      required bool? stopOnFirstFailure,

      // Suite-level configuration
      required bool? allowDuplicateTestNames,
      required bool? allowTestRandomization,
      required bool? jsTrace,
      required bool? runSkipped,
      required Iterable<String>? dart2jsArgs,
      required String? precompiledPath,
      required Iterable<Pattern>? globalPatterns,
      required Iterable<CompilerSelection>? compilerSelections,
      required Iterable<RuntimeSelection>? runtimes,
      required BooleanSelector? includeTags,
      required BooleanSelector? excludeTags,
      required Map<BooleanSelector, SuiteConfiguration>? tags,
      required Map<PlatformSelector, SuiteConfiguration>? onPlatform,
      required bool? ignoreTimeouts,

      // Test-level configuration
      required Timeout? timeout,
      required bool? verboseTrace,
      required bool? chainStackTraces,
      required bool? skip,
      required int? retry,
      required String? skipReason,
      required PlatformSelector? testOn,
      required Iterable<String>? addTags}) {
    var chosenPresetSet = chosenPresets?.toSet();
    var configuration = Configuration._(
        help: help,
        customHtmlTemplatePath: customHtmlTemplatePath,
        version: version,
        pauseAfterLoad: pauseAfterLoad,
        debug: debug,
        color: color,
        configurationPath: configurationPath,
        reporter: reporter,
        fileReporters: fileReporters,
        coverage: coverage,
        concurrency: concurrency,
        shardIndex: shardIndex,
        totalShards: totalShards,
        testSelections: testSelections,
        foldTraceExcept: foldTraceExcept,
        foldTraceOnly: foldTraceOnly,
        filename: filename,
        chosenPresets: chosenPresetSet,
        presets: _withChosenPresets(presets, chosenPresetSet),
        overrideRuntimes: overrideRuntimes,
        defineRuntimes: defineRuntimes,
        noRetry: noRetry,
        testRandomizeOrderingSeed: testRandomizeOrderingSeed,
        stopOnFirstFailure: stopOnFirstFailure,
        includeTags: includeTags,
        excludeTags: excludeTags,
        globalPatterns: globalPatterns,
        suiteDefaults: SuiteConfiguration(
            allowDuplicateTestNames: allowDuplicateTestNames,
            allowTestRandomization: allowTestRandomization,
            jsTrace: jsTrace,
            runSkipped: runSkipped,
            dart2jsArgs: dart2jsArgs,
            precompiledPath: precompiledPath,
            compilerSelections: compilerSelections,
            runtimes: runtimes,
            tags: tags,
            onPlatform: onPlatform,
            ignoreTimeouts: ignoreTimeouts,

            // Test-level configuration
            timeout: timeout,
            verboseTrace: verboseTrace,
            chainStackTraces: chainStackTraces,
            skip: skip,
            retry: retry,
            skipReason: skipReason,
            testOn: testOn,
            addTags: addTags));
    return configuration._resolvePresets();
  }

  /// A constructor that doesn't require all of its options to be passed.
  ///
  /// This should only be used in situations where you really only want to
  /// configure a specific restricted set of options.
  factory Configuration._unsafe(
          {bool? help,
          String? customHtmlTemplatePath,
          bool? version,
          bool? pauseAfterLoad,
          bool? debug,
          bool? color,
          String? configurationPath,
          String? reporter,
          Map<String, String>? fileReporters,
          String? coverage,
          int? concurrency,
          int? shardIndex,
          int? totalShards,
          Map<String, Set<TestSelection>>? testSelections,
          Iterable<String>? foldTraceExcept,
          Iterable<String>? foldTraceOnly,
          Glob? filename,
          Iterable<String>? chosenPresets,
          Map<String, Configuration>? presets,
          Map<String, RuntimeSettings>? overrideRuntimes,
          Map<String, CustomRuntime>? defineRuntimes,
          bool? noRetry,
          int? testRandomizeOrderingSeed,
          bool? stopOnFirstFailure,

          // Suite-level configuration
          bool? allowDuplicateTestNames,
          bool? allowTestRandomization,
          bool? jsTrace,
          bool? runSkipped,
          Iterable<String>? dart2jsArgs,
          String? precompiledPath,
          Iterable<Pattern>? globalPatterns,
          Iterable<CompilerSelection>? compilerSelections,
          Iterable<RuntimeSelection>? runtimes,
          BooleanSelector? includeTags,
          BooleanSelector? excludeTags,
          Map<BooleanSelector, SuiteConfiguration>? tags,
          Map<PlatformSelector, SuiteConfiguration>? onPlatform,
          bool? ignoreTimeouts,

          // Test-level configuration
          Timeout? timeout,
          bool? verboseTrace,
          bool? chainStackTraces,
          bool? skip,
          int? retry,
          String? skipReason,
          PlatformSelector? testOn,
          Iterable<String>? addTags}) =>
      Configuration(
          help: help,
          customHtmlTemplatePath: customHtmlTemplatePath,
          version: version,
          pauseAfterLoad: pauseAfterLoad,
          debug: debug,
          color: color,
          configurationPath: configurationPath,
          reporter: reporter,
          fileReporters: fileReporters,
          coverage: coverage,
          concurrency: concurrency,
          shardIndex: shardIndex,
          totalShards: totalShards,
          testSelections: testSelections,
          foldTraceExcept: foldTraceExcept,
          foldTraceOnly: foldTraceOnly,
          filename: filename,
          chosenPresets: chosenPresets,
          presets: presets,
          overrideRuntimes: overrideRuntimes,
          defineRuntimes: defineRuntimes,
          noRetry: noRetry,
          testRandomizeOrderingSeed: testRandomizeOrderingSeed,
          stopOnFirstFailure: stopOnFirstFailure,
          allowDuplicateTestNames: allowDuplicateTestNames,
          allowTestRandomization: allowTestRandomization,
          jsTrace: jsTrace,
          runSkipped: runSkipped,
          dart2jsArgs: dart2jsArgs,
          precompiledPath: precompiledPath,
          globalPatterns: globalPatterns,
          compilerSelections: compilerSelections,
          runtimes: runtimes,
          includeTags: includeTags,
          excludeTags: excludeTags,
          tags: tags,
          onPlatform: onPlatform,
          ignoreTimeouts: ignoreTimeouts,
          timeout: timeout,
          verboseTrace: verboseTrace,
          chainStackTraces: chainStackTraces,
          skip: skip,
          retry: retry,
          skipReason: skipReason,
          testOn: testOn,
          addTags: addTags);

  /// Suite level configuration allowed in the global test config file.
  ///
  /// This is per-user configuration and should be limited as such, it should
  /// not contain options that would change the pass/fail result of any given
  /// test, or change which tests would run.
  factory Configuration.globalTest({
    required bool? verboseTrace,
    required bool? jsTrace,
    required Timeout? timeout,
    required Map<String, Configuration>? presets,
    required bool? chainStackTraces,
    required Iterable<String>? foldTraceExcept,
    required Iterable<String>? foldTraceOnly,
  }) =>
      Configuration(
        foldTraceExcept: foldTraceExcept,
        foldTraceOnly: foldTraceOnly,
        jsTrace: jsTrace,
        timeout: timeout,
        verboseTrace: verboseTrace,
        chainStackTraces: chainStackTraces,
        help: null,
        customHtmlTemplatePath: null,
        version: null,
        pauseAfterLoad: null,
        debug: null,
        color: null,
        configurationPath: null,
        reporter: null,
        fileReporters: null,
        coverage: null,
        concurrency: null,
        shardIndex: null,
        totalShards: null,
        testSelections: null,
        filename: null,
        chosenPresets: null,
        presets: presets,
        overrideRuntimes: null,
        defineRuntimes: null,
        noRetry: null,
        testRandomizeOrderingSeed: null,
        stopOnFirstFailure: null,
        ignoreTimeouts: null,
        allowDuplicateTestNames: null,
        allowTestRandomization: null,
        runSkipped: null,
        dart2jsArgs: null,
        precompiledPath: null,
        globalPatterns: null,
        compilerSelections: null,
        runtimes: null,
        includeTags: null,
        excludeTags: null,
        tags: null,
        onPlatform: null,
        skip: null,
        retry: null,
        skipReason: null,
        testOn: null,
        addTags: null,
      );

  /// Suite level configuration that is not allowed in the global test
  /// config file.
  ///
  /// This configuration may alter the pass/fail result of a test run, and thus
  /// should only be configured per package and not at the global level (global
  /// config is user specific).
  factory Configuration.localTest({
    required bool? skip,
    required int? retry,
    required String? skipReason,
    required PlatformSelector? testOn,
    required Iterable<String>? addTags,
    required bool? allowDuplicateTestNames,
    required bool? allowTestRandomization,
  }) =>
      Configuration(
        allowDuplicateTestNames: allowDuplicateTestNames,
        allowTestRandomization: allowTestRandomization,
        skip: skip,
        retry: retry,
        skipReason: skipReason,
        testOn: testOn,
        addTags: addTags,
        help: null,
        customHtmlTemplatePath: null,
        version: null,
        pauseAfterLoad: null,
        debug: null,
        color: null,
        configurationPath: null,
        reporter: null,
        fileReporters: null,
        coverage: null,
        concurrency: null,
        shardIndex: null,
        totalShards: null,
        testSelections: null,
        foldTraceExcept: null,
        foldTraceOnly: null,
        filename: null,
        chosenPresets: null,
        presets: null,
        overrideRuntimes: null,
        defineRuntimes: null,
        noRetry: null,
        testRandomizeOrderingSeed: null,
        stopOnFirstFailure: null,
        jsTrace: null,
        runSkipped: null,
        dart2jsArgs: null,
        precompiledPath: null,
        globalPatterns: null,
        compilerSelections: null,
        runtimes: null,
        includeTags: null,
        excludeTags: null,
        tags: null,
        onPlatform: null,
        ignoreTimeouts: null,
        timeout: null,
        verboseTrace: null,
        chainStackTraces: null,
      );

  /// Runner configuration that is allowed in the global test config file.
  ///
  /// This is per-user configuration and should be limited as such, it should
  /// not contain options that would change the pass/fail result of any given
  /// test, or change which tests would run.
  ///
  /// Note that [customHtmlTemplatePath] violates this rule, and really should
  /// not be configurable globally.
  factory Configuration.globalRunner(
          {required bool? pauseAfterLoad,
          required String? customHtmlTemplatePath,
          required bool? runSkipped,
          required String? reporter,
          required Map<String, String>? fileReporters,
          required int? concurrency,
          required Iterable<CompilerSelection>? compilerSelections,
          required Iterable<RuntimeSelection>? runtimes,
          required Iterable<String>? chosenPresets,
          required Map<String, RuntimeSettings>? overrideRuntimes}) =>
      Configuration(
        customHtmlTemplatePath: customHtmlTemplatePath,
        pauseAfterLoad: pauseAfterLoad,
        runSkipped: runSkipped,
        reporter: reporter,
        fileReporters: fileReporters,
        concurrency: concurrency,
        compilerSelections: compilerSelections,
        runtimes: runtimes,
        chosenPresets: chosenPresets,
        overrideRuntimes: overrideRuntimes,
        help: null,
        version: null,
        debug: null,
        color: null,
        configurationPath: null,
        coverage: null,
        shardIndex: null,
        totalShards: null,
        testSelections: null,
        foldTraceExcept: null,
        foldTraceOnly: null,
        filename: null,
        presets: null,
        defineRuntimes: null,
        noRetry: null,
        testRandomizeOrderingSeed: null,
        stopOnFirstFailure: null,
        allowDuplicateTestNames: null,
        allowTestRandomization: null,
        jsTrace: null,
        dart2jsArgs: null,
        precompiledPath: null,
        globalPatterns: null,
        includeTags: null,
        excludeTags: null,
        tags: null,
        onPlatform: null,
        ignoreTimeouts: null,
        timeout: null,
        verboseTrace: null,
        chainStackTraces: null,
        skip: null,
        retry: null,
        skipReason: null,
        testOn: null,
        addTags: null,
      );

  /// Runner configuration that is not allowed in the global test config file.
  ///
  /// This configuration may alter the pass/fail result of a test run, and thus
  /// should only be configured per package and not at the global level (global
  /// config is user specific).
  factory Configuration.localRunner(
          {required Iterable<Pattern>? globalPatterns,
          required Map<String, Set<TestSelection>>? testSelections,
          required Glob? filename,
          required BooleanSelector? includeTags,
          required BooleanSelector? excludeTags,
          required Map<String, CustomRuntime>? defineRuntimes}) =>
      Configuration(
          globalPatterns: globalPatterns,
          testSelections: testSelections,
          filename: filename,
          includeTags: includeTags,
          excludeTags: excludeTags,
          defineRuntimes: defineRuntimes,
          help: null,
          customHtmlTemplatePath: null,
          version: null,
          pauseAfterLoad: null,
          debug: null,
          color: null,
          configurationPath: null,
          reporter: null,
          fileReporters: null,
          coverage: null,
          concurrency: null,
          shardIndex: null,
          totalShards: null,
          foldTraceExcept: null,
          foldTraceOnly: null,
          chosenPresets: null,
          presets: null,
          overrideRuntimes: null,
          noRetry: null,
          testRandomizeOrderingSeed: null,
          stopOnFirstFailure: null,
          allowDuplicateTestNames: null,
          allowTestRandomization: null,
          jsTrace: null,
          runSkipped: null,
          dart2jsArgs: null,
          precompiledPath: null,
          compilerSelections: null,
          runtimes: null,
          tags: null,
          onPlatform: null,
          ignoreTimeouts: null,
          timeout: null,
          verboseTrace: null,
          chainStackTraces: null,
          skip: null,
          retry: null,
          skipReason: null,
          testOn: null,
          addTags: null);

  /// A specialized constructor for configuring only `onPlatform`.
  factory Configuration.onPlatform(
          Map<PlatformSelector, SuiteConfiguration> onPlatform) =>
      Configuration._unsafe(onPlatform: onPlatform);

  factory Configuration.tags(Map<BooleanSelector, SuiteConfiguration> tags) =>
      Configuration._unsafe(tags: tags);

  static Map<String, Configuration>? _withChosenPresets(
      Map<String, Configuration>? map, Set<String>? chosenPresets) {
    if (map == null || chosenPresets == null) return map;
    return map.map((key, config) => MapEntry(
        key,
        config.change(
            chosenPresets: config.chosenPresets.union(chosenPresets))));
  }

  /// Creates new Configuration.
  ///
  /// Unlike [Configuration.new], this assumes [presets] is already resolved.
  Configuration._(
      {required bool? help,
      required this.customHtmlTemplatePath,
      required bool? version,
      required bool? pauseAfterLoad,
      required bool? debug,
      required bool? color,
      required String? configurationPath,
      required String? reporter,
      required Map<String, String>? fileReporters,
      required this.coverage,
      required int? concurrency,
      required this.shardIndex,
      required this.totalShards,
      required Map<String, Set<TestSelection>>? testSelections,
      required Iterable<String>? foldTraceExcept,
      required Iterable<String>? foldTraceOnly,
      required Glob? filename,
      required Iterable<String>? chosenPresets,
      required Map<String, Configuration>? presets,
      required Map<String, RuntimeSettings>? overrideRuntimes,
      required Map<String, CustomRuntime>? defineRuntimes,
      required bool? noRetry,
      required this.testRandomizeOrderingSeed,
      required bool? stopOnFirstFailure,
      required BooleanSelector? includeTags,
      required BooleanSelector? excludeTags,
      required Iterable<Pattern>? globalPatterns,
      required SuiteConfiguration? suiteDefaults})
      : _help = help,
        _version = version,
        _pauseAfterLoad = pauseAfterLoad,
        _debug = debug,
        _color = color,
        _configurationPath = configurationPath,
        _reporter = reporter,
        fileReporters = fileReporters ?? {},
        _concurrency = concurrency,
        _testSelections = testSelections == null || testSelections.isEmpty
            ? null
            : Map.unmodifiable(testSelections),
        _foldTraceExcept = _set(foldTraceExcept),
        _foldTraceOnly = _set(foldTraceOnly),
        _filename = filename,
        chosenPresets = UnmodifiableSetView(chosenPresets?.toSet() ?? {}),
        presets = _map(presets),
        overrideRuntimes = _map(overrideRuntimes),
        defineRuntimes = _map(defineRuntimes),
        _noRetry = noRetry,
        includeTags = includeTags ?? BooleanSelector.all,
        excludeTags = excludeTags ?? BooleanSelector.none,
        globalPatterns = globalPatterns == null
            ? const {}
            : UnmodifiableSetView(globalPatterns.toSet()),
        _stopOnFirstFailure = stopOnFirstFailure,
        suiteDefaults = (() {
          var config = suiteDefaults ?? SuiteConfiguration.empty;
          if (pauseAfterLoad == true) {
            return config.change(ignoreTimeouts: true);
          }
          return config;
        }()) {
    if (_filename != null && _filename.context.style != p.style) {
      throw ArgumentError(
          "filename's context must match the current operating system, was "
          '${_filename.context.style}.');
    }

    if ((shardIndex == null) != (totalShards == null)) {
      throw ArgumentError(
          'shardIndex and totalShards may only be passed together.');
    } else if (shardIndex != null) {
      RangeError.checkValueInInterval(
          shardIndex!, 0, totalShards! - 1, 'shardIndex');
    }
  }

  /// Creates a new [Configuration] that takes its configuration from
  /// [SuiteConfiguration].
  factory Configuration.fromSuiteConfiguration(
          SuiteConfiguration suiteConfig) =>
      Configuration._(
        suiteDefaults: suiteConfig,
        globalPatterns: null,
        help: null,
        customHtmlTemplatePath: null,
        version: null,
        pauseAfterLoad: null,
        debug: null,
        color: null,
        configurationPath: null,
        reporter: null,
        fileReporters: null,
        coverage: null,
        concurrency: null,
        shardIndex: null,
        totalShards: null,
        testSelections: null,
        foldTraceExcept: null,
        foldTraceOnly: null,
        filename: null,
        chosenPresets: null,
        presets: null,
        overrideRuntimes: null,
        defineRuntimes: null,
        noRetry: null,
        testRandomizeOrderingSeed: null,
        stopOnFirstFailure: null,
        includeTags: null,
        excludeTags: null,
      );

  /// Returns a set from [input].
  ///
  /// If [input] is `null` or empty, this returns `null`.
  static Set<T>? _set<T>(Iterable<T>? input) {
    if (input == null) return null;
    var set = Set<T>.from(input);
    if (set.isEmpty) return null;
    return set;
  }

  /// Returns an unmodifiable copy of [input] or an empty unmodifiable map.
  static Map<K, V> _map<K, V>(Map<K, V>? input) {
    input ??= {};
    return Map.unmodifiable(input);
  }

  /// Runs [body] with this as [Configuration.current].
  ///
  /// This is zone-scoped, so this will be the current configuration in any
  /// asynchronous callbacks transitively created by [body].
  T asCurrent<T>(T Function() body) =>
      runZoned(body, zoneValues: {_currentKey: this});

  /// Throws a [FormatException] if this refers to any undefined runtimes.
  void validateRuntimes(List<Runtime> allRuntimes) {
    // We don't need to verify [customRuntimes] here because those runtimes
    // already need to be verified and resolved to create [allRuntimes].

    for (var settings in overrideRuntimes.values) {
      if (!allRuntimes
          .any((runtime) => runtime.identifier == settings.identifier)) {
        throw SourceSpanFormatException(
            'Unknown platform "${settings.identifier}".',
            settings.identifierSpan);
      }
    }

    suiteDefaults.validateRuntimes(allRuntimes);
    for (var config in presets.values) {
      config.validateRuntimes(allRuntimes);
    }
  }

  /// Merges this with [other].
  ///
  /// For most fields, if both configurations have values set, [other]'s value
  /// takes precedence. However, certain fields are merged together instead.
  /// This is indicated in those fields' documentation.
  Configuration merge(Configuration other) {
    if (this == Configuration.empty) return other;
    if (other == Configuration.empty) return this;

    var foldTraceOnly = other._foldTraceOnly ?? _foldTraceOnly;
    var foldTraceExcept = other._foldTraceExcept ?? _foldTraceExcept;
    if (_foldTraceOnly != null) {
      if (other._foldTraceExcept != null) {
        foldTraceOnly = _foldTraceOnly.difference(other._foldTraceExcept);
      } else if (other._foldTraceOnly != null) {
        foldTraceOnly = other._foldTraceOnly.intersection(_foldTraceOnly);
      }
    } else if (_foldTraceExcept != null) {
      if (other._foldTraceOnly != null) {
        foldTraceOnly = other._foldTraceOnly.difference(_foldTraceExcept);
      } else if (other._foldTraceExcept != null) {
        foldTraceExcept = other._foldTraceExcept.union(_foldTraceExcept);
      }
    }

    var result = Configuration._(
        help: other._help ?? _help,
        customHtmlTemplatePath:
            other.customHtmlTemplatePath ?? customHtmlTemplatePath,
        version: other._version ?? _version,
        pauseAfterLoad: other._pauseAfterLoad ?? _pauseAfterLoad,
        debug: other._debug ?? _debug,
        color: other._color ?? _color,
        configurationPath: other._configurationPath ?? _configurationPath,
        reporter: other._reporter ?? _reporter,
        fileReporters: mergeMaps(fileReporters, other.fileReporters),
        coverage: other.coverage ?? coverage,
        concurrency: other._concurrency ?? _concurrency,
        shardIndex: other.shardIndex ?? shardIndex,
        totalShards: other.totalShards ?? totalShards,
        testSelections: other._testSelections ?? _testSelections,
        foldTraceExcept: foldTraceExcept,
        foldTraceOnly: foldTraceOnly,
        filename: other._filename ?? _filename,
        chosenPresets: chosenPresets.union(other.chosenPresets),
        presets: _mergeConfigMaps(presets, other.presets),
        overrideRuntimes: mergeUnmodifiableMaps(
            overrideRuntimes, other.overrideRuntimes,
            value: (settings1, settings2) => RuntimeSettings(
                settings1.identifier,
                settings1.identifierSpan,
                [...settings1.settings, ...settings2.settings])),
        defineRuntimes:
            mergeUnmodifiableMaps(defineRuntimes, other.defineRuntimes),
        noRetry: other._noRetry ?? _noRetry,
        testRandomizeOrderingSeed:
            other.testRandomizeOrderingSeed ?? testRandomizeOrderingSeed,
        stopOnFirstFailure: other._stopOnFirstFailure ?? _stopOnFirstFailure,
        includeTags: includeTags.intersection(other.includeTags),
        excludeTags: excludeTags.union(other.excludeTags),
        globalPatterns: globalPatterns.union(other.globalPatterns),
        suiteDefaults: suiteDefaults.merge(other.suiteDefaults));
    result = result._resolvePresets();

    // Make sure the merged config preserves any presets that were chosen and
    // discarded.
    result._knownPresets = knownPresets.union(other.knownPresets);
    return result;
  }

  /// Returns a copy of this configuration with the given fields updated.
  ///
  /// Note that unlike [merge], this has no merging behavior—the old value is
  /// always replaced by the new one.
  Configuration change(
      {bool? help,
      String? customHtmlTemplatePath,
      bool? version,
      bool? pauseAfterLoad,
      bool? debug,
      bool? color,
      String? configurationPath,
      String? reporter,
      Map<String, String>? fileReporters,
      String? coverage,
      int? concurrency,
      int? shardIndex,
      int? totalShards,
      Map<String, Set<TestSelection>>? testSelections,
      Iterable<String>? exceptPackages,
      Iterable<String>? onlyPackages,
      Glob? filename,
      Iterable<String>? chosenPresets,
      Map<String, Configuration>? presets,
      Map<String, RuntimeSettings>? overrideRuntimes,
      Map<String, CustomRuntime>? defineRuntimes,
      bool? noRetry,
      int? testRandomizeOrderingSeed,
      bool? ignoreTimeouts,

      // Suite-level configuration
      bool? allowDuplicateTestNames,
      bool? jsTrace,
      bool? runSkipped,
      Iterable<String>? dart2jsArgs,
      String? precompiledPath,
      Iterable<RuntimeSelection>? runtimes,
      BooleanSelector? includeTags,
      BooleanSelector? excludeTags,
      Map<BooleanSelector, SuiteConfiguration>? tags,
      Map<PlatformSelector, SuiteConfiguration>? onPlatform,

      // Test-level configuration
      Timeout? timeout,
      bool? verboseTrace,
      bool? chainStackTraces,
      bool? skip,
      String? skipReason,
      PlatformSelector? testOn,
      Iterable<String>? addTags}) {
    var config = Configuration._(
        help: help ?? _help,
        customHtmlTemplatePath:
            customHtmlTemplatePath ?? this.customHtmlTemplatePath,
        version: version ?? _version,
        pauseAfterLoad: pauseAfterLoad ?? _pauseAfterLoad,
        debug: debug ?? _debug,
        color: color ?? _color,
        configurationPath: configurationPath ?? _configurationPath,
        reporter: reporter ?? _reporter,
        fileReporters: fileReporters ?? this.fileReporters,
        coverage: coverage ?? this.coverage,
        concurrency: concurrency ?? _concurrency,
        shardIndex: shardIndex ?? this.shardIndex,
        totalShards: totalShards ?? this.totalShards,
        testSelections: testSelections ?? _testSelections,
        foldTraceExcept: exceptPackages ?? _foldTraceExcept,
        foldTraceOnly: onlyPackages ?? _foldTraceOnly,
        filename: filename ?? _filename,
        chosenPresets: chosenPresets ?? this.chosenPresets,
        presets: presets ?? this.presets,
        overrideRuntimes: overrideRuntimes ?? this.overrideRuntimes,
        defineRuntimes: defineRuntimes ?? this.defineRuntimes,
        noRetry: noRetry ?? _noRetry,
        testRandomizeOrderingSeed:
            testRandomizeOrderingSeed ?? this.testRandomizeOrderingSeed,
        stopOnFirstFailure: _stopOnFirstFailure,
        includeTags: includeTags,
        excludeTags: excludeTags,
        globalPatterns: globalPatterns,
        suiteDefaults: suiteDefaults.change(
          allowDuplicateTestNames: allowDuplicateTestNames,
          jsTrace: jsTrace,
          runSkipped: runSkipped,
          dart2jsArgs: dart2jsArgs,
          precompiledPath: precompiledPath,
          runtimes: runtimes,
          tags: tags,
          onPlatform: onPlatform,
          timeout: timeout,
          verboseTrace: verboseTrace,
          chainStackTraces: chainStackTraces,
          skip: skip,
          skipReason: skipReason,
          testOn: testOn,
          addTags: addTags,
          ignoreTimeouts: ignoreTimeouts,
        ));
    return config._resolvePresets();
  }

  /// Merges two maps whose values are [Configuration]s.
  ///
  /// Any overlapping keys in the maps have their configurations merged in the
  /// returned map.
  Map<String, Configuration> _mergeConfigMaps(
          Map<String, Configuration> map1, Map<String, Configuration> map2) =>
      mergeMaps(map1, map2,
          value: (config1, config2) => config1.merge(config2));

  /// Returns a copy of this [Configuration] with all [chosenPresets] resolved
  /// against [presets].
  Configuration _resolvePresets() {
    if (chosenPresets.isEmpty || presets.isEmpty) return this;

    var newPresets = Map<String, Configuration>.from(presets);
    var merged = chosenPresets.fold(
        empty,
        (Configuration merged, preset) =>
            merged.merge(newPresets.remove(preset) ?? Configuration.empty));

    if (merged == empty) return this;
    var result = change(presets: newPresets).merge(merged);

    // Make sure the configuration knows about presets that were selected and
    // thus removed from [newPresets].
    result._knownPresets =
        UnmodifiableSetView({...result.knownPresets, ...presets.keys});

    return result;
  }
}
