// 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 ||
      coverageLcov != null;
  final bool? _debug;

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

  /// The lcov file to output coverage to.
  final String? coverageLcov;

  /// Whether to collect branch coverage info.
  bool get branchCoverage => _branchCoverage ?? false;
  final bool? _branchCoverage;

  /// A list of regular expressions to match against the package names during
  /// coverage collection.
  ///
  /// Only packages that match at least one of the regexps will be included in
  /// the coverage report.
  final List<RegExp>? coveragePackages;

  /// 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 String? coverageLcov,
    required bool? branchCoverage,
    required List<RegExp>? coveragePackages,
    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,
    required Timeout? suiteLoadTimeout,

    // 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,
      coverageLcov: coverageLcov,
      branchCoverage: branchCoverage,
      coveragePackages: coveragePackages,
      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,
        suiteLoadTimeout: suiteLoadTimeout,

        // 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,
    String? coverageLcov,
    bool? branchCoverage,
    List<RegExp>? coveragePackages,
    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,
    Timeout? suiteLoadTimeout,

    // 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,
    coverageLcov: coverageLcov,
    branchCoverage: branchCoverage,
    coveragePackages: coveragePackages,
    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,
    suiteLoadTimeout: suiteLoadTimeout,
    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 Timeout? suiteLoadTimeout,
    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,
    coverageLcov: null,
    branchCoverage: null,
    coveragePackages: 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,
    suiteLoadTimeout: suiteLoadTimeout,
    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,
    coverageLcov: null,
    branchCoverage: null,
    coveragePackages: 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,
    suiteLoadTimeout: 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,
    coverageLcov: null,
    branchCoverage: null,
    coveragePackages: 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,
    suiteLoadTimeout: 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,
    coverageLcov: null,
    branchCoverage: null,
    coveragePackages: 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,
    suiteLoadTimeout: 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 this.coverageLcov,
    required bool? branchCoverage,
    required this.coveragePackages,
    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 ?? {},
       _branchCoverage = branchCoverage,
       _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,
    coverageLcov: null,
    branchCoverage: null,
    coveragePackages: 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,
      coverageLcov: other.coverageLcov ?? coverageLcov,
      branchCoverage: other._branchCoverage ?? _branchCoverage,
      coveragePackages: other.coveragePackages ?? coveragePackages,
      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,
    String? coverageLcov,
    bool? branchCoverage,
    List<RegExp>? coveragePackages,
    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,
      coverageLcov: coverageLcov ?? this.coverageLcov,
      branchCoverage: branchCoverage ?? _branchCoverage,
      coveragePackages: coveragePackages ?? this.coveragePackages,
      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;
  }
}
