// Copyright (c) 2019, 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 'annotated_code_helper.dart';
import 'id.dart';
import 'id_generation.dart';
import '../util/colors.dart' as colors;
import '../util/options.dart';

const String cfeMarker = 'cfe';
const String dart2jsMarker = 'dart2js';
const String analyzerMarker = 'analyzer';
const String ddcMarker = 'ddc';

/// Markers used in annotated tests shared by CFE, analyzer and dart2js.
const List<String> sharedMarkers = [cfeMarker, dart2jsMarker, analyzerMarker];

/// Markers used in annotated tests shared by CFE and analyzer.
const List<String> cfeAnalyzerMarkers = [cfeMarker, analyzerMarker];

/// `true` if ANSI colors are supported by stdout.
bool useColors = stdout.supportsAnsiEscapes;

/// Colorize a message [text], if ANSI colors are supported.
String colorizeMessage(String text) {
  if (useColors) {
    return '${colors.YELLOW_COLOR}${text}${colors.DEFAULT_COLOR}';
  } else {
    return text;
  }
}

/// Colorize a matching annotation [text], if ANSI colors are supported.
String colorizeMatch(String text) {
  if (useColors) {
    return '${colors.BLUE_COLOR}${text}${colors.DEFAULT_COLOR}';
  } else {
    return text;
  }
}

/// Colorize a single annotation [text], if ANSI colors are supported.
String colorizeSingle(String text) {
  if (useColors) {
    return '${colors.GREEN_COLOR}${text}${colors.DEFAULT_COLOR}';
  } else {
    return text;
  }
}

/// Colorize the actual annotation [text], if ANSI colors are supported.
String colorizeActual(String text) {
  if (useColors) {
    return '${colors.RED_COLOR}${text}${colors.DEFAULT_COLOR}';
  } else {
    return text;
  }
}

/// Colorize an expected annotation [text], if ANSI colors are supported.
String colorizeExpected(String text) {
  if (useColors) {
    return '${colors.GREEN_COLOR}${text}${colors.DEFAULT_COLOR}';
  } else {
    return text;
  }
}

/// Colorize delimiter [text], if ANSI colors are supported.
String colorizeDelimiter(String text) {
  if (useColors) {
    return '${colors.YELLOW_COLOR}${text}${colors.DEFAULT_COLOR}';
  } else {
    return text;
  }
}

/// Colorize diffs [expected] and [actual] and [delimiter], if ANSI colors are
/// supported.
String colorizeDiff(String expected, String delimiter, String actual) {
  return '${colorizeExpected(expected)}'
      '${colorizeDelimiter(delimiter)}${colorizeActual(actual)}';
}

/// Colorize annotation delimiters [start] and [end] surrounding [text], if
/// ANSI colors are supported.
String colorizeAnnotation(String start, String text, String end) {
  return '${colorizeDelimiter(start)}$text${colorizeDelimiter(end)}';
}

/// Creates an annotation that shows the difference between [expected] and
/// [actual].
Annotation? createAnnotationsDiff(Annotation? expected, Annotation? actual) {
  if (identical(expected, actual)) return null;
  if (expected != null && actual != null) {
    return new Annotation(
      expected.index,
      expected.lineNo,
      expected.columnNo,
      expected.offset,
      expected.prefix,
      '${colorizeExpected(expected.text)}'
      '${colorizeDelimiter(' | ')}'
      '${colorizeActual(actual.text)}',
      expected.suffix,
    );
  } else if (expected != null) {
    return new Annotation(
      expected.index,
      expected.lineNo,
      expected.columnNo,
      expected.offset,
      expected.prefix,
      '${colorizeExpected(expected.text)}'
      '${colorizeDelimiter(' | ')}'
      '${colorizeActual('---')}',
      expected.suffix,
    );
  } else if (actual != null) {
    return new Annotation(
      actual.index,
      actual.lineNo,
      actual.columnNo,
      actual.offset,
      actual.prefix,
      '${colorizeExpected('---')}'
      '${colorizeDelimiter(' | ')}'
      '${colorizeActual(actual.text)}',
      actual.suffix,
    );
  } else {
    return null;
  }
}

/// Encapsulates the member data computed for each source file of interest.
/// It's a glorified wrapper around a map of maps, but written this way to
/// provide a little more information about what it's doing. [DataType] refers
/// to the type this map is holding -- it is either [IdValue] or [ActualData].
class MemberAnnotations<DataType> {
  /// For each Uri, we create a map associating an element id with its
  /// corresponding annotations.
  final Map<Uri, Map<Id, DataType>> _computedDataForEachFile =
      new Map<Uri, Map<Id, DataType>>();

  /// Member or class annotations that don't refer to any of the user files.
  final Map<Id, DataType> globalData = <Id, DataType>{};

  void operator []=(Uri file, Map<Id, DataType> computedData) {
    _computedDataForEachFile[file] = computedData;
  }

  void forEach(void f(Uri file, Map<Id, DataType> computedData)) {
    _computedDataForEachFile.forEach(f);
  }

  Map<Id, DataType>? operator [](Uri file) {
    if (!_computedDataForEachFile.containsKey(file)) {
      _computedDataForEachFile[file] = <Id, DataType>{};
    }
    return _computedDataForEachFile[file];
  }

  @override
  String toString() {
    StringBuffer sb = new StringBuffer();
    sb.write('MemberAnnotations(');
    String comma = '';
    if (_computedDataForEachFile.isNotEmpty &&
        (_computedDataForEachFile.length > 1 ||
            _computedDataForEachFile.values.single.isNotEmpty)) {
      sb.write('data:{');
      _computedDataForEachFile.forEach((Uri uri, Map<Id, DataType> data) {
        sb.write(comma);
        sb.write('$uri:');
        sb.write(data);
        comma = ',';
      });
      sb.write('}');
    }
    if (globalData.isNotEmpty) {
      sb.write(comma);
      sb.write('global:');
      sb.write(globalData);
    }
    sb.write(')');
    return sb.toString();
  }
}

/// Compute a [MemberAnnotations] object from [code] for each marker in [maps]
/// specifying the expected annotations.
///
/// If an annotation starts with a marker, it is only expected for the
/// corresponding test configuration. Otherwise it is expected for all
/// configurations.
// TODO(johnniwinther): Support an empty marker set.
void computeExpectedMap(
  Uri sourceUri,
  String filename,
  AnnotatedCode code,
  Map<String, MemberAnnotations<IdValue>> maps, {
  required void onFailure(String message),
  bool preserveWhitespaceInAnnotations = false,
  bool preserveInfixWhitespaceInAnnotations = false,
}) {
  List<String> mapKeys = maps.keys.toList();
  Map<String, AnnotatedCode> split = splitByPrefixes(code, mapKeys);

  split.forEach((String marker, AnnotatedCode code) {
    MemberAnnotations<IdValue> fileAnnotations = maps[marker]!;
    Map<Id, IdValue> expectedValues = fileAnnotations[sourceUri]!;
    for (Annotation annotation in code.annotations) {
      String text = annotation.text;
      IdValue idValue = IdValue.decode(
        sourceUri,
        annotation,
        text,
        preserveWhitespaceInAnnotations: preserveWhitespaceInAnnotations,
        preserveInfixWhitespace: preserveInfixWhitespaceInAnnotations,
      );
      if (idValue.id.isGlobal) {
        if (fileAnnotations.globalData.containsKey(idValue.id)) {
          onFailure(
            "Error in test '$filename': "
            "Duplicate annotations for ${idValue.id} in $marker: "
            "${idValue} and ${fileAnnotations.globalData[idValue.id]}.",
          );
        }
        fileAnnotations.globalData[idValue.id] = idValue;
      } else {
        if (expectedValues.containsKey(idValue.id)) {
          onFailure(
            "Error in test '$filename': "
            "Duplicate annotations for ${idValue.id} in $marker: "
            "${idValue} and ${expectedValues[idValue.id]}.",
          );
        }
        expectedValues[idValue.id] = idValue;
      }
    }
  });
}

/// Creates a [TestData] object for the annotated test in [testFile].
///
/// If [testFile] is a file, use that directly. If it's a directory include
/// everything in that directory.
TestData computeTestData(
  FileSystemEntity testFile, {
  required Iterable<String> supportedMarkers,
  required Uri createTestUri(Uri uri, String fileName),
  required void onFailure(String message),
  String Function(String) preprocessFile = _noProcessing,
  bool preserveWhitespaceInAnnotations = false,
  bool preserveInfixWhitespaceInAnnotations = false,
}) {
  Uri? entryPoint;

  String testName;
  File? mainTestFile;
  Uri testFileUri = testFile.uri;
  Map<String, File>? additionalFiles;
  if (testFile is File) {
    testName = testFileUri.pathSegments.last;
    mainTestFile = testFile;
    entryPoint = createTestUri(mainTestFile.uri, 'main.dart');
  } else if (testFile is Directory) {
    testName = testFileUri.pathSegments[testFileUri.pathSegments.length - 2];
    additionalFiles = new Map<String, File>();
    for (FileSystemEntity entry
        in testFile
            .listSync(recursive: true)
            .where((entity) => !entity.path.endsWith('~'))) {
      if (entry is! File) continue;
      if (entry.uri.pathSegments.last == "main.dart") {
        mainTestFile = entry;
        entryPoint = createTestUri(mainTestFile.uri, 'main.dart');
      } else {
        additionalFiles[entry.uri.path.substring(testFile.uri.path.length)] =
            entry;
      }
    }
    assert(
      mainTestFile != null,
      "No 'main.dart' test file found for $testFile.",
    );
  } else {
    throw new UnimplementedError();
  }

  String annotatedCode = preprocessFile(
    new File.fromUri(mainTestFile!.uri).readAsStringSync(),
  );
  Map<Uri, AnnotatedCode> code = {
    entryPoint!: new AnnotatedCode.fromText(
      annotatedCode,
      commentStart,
      commentEnd,
    ),
  };
  Map<String, MemberAnnotations<IdValue>> expectedMaps = {};
  for (String testMarker in supportedMarkers) {
    expectedMaps[testMarker] = new MemberAnnotations<IdValue>();
  }
  computeExpectedMap(
    entryPoint,
    testFile.uri.pathSegments.last,
    code[entryPoint]!,
    expectedMaps,
    onFailure: onFailure,
    preserveWhitespaceInAnnotations: preserveWhitespaceInAnnotations,
    preserveInfixWhitespaceInAnnotations: preserveInfixWhitespaceInAnnotations,
  );
  Map<String, String> memorySourceFiles = {
    entryPoint.path: code[entryPoint]!.sourceCode,
  };

  if (additionalFiles != null) {
    for (MapEntry<String, File> additionalFileData in additionalFiles.entries) {
      String libFileName = additionalFileData.key;
      File libEntity = additionalFileData.value;
      Uri libFileUri = createTestUri(libEntity.uri, libFileName);
      String libCode = preprocessFile(libEntity.readAsStringSync());
      AnnotatedCode annotatedLibCode = new AnnotatedCode.fromText(
        libCode,
        commentStart,
        commentEnd,
      );
      memorySourceFiles[libFileUri.path] = annotatedLibCode.sourceCode;
      code[libFileUri] = annotatedLibCode;
      computeExpectedMap(
        libFileUri,
        libFileName,
        annotatedLibCode,
        expectedMaps,
        onFailure: onFailure,
        preserveWhitespaceInAnnotations: preserveWhitespaceInAnnotations,
        preserveInfixWhitespaceInAnnotations:
            preserveInfixWhitespaceInAnnotations,
      );
    }
  }

  return new TestData(
    testName,
    testFileUri,
    entryPoint,
    memorySourceFiles,
    code,
    expectedMaps,
  );
}

/// Data for an annotated test.
class TestData {
  final String name;
  final Uri testFileUri;
  final Uri entryPoint;
  final Map<String, String> memorySourceFiles;
  final Map<Uri, AnnotatedCode> code;
  final Map<String, MemberAnnotations<IdValue>> expectedMaps;

  TestData(
    this.name,
    this.testFileUri,
    this.entryPoint,
    this.memorySourceFiles,
    this.code,
    this.expectedMaps,
  );
}

/// The results for running a test on a single configuration.
class TestResult<T> {
  /// `true` if the [compiledData]  didn't match the expected annotations.
  final bool hasMismatches;

  /// `true` if the test couldn't be run due to errors in the test setup.
  final bool isErroneous;

  /// The data interpreter used to verify the [compiledData].
  final DataInterpreter<T>? interpreter;

  /// The actual data computed for the test.
  final CompiledData<T>? compiledData;

  TestResult(this.interpreter, this.compiledData, this.hasMismatches)
    : isErroneous = false;

  TestResult.erroneous()
    : isErroneous = true,
      hasMismatches = false,
      interpreter = null,
      compiledData = null;

  bool get hasFailures => hasMismatches || isErroneous;
}

/// The actual result computed for an annotated test.
abstract class CompiledData<T> {
  final Uri mainUri;

  /// For each Uri, a map associating an element id with the instrumentation
  /// data we've collected for it.
  final Map<Uri, Map<Id, ActualData<T>>> actualMaps;

  /// Collected instrumentation data that doesn't refer to any of the user
  /// files.  (E.g. information the test has collected about files in
  /// `dart:core`).
  final Map<Id, ActualData<T>> globalData;

  CompiledData(this.mainUri, this.actualMaps, this.globalData);

  Map<int, List<String>> computeAnnotations(Uri uri) {
    Map<Id, ActualData<T>> actualMap = actualMaps[uri]!;
    Map<int, List<String>> annotations = <int, List<String>>{};
    actualMap.forEach((Id id, ActualData<T> data) {
      String value1 = '${data.value}';
      annotations
          .putIfAbsent(data.offset, () => [])
          .add(colorizeActual(value1));
    });
    return annotations;
  }

  Map<int, List<String>> computeDiffAnnotationsAgainst(
    Map<Id, ActualData<T>> thisMap,
    Map<Id, ActualData<T>> otherMap,
    Uri uri, {
    bool includeMatches = false,
  }) {
    Map<int, List<String>> annotations = <int, List<String>>{};
    thisMap.forEach((Id id, ActualData<T> thisData) {
      ActualData<T>? otherData = otherMap[id];
      String thisValue = '${thisData.value}';
      if (thisData.value != otherData?.value) {
        String otherValue = '${otherData?.value ?? '---'}';
        annotations
            .putIfAbsent(thisData.offset, () => [])
            .add(colorizeDiff(thisValue, ' | ', otherValue));
      } else if (includeMatches) {
        annotations
            .putIfAbsent(thisData.offset, () => [])
            .add(colorizeMatch(thisValue));
      }
    });
    otherMap.forEach((Id id, ActualData<T> otherData) {
      if (!thisMap.containsKey(id)) {
        String thisValue = '---';
        String otherValue = '${otherData.value}';
        annotations
            .putIfAbsent(otherData.offset, () => [])
            .add(colorizeDiff(thisValue, ' | ', otherValue));
      }
    });
    return annotations;
  }

  int getOffsetFromId(Id id, Uri uri);

  void reportError(
    Uri uri,
    int offset,
    String message, {
    bool succinct = false,
  });
}

/// Interface used for interpreting annotations.
abstract class DataInterpreter<T> {
  /// Returns `null` if [actualData] satisfies the [expectedData] annotation.
  /// Otherwise, a message is returned contain the information about the
  /// problems found.
  String? isAsExpected(T actualData, String? expectedData);

  /// Returns `true` if [actualData] corresponds to empty data.
  bool isEmpty(T actualData);

  /// Returns a textual representation of [actualData].
  ///
  /// If [indentation] is provided a multiline pretty printing can be returned
  /// using [indentation] for additional lines.
  String getText(T actualData, [String? indentation]);
}

/// Default data interpreter for string data.
class StringDataInterpreter implements DataInterpreter<String> {
  const StringDataInterpreter();

  @override
  String? isAsExpected(String actualData, String? expectedData) {
    expectedData ??= '';
    if (actualData != expectedData) {
      return "Expected $expectedData, found $actualData";
    }
    return null;
  }

  @override
  bool isEmpty(String actualData) {
    return actualData == '';
  }

  @override
  String getText(String actualData, [String? indentation]) {
    return actualData;
  }
}

String withAnnotations(String sourceCode, Map<int, List<String>> annotations) {
  StringBuffer sb = new StringBuffer();
  int end = 0;
  for (int offset in annotations.keys.toList()..sort()) {
    if (offset >= sourceCode.length) {
      sb.write('...');
      return sb.toString();
    }
    if (offset > end) {
      sb.write(sourceCode.substring(end, offset));
    }
    for (String annotation in annotations[offset]!) {
      sb.write(colorizeAnnotation('/*', annotation, '*/'));
    }
    end = offset;
  }
  if (end < sourceCode.length) {
    sb.write(sourceCode.substring(end));
  }
  return sb.toString();
}

/// Checks [compiledData] against the expected data in [expectedMaps] derived
/// from [testData].code.
Future<TestResult<T>> checkCode<T>(
  MarkerOptions markerOptions,
  String marker,
  String modeName,
  TestData testData,
  MemberAnnotations<IdValue> expectedMaps,
  CompiledData<T> compiledData,
  DataInterpreter<T> dataInterpreter, {
  bool filterActualData(IdValue? expected, ActualData<T> actualData)?,
  bool fatalErrors = true,
  bool succinct = false,
  required void onFailure(String message),
}) async {
  String testName = testData.name;
  Map<Uri, AnnotatedCode> code = testData.code;

  bool hasFailure = false;
  Set<Uri> neededDiffs = new Set<Uri>();

  void checkActualMap(
    Map<Id, ActualData<T>> actualMap,
    Map<Id, IdValue>? expectedMap,
    Uri uri,
  ) {
    expectedMap ??= {};
    bool hasLocalFailure = false;
    actualMap.forEach((Id id, ActualData<T> actualData) {
      T actual = actualData.value;
      String actualText = dataInterpreter.getText(actual);

      if (!expectedMap!.containsKey(id)) {
        if (!dataInterpreter.isEmpty(actual)) {
          String actualValueText = IdValue.idToString(id, actualText);
          compiledData.reportError(
            actualData.uri,
            actualData.offset,
            succinct
                ? 'EXTRA $modeName DATA for ${id.descriptor}'
                : 'EXTRA $modeName DATA for ${id.descriptor}:\n '
                      'object   : ${actualData.objectText}\n '
                      'actual   : ${colorizeActual(actualValueText)}\n '
                      'Data was expected for these ids: ${expectedMap.keys}',
            succinct: succinct,
          );
          if (filterActualData == null || filterActualData(null, actualData)) {
            hasLocalFailure = true;
          }
        }
      } else {
        IdValue expected = expectedMap[id]!;
        String? unexpectedMessage = dataInterpreter.isAsExpected(
          actual,
          expected.value,
        );
        if (unexpectedMessage != null) {
          String actualValueText = IdValue.idToString(id, actualText);
          compiledData.reportError(
            actualData.uri,
            actualData.offset,
            succinct
                ? 'UNEXPECTED $modeName DATA for ${id.descriptor}'
                : 'UNEXPECTED $modeName DATA for ${id.descriptor}:\n '
                      'detail  : ${colorizeMessage(unexpectedMessage)}\n '
                      'object  : ${actualData.objectText}\n '
                      'expected: ${colorizeExpected('$expected')}\n '
                      'actual  : ${colorizeActual(actualValueText)}',
            succinct: succinct,
          );
          if (filterActualData == null ||
              filterActualData(expected, actualData)) {
            hasLocalFailure = true;
          }
        }
      }
    });
    if (hasLocalFailure) {
      hasFailure = true;
      neededDiffs.add(uri);
    }
  }

  compiledData.actualMaps.forEach((Uri uri, Map<Id, ActualData<T>> actualMap) {
    checkActualMap(actualMap, expectedMaps[uri], uri);
  });
  checkActualMap(
    compiledData.globalData,
    expectedMaps.globalData,
    Uri.parse("global:data"),
  );

  Set<Id> missingIds = new Set<Id>();
  void checkMissing(
    Map<Id, IdValue>? expectedMap,
    Map<Id, ActualData<T>>? actualMap, [
    Uri? uri,
  ]) {
    actualMap ??= {};
    expectedMap?.forEach((Id id, IdValue expected) {
      if (!actualMap!.containsKey(id)) {
        missingIds.add(id);
        String message =
            'MISSING $modeName DATA for ${id.descriptor}: '
            'Expected ${colorizeExpected('$expected')}';
        if (uri != null) {
          compiledData.reportError(
            uri,
            compiledData.getOffsetFromId(id, uri),
            message,
            succinct: succinct,
          );
        } else {
          print(message);
        }
      }
    });
    if (missingIds.isNotEmpty && uri != null) {
      neededDiffs.add(uri);
    }
  }

  expectedMaps.forEach((Uri uri, Map<Id, IdValue> expectedMap) {
    checkMissing(expectedMap, compiledData.actualMaps[uri], uri);
  });
  checkMissing(expectedMaps.globalData, compiledData.globalData);
  if (!succinct) {
    if (neededDiffs.isNotEmpty) {
      Map<Uri, List<Annotation>> annotations = computeAnnotationsPerUri(
        code,
        {'dummyMarker': expectedMaps},
        compiledData.mainUri,
        {'dummyMarker': compiledData.actualMaps},
        dataInterpreter,
        createDiff: createAnnotationsDiff,
      );
      for (Uri uri in neededDiffs) {
        print('--annotations diff [${uri.pathSegments.last}]-------------');
        AnnotatedCode? annotatedCode = code[uri];
        print(
          new AnnotatedCode(
            annotatedCode?.annotatedCode ?? "",
            annotatedCode?.sourceCode ?? "",
            annotations[uri] ?? const [],
          ).toText(),
        );
        print('----------------------------------------------------------');
      }
    }
  }
  if (missingIds.isNotEmpty) {
    print("MISSING ids: ${missingIds}.");
    hasFailure = true;
  }
  if (hasFailure && fatalErrors) {
    onFailure(
      generateErrorMessage(
        markerOptions,
        mismatches: {
          testName: {marker},
        },
      ),
    );
  }
  return new TestResult<T>(dataInterpreter, compiledData, hasFailure);
}

typedef Future<Map<String, TestResult<T>>> RunTestFunction<T>(
  MarkerOptions markerOptions,
  TestData testData, {
  required bool testAfterFailures,
  required bool verbose,
  required bool succinct,
  required bool printCode,
  Map<String, List<String>>? skipMap,
  required Uri nullUri,
});

/// Compute the file: URI of the file located at `path`, where `path` is
/// relative to the root of the SDK repository.
///
/// We find the root of the SDK repository by looking for the parent of the
/// directory named `pkg`.
Uri _fileUriFromSdkRoot(String path) {
  Uri uri = Platform.script;
  List<String> pathSegments = uri.pathSegments;
  return uri.replace(
    pathSegments: [
      ...pathSegments.sublist(0, pathSegments.lastIndexOf('pkg')),
      ...path.split('/'),
    ],
  );
}

/// Description of a tester for a specific marker.
///
/// A tester is the runnable dart script used to run the tests for a certain
/// configurations. For instance
/// `pkg/front_end/test/id_tests/constant_test.dart` is used to run the test
/// data in `pkg/_fe_analyzer_shared/test/constants/data/` using the front end
/// and `pkg/analyzer/test/id_tests/constant_test.dart` is used to run the same
/// test data using the analyzer.
class MarkerTester {
  /// The marker supported by this tester, for instance 'cfe' for front end
  /// testing.
  final String marker;

  /// The path of the tester relative to the sdk repo root, for instance
  /// `pkg/front_end/test/id_tests/constant_test.dart`.
  final String path;

  /// The resolved file uri for the test.
  final Uri uri;

  MarkerTester(this.marker, this.path, this.uri);

  @override
  String toString() => 'MarkerTester($marker,$path,$uri)';
}

/// A model of the testers defined in a `marker.options` file, located in the
/// data folder for id tests.
class MarkerOptions {
  /// The supported markers and their corresponding testers.
  final Map<String, MarkerTester> markers;

  MarkerOptions.internal(this.markers);

  factory MarkerOptions.fromDataDir(
    Directory dataDir, {
    bool shouldFindScript = true,
  }) {
    File file = new File.fromUri(dataDir.uri.resolve('marker.options'));
    File script = new File.fromUri(Platform.script);
    if (!file.existsSync()) {
      throw new ArgumentError(
        "Marker option file '${file.uri}' doesn't exist.",
      );
    }

    Map<String, MarkerTester> markers = {};
    String text = file.readAsStringSync();
    bool isScriptFound = false;
    for (String line in text.split('\n')) {
      line = line.trim();
      if (line.isEmpty || line.startsWith('#')) continue;
      int eqPos = line.indexOf('=');
      if (eqPos == -1) {
        throw new ArgumentError(
          "Unsupported marker option '$line' in ${file.uri}",
        );
      }
      String marker = line.substring(0, eqPos);
      String tester = line.substring(eqPos + 1);
      File testerFile = new File.fromUri(_fileUriFromSdkRoot(tester));
      if (!testerFile.existsSync()) {
        throw new ArgumentError(
          "Tester '$tester' does not exist for marker '$marker' in "
          "${file.uri}",
        );
      }
      if (markers.containsKey(marker)) {
        throw new ArgumentError("Duplicate marker '$marker' in ${file.uri}");
      }
      markers[marker] = new MarkerTester(marker, tester, testerFile.uri);
      if (testerFile.absolute.uri == script.absolute.uri) {
        isScriptFound = true;
      }
    }
    if (shouldFindScript && !isScriptFound) {
      throw new ArgumentError(
        "Script '${script.uri}' not found in ${file.uri}",
      );
    }
    return new MarkerOptions.internal(markers);
  }

  Iterable<String> get supportedMarkers => markers.keys;

  Future<void> runAll(List<String> args) async {
    bool assertsEnabled = true;
    try {
      assert(false);
      assertsEnabled = false;
    } catch (_) {}
    Set<MarkerTester> testers = markers.values.toSet();
    int errorsCount = 0;
    for (MarkerTester tester in testers) {
      print('================================================================');
      print('Running tester: ${tester.path} ${args.join(' ')}');
      print('================================================================');
      Process process = await Process.start(Platform.resolvedExecutable, [
        if (assertsEnabled) '--enable_asserts',
        tester.uri.toString(),
        ...args,
      ], mode: ProcessStartMode.inheritStdio);
      if (await process.exitCode != 0) {
        errorsCount++;
      }
    }
    if (errorsCount != 0) {
      throw "Error${errorsCount == 1 ? "" : "s"} occurred.";
    }
  }
}

String getTestName(FileSystemEntity entity) {
  if (entity is Directory) {
    return entity.uri.pathSegments[entity.uri.pathSegments.length - 2];
  } else {
    return entity.uri.pathSegments.last;
  }
}

// TODO(johnniwinther): Support --show to show actual data for an input.
class Options {
  static const Option<bool> runAll = const Option<bool>(
    '--run-all',
    const BoolValue(false),
  );
  static const Option<bool> verbose = const Option<bool>(
    '--verbose',
    const BoolValue(false),
    aliases: ['-v'],
  );
  static const Option<bool> succinct = const Option<bool>(
    '--succinct',
    const BoolValue(false),
    aliases: ['-s'],
  );
  static const Option<bool> shouldContinue = const Option<bool>(
    '--continue',
    const BoolValue(false),
    aliases: ['-c'],
  );
  static const Option<bool> stopAfterFailures = const Option<bool>(
    '--stop-after-failures',
    const BoolValue(false),
    aliases: ['-a'],
  );
  static const Option<bool> printCode = const Option<bool>(
    '--print-code',
    const BoolValue(false),
    aliases: ['-p'],
  );
  static const Option<bool> generateAnnotations = const Option<bool>(
    '--generate',
    const BoolValue(false),
    aliases: ['-g'],
  );
  static const Option<bool> forceUpdate = const Option<bool>(
    '--force-update',
    const BoolValue(false),
    aliases: ['-f'],
  );
}

const List<Option> idTestOptions = [
  Options.runAll,
  Options.verbose,
  Options.succinct,
  Options.shouldContinue,
  Options.stopAfterFailures,
  Options.printCode,
  Options.generateAnnotations,
  Options.forceUpdate,
];

String _noProcessing(String s) => s;

/// Check code for all tests in [dataDir] using [runTest].
Future<void> runTests<T>(
  Directory dataDir, {
  List<String> args = const <String>[],
  int shards = 1,
  int shardIndex = 0,
  void onTest(Uri uri)?,
  required Uri createUriForFileName(String fileName),
  required void onFailure(String message),
  required RunTestFunction<T> runTest,
  List<String>? skipList,
  Map<String, List<String>>? skipMap,
  bool preserveWhitespaceInAnnotations = false,
  bool preserveInfixWhitespaceInAnnotations = false,
  String Function(String) preProcessFile = _noProcessing,
  String Function(String) postProcessFile = _noProcessing,
}) async {
  ParsedOptions parsedOptions = ParsedOptions.parse(args, idTestOptions);
  MarkerOptions markerOptions = new MarkerOptions.fromDataDir(
    dataDir,
    shouldFindScript: shards == 1,
  );
  if (Options.runAll.read(parsedOptions)) {
    await markerOptions.runAll(Options.runAll.remove(args.toList()));
    return;
  }
  bool verbose = Options.verbose.read(parsedOptions);
  bool succinct = Options.succinct.read(parsedOptions);
  bool shouldContinue = Options.shouldContinue.read(parsedOptions);
  bool stopAfterFailures = Options.stopAfterFailures.read(parsedOptions);
  bool printCode = Options.printCode.read(parsedOptions);
  bool generateAnnotations = Options.generateAnnotations.read(parsedOptions);
  bool forceUpdate = Options.forceUpdate.read(parsedOptions);
  List<String> arguments = parsedOptions.arguments;

  bool continued = false;

  Map<String, Set<String>> mismatches = {};
  Map<String, Set<String>> errors = {};

  String relativeDir = dataDir.uri.path.replaceAll(Uri.base.path, '');
  print('Data dir: ${relativeDir}');
  List<FileSystemEntity> entities = dataDir
      .listSync()
      .where(
        (entity) =>
            !entity.path.endsWith('~') &&
            !entity.path.endsWith('marker.options') &&
            !entity.path.endsWith('.expect'),
      )
      .toList();
  if (shards > 1) {
    entities.sort((a, b) => getTestName(a).compareTo(getTestName(b)));
    int start = entities.length * shardIndex ~/ shards;
    int end = entities.length * (shardIndex + 1) ~/ shards;
    entities = entities.sublist(start, end);
  }
  int testCount = 0;
  for (FileSystemEntity entity in entities) {
    String testName = getTestName(entity);
    if (arguments.isNotEmpty && !arguments.contains(testName) && !continued) {
      continue;
    }
    if (shouldContinue) continued = true;
    testCount++;

    if (skipList != null &&
        skipList.contains(testName) &&
        !arguments.contains(testName)) {
      print('Skip: ${testName}');
      continue;
    }
    if (onTest != null) {
      onTest(entity.uri);
    }
    print('----------------------------------------------------------------');

    Map<Uri, Uri> testToFileUri = {};

    Uri createTestUri(Uri fileUri, String fileName) {
      Uri testUri = createUriForFileName(fileName);
      testToFileUri[testUri] = fileUri;
      return testUri;
    }

    TestData testData = computeTestData(
      entity,
      supportedMarkers: markerOptions.supportedMarkers,
      createTestUri: createTestUri,
      onFailure: onFailure,
      preserveWhitespaceInAnnotations: preserveWhitespaceInAnnotations,
      preserveInfixWhitespaceInAnnotations:
          preserveInfixWhitespaceInAnnotations,
      preprocessFile: preProcessFile,
    );
    print('Test: ${testData.testFileUri}');

    Map<String, TestResult<T>> results = await runTest(
      markerOptions,
      testData,
      testAfterFailures: !stopAfterFailures || generateAnnotations,
      verbose: verbose,
      succinct: succinct,
      printCode: printCode,
      skipMap: skipMap,
      nullUri: createTestUri(entity.uri.resolve("null"), "null"),
    );

    Set<String> mismatchMarkers = {};
    Set<String> errorMarkers = {};
    results.forEach((String marker, TestResult<T> result) {
      if (result.hasMismatches) {
        mismatchMarkers.add(marker);
      } else if (result.isErroneous) {
        errorMarkers.add(marker);
      }
    });
    if (errorMarkers.isNotEmpty) {
      // Cannot generate annotations for erroneous tests.
      if (mismatchMarkers.isNotEmpty) {
        mismatches[testName] = mismatchMarkers;
      }
      errors[testName] = errorMarkers;
    } else if (mismatchMarkers.isNotEmpty ||
        (forceUpdate && generateAnnotations)) {
      if (generateAnnotations) {
        DataInterpreter? dataInterpreter;
        Map<String, Map<Uri, Map<Id, ActualData<T>>>> actualData = {};
        results.forEach((String marker, TestResult<T> result) {
          dataInterpreter ??= result.interpreter;
          Map<Uri, Map<Id, ActualData<T>>> actualDataPerUri =
              actualData[marker] = {};

          void addActualData(Uri uri, Map<Id, ActualData<T>> actualData) {
            assert(
              testData.code.containsKey(uri) || actualData.isEmpty,
              "Unexpected data ${actualData} for $uri",
            );
            if (actualData.isEmpty) {
              // TODO(johnniwinther): Avoid collecting data without
              //  invalid uris.
              return;
            }
            Map<Id, ActualData<T>> actualDataPerId = actualDataPerUri[uri] ??=
                {};
            actualDataPerId.addAll(actualData);
          }

          result.compiledData!.actualMaps.forEach(addActualData);
          addActualData(
            result.compiledData!.mainUri,
            result.compiledData!.globalData,
          );
        });

        Map<Uri, List<Annotation>> annotations = computeAnnotationsPerUri(
          testData.code,
          testData.expectedMaps,
          testData.entryPoint,
          actualData,
          dataInterpreter!,
          forceUpdate: forceUpdate,
        );
        annotations.forEach((Uri uri, List<Annotation> annotations) {
          AnnotatedCode? code = testData.code[uri];
          assert(
            code != null,
            "No annotated code for $uri with annotations: $annotations",
          );
          AnnotatedCode generated = new AnnotatedCode(
            code?.annotatedCode ?? "",
            code?.sourceCode ?? "",
            annotations,
          );
          Uri fileUri = testToFileUri[uri]!;
          new File.fromUri(
            fileUri,
          ).writeAsStringSync(postProcessFile(generated.toText()));
          print('Generated annotations for ${fileUri}');
        });
      } else {
        mismatches[testName] = mismatchMarkers;
      }
    }
  }
  if (mismatches.isNotEmpty || errors.isNotEmpty) {
    onFailure(
      generateErrorMessage(
        markerOptions,
        mismatches: mismatches,
        errors: errors,
      ),
    );
  }
  if (testCount == 0) {
    onFailure("No files were tested.");
  }
}

const String _dartLocationMessage = """
* Note that the 'dart' executable *must* be the one
  located in the 'dart-sdk/bin' folder of the build
  directory, for instance

    out/ReleaseX64/dart-sdk/bin/dart""";

/// Generates an error message for the [mismatches] and [errors] using
/// [markerOptions] to provide a re-run/re-generation command.
///
/// [mismatches] and [errors] are maps from the test names of the failing tests
/// to the markers for the configurations in which they failed.
String generateErrorMessage(
  MarkerOptions markerOptions, {
  Map<String, Set<String>> mismatches = const {},
  Map<String, Set<String>> errors = const {},
}) {
  Set<String> testNames = {...mismatches.keys, ...errors.keys};
  StringBuffer message = new StringBuffer();
  message.writeln();
  message.writeln('==========================================================');
  message.writeln();
  if (testNames.length > 1) {
    message.writeln("Errors found in tests:");
    message.writeln();
    for (String testName in testNames.toList()..sort()) {
      message.writeln("  ${testName}");
    }
  } else {
    message.writeln("Errors found in test '${testNames.single}'");
  }
  if (mismatches.isNotEmpty) {
    Map<MarkerTester, Set<String>> regenerations = {};
    mismatches.forEach((String testName, Set<String> markers) {
      for (String marker in markers) {
        MarkerTester tester = markerOptions.markers[marker]!;
        (regenerations[tester] ??= {}).add(testName);
      }
    });
    message.writeln();
    if (regenerations.length > 1) {
      message.writeln('Run these commands to generate new expectations:');
    } else {
      message.writeln('Run this command to generate new expectations:');
    }
    message.writeln();
    regenerations.forEach((MarkerTester tester, Set<String> testNames) {
      message.write('  dart ${tester.path} -g');
      for (String testName in testNames) {
        message.write(' ${testName}');
      }
      message.writeln();
    });
    message.writeln();
    message.writeln(_dartLocationMessage);
  }
  if (errors.isNotEmpty) {
    Map<MarkerTester, Set<String>> erroneous = {};
    errors.forEach((String testName, Set<String> markers) {
      for (String marker in markers) {
        MarkerTester tester = markerOptions.markers[marker]!;
        (erroneous[tester] ??= {}).add(testName);
      }
    });
    message.writeln();
    if (erroneous.length > 1) {
      message.writeln('Run these commands to re-run the failing tests:');
    } else {
      message.writeln('Run this command to re-run the failing test(s):');
    }
    message.writeln();
    erroneous.forEach((MarkerTester tester, Set<String> testNames) {
      message.write('  dart ${tester.path}');
      for (String testName in testNames) {
        message.write(' ${testName}');
      }
      message.writeln();
    });
    message.writeln(_dartLocationMessage);
  }
  message.writeln();
  message.writeln('==========================================================');
  return message.toString();
}

/// Returns `true` if [testName] is marked as skipped in [skipMap] for
/// the given [configMarker].
bool skipForConfig(
  String testName,
  String configMarker,
  Map<String, List<String>>? skipMap,
) {
  if (skipMap != null) {
    List<String>? skipList = skipMap[configMarker];
    if (skipList != null && skipList.contains(testName)) {
      print("Skip: ${testName} for config '${configMarker}'");
      return true;
    }
    skipList = skipMap[null];
    if (skipList != null && skipList.contains(testName)) {
      print("Skip: ${testName} for config '${configMarker}'");
      return true;
    }
  }
  return false;
}

/// Updates all id tests in [relativeTestPaths].
///
/// This assumes that the current working directory is the repository root.
Future<void> updateAllTests(List<String> relativeTestPaths) async {
  for (String testPath in relativeTestPaths) {
    List<String> arguments = [];
    if (Platform.packageConfig != null) {
      arguments.add('--packages=${Platform.packageConfig}');
    }
    arguments.addAll([testPath, '-g', '--run-all']);
    print('Running: ${Platform.resolvedExecutable} ${arguments.join(' ')}');
    Process process = await Process.start(
      Platform.resolvedExecutable,
      arguments,
      mode: ProcessStartMode.inheritStdio,
    );
    await process.exitCode;
  }
}
