// Copyright (c) 2020, 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.

/// These classes were copied from `analyzer`. They should be moved into the
/// `analyzer/lib/src/test_utilities` directory so that they can be shared.
/// (This version has been converted to a more modern style.)
library;

import 'package:analyzer/diagnostic/diagnostic.dart';
import 'package:analyzer/error/error.dart';
import 'package:analyzer/error/listener.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer/source/source.dart';
import 'package:analyzer_testing/utilities/extensions/diagnostic_code.dart';
import 'package:test/test.dart';

/// A description of a message that is expected to be reported with an error.
class ExpectedContextMessage {
  /// The path of the file with which the message is associated.
  final String filePath;

  /// The offset of the beginning of the error's region.
  final int offset;

  /// The offset of the beginning of the error's region.
  final int length;

  /// The message text for the error.
  final String? text;

  new(this.filePath, this.offset, this.length, {this.text});

  /// Return `true` if the [message] matches this description of what the state
  /// of the [message] is expected to be.
  bool matches(DiagnosticMessage message) {
    return message.filePath == filePath &&
        message.offset == offset &&
        message.length == length &&
        (text == null || message.messageText(includeUrl: true) == text);
  }
}

/// A description of an error that is expected to be reported.
class ExpectedError {
  /// An empty array of error descriptors used when no errors are expected.
  static List<ExpectedError> NO_ERRORS = <ExpectedError>[];

  /// The error code associated with the error.
  final DiagnosticCode code;

  /// The offset of the beginning of the error's region.
  final int offset;

  /// The offset of the beginning of the error's region.
  final int length;

  /// The message text of the error or `null` if the message should not be
  /// checked.
  final String? message;

  /// A pattern that should be contained in the error message or `null` if the
  /// message contents should not be checked.
  final Pattern? messageContains;

  /// The list of context messages that are expected to be associated with the
  /// error.
  final List<ExpectedContextMessage> expectedContextMessages;

  /// Initialize a newly created error description.
  new(
    this.code,
    this.offset,
    this.length, {
    this.message,
    this.messageContains,
    this.expectedContextMessages = const <ExpectedContextMessage>[],
  });

  /// Return `true` if the [diagnostic] matches this description of what the state
  /// of the [diagnostic] is expected to be.
  bool matches(Diagnostic diagnostic) {
    if (diagnostic.offset != offset ||
        diagnostic.length != length ||
        diagnostic.diagnosticCode != code) {
      return false;
    }
    if (message != null && diagnostic.message != message) {
      return false;
    }
    var messageContains = this.messageContains;
    if (messageContains != null &&
        !diagnostic.message.contains(messageContains)) {
      return false;
    }
    var contextMessages = diagnostic.contextMessages.toList();
    contextMessages.sort((first, second) {
      var result = first.filePath.compareTo(second.filePath);
      if (result != 0) {
        return result;
      }
      return second.offset - first.offset;
    });
    if (contextMessages.length != expectedContextMessages.length) {
      return false;
    }
    for (var i = 0; i < expectedContextMessages.length; i++) {
      if (!expectedContextMessages[i].matches(contextMessages[i])) {
        return false;
      }
    }
    return true;
  }
}

/// A diagnostic listener that collects all of the diagnostics passed to it for
/// later examination.
class GatheringDiagnosticListener implements DiagnosticListener {
  /// A flag indicating whether diagnostic ranges are to be compared when
  /// comparing expected and actual diagnostic.
  final bool checkRanges;

  /// A list containing the diagnostics that were collected.
  final List<Diagnostic> _diagnostics = <Diagnostic>[];

  /// A table mapping sources to the line information for the source.
  final Map<Source, LineInfo> _lineInfoMap = <Source, LineInfo>{};

  /// Initialize a newly created diagnostic listener to collect diagnostics.
  new({this.checkRanges = true});

  /// The diagnostics that were collected.
  List<Diagnostic> get diagnostics => _diagnostics;

  /// Whether at least one diagnostic has been gathered.
  bool get hasDiagnostics => _diagnostics.isNotEmpty;

  /// Adds the given [diagnostics] to this listener.
  void addAll(List<Diagnostic> diagnostics) {
    for (var diagnostic in diagnostics) {
      onDiagnostic(diagnostic);
    }
  }

  /// Adds all of the diagnostics recorded by the given [listener] to this
  /// listener.
  void addAll2(RecordingDiagnosticListener listener) {
    addAll(listener.diagnostics);
  }

  /// Assert that the number of errors that have been gathered matches the
  /// number of [expectedErrors] and that they have the expected error codes and
  /// locations. The order in which the errors were gathered is ignored.
  void assertErrors(List<ExpectedError> expectedErrors) {
    //
    // Match actual errors to expected errors.
    //
    var unmatchedActual = diagnostics.toList();
    var unmatchedExpected = expectedErrors.toList();
    var actualIndex = 0;
    while (actualIndex < unmatchedActual.length) {
      var matchFound = false;
      var expectedIndex = 0;
      while (expectedIndex < unmatchedExpected.length) {
        if (unmatchedExpected[expectedIndex].matches(
          unmatchedActual[actualIndex],
        )) {
          matchFound = true;
          unmatchedActual.removeAt(actualIndex);
          unmatchedExpected.removeAt(expectedIndex);
          break;
        }
        expectedIndex++;
      }
      if (!matchFound) {
        actualIndex++;
      }
    }
    //
    // Write the results.
    //
    var buffer = StringBuffer();
    if (unmatchedExpected.isNotEmpty) {
      buffer.writeln('Expected but did not find:');
      for (var expected in unmatchedExpected) {
        buffer.write('  ');
        buffer.write(expected.code);
        buffer.write(' [');
        buffer.write(expected.offset);
        buffer.write(', ');
        buffer.write(expected.length);
        if (expected.message != null) {
          buffer.write(', ');
          buffer.write(expected.message);
        }
        buffer.writeln(']');
      }
    }
    if (unmatchedActual.isNotEmpty) {
      if (buffer.isNotEmpty) {
        buffer.writeln();
      }
      buffer.writeln('Found but did not expect:');
      for (var actual in unmatchedActual) {
        buffer.write('  ');
        buffer.write(actual.diagnosticCode);
        buffer.write(' [');
        buffer.write(actual.offset);
        buffer.write(', ');
        buffer.write(actual.length);
        buffer.write(', ');
        buffer.write(actual.message);
        buffer.writeln(']');
      }
    }
    if (buffer.isNotEmpty) {
      diagnostics.sort(
        (first, second) => first.offset.compareTo(second.offset),
      );
      buffer.writeln();
      buffer.writeln('To accept the current state, expect:');
      for (var actual in diagnostics) {
        var contextMessages = actual.contextMessages;
        buffer.write('  error(');
        buffer.write(actual.diagnosticCode.constantName);
        buffer.write(', ');
        buffer.write(actual.offset);
        buffer.write(', ');
        buffer.write(actual.length);
        if (contextMessages.isNotEmpty) {
          buffer.write(', contextMessages: [');
          for (var i = 0; i < contextMessages.length; i++) {
            var message = contextMessages[i];
            if (i > 0) {
              buffer.write(', ');
            }
            buffer.write('message(\'');
            buffer.write(message.filePath);
            buffer.write('\', ');
            buffer.write(message.offset);
            buffer.write(', ');
            buffer.write(message.length);
            buffer.write(')');
          }
          buffer.write(']');
        }
        buffer.writeln('),');
      }
      fail(buffer.toString());
    }
  }

  /// Assert that the number of errors that have been gathered matches the
  /// number of [expectedCodes] and that they have the expected error
  /// codes. The order in which the errors were gathered is ignored.
  void assertErrorsWithCodes([
    List<DiagnosticCode> expectedCodes = const <DiagnosticCode>[],
  ]) {
    var buffer = StringBuffer();
    //
    // Compute the expected number of each type of error.
    //
    var expectedCounts = <DiagnosticCode, int>{};
    for (var code in expectedCodes) {
      var count = expectedCounts[code];
      if (count == null) {
        count = 1;
      } else {
        count = count + 1;
      }
      expectedCounts[code] = count;
    }

    // Compute the actual number of each type of diagnostic.
    var diagnosticsByCode = <DiagnosticCode, List<Diagnostic>>{};
    for (var diagnostic in _diagnostics) {
      diagnosticsByCode
          .putIfAbsent(diagnostic.diagnosticCode, () => <Diagnostic>[])
          .add(diagnostic);
    }

    // Compare the expected and actual number of each type of diagnostic.
    expectedCounts.forEach((DiagnosticCode code, int expectedCount) {
      int actualCount;
      var list = diagnosticsByCode.remove(code);
      if (list == null) {
        actualCount = 0;
      } else {
        actualCount = list.length;
      }
      if (actualCount != expectedCount) {
        if (buffer.length == 0) {
          buffer.write('Expected ');
        } else {
          buffer.write('; ');
        }
        buffer.write(expectedCount);
        buffer.write(' errors of type ');
        buffer.write(code.lowerCaseName);
        buffer.write(', found ');
        buffer.write(actualCount);
      }
    });
    //
    // Check that there are no more errors in the actual-errors map,
    // otherwise record message.
    //
    diagnosticsByCode.forEach((
      DiagnosticCode code,
      List<Diagnostic> actualDiagnostics,
    ) {
      var actualCount = actualDiagnostics.length;
      if (buffer.length == 0) {
        buffer.write('Expected ');
      } else {
        buffer.write('; ');
      }
      buffer.write('0 errors of type ');
      buffer.write(code.lowerCaseUniqueName);
      buffer.write(', found ');
      buffer.write(actualCount);
      buffer.write(' (');
      for (var i = 0; i < actualDiagnostics.length; i++) {
        var error = actualDiagnostics[i];
        if (i > 0) {
          buffer.write(', ');
        }
        buffer.write(error.offset);
      }
      buffer.write(')');
    });
    if (buffer.length > 0) {
      fail(buffer.toString());
    }
  }

  /// Assert that the number of errors that have been gathered matches the
  /// number of [expectedSeverities] and that there are the same number of
  /// errors and warnings as specified by the argument. The order in which the
  /// errors were gathered is ignored.
  void assertErrorsWithSeverities(List<DiagnosticSeverity> expectedSeverities) {
    var expectedErrorCount = 0;
    var expectedWarningCount = 0;
    for (var severity in expectedSeverities) {
      if (severity == DiagnosticSeverity.ERROR) {
        expectedErrorCount++;
      } else {
        expectedWarningCount++;
      }
    }
    var actualErrorCount = 0;
    var actualWarningCount = 0;
    for (var diagnostic in _diagnostics) {
      if (diagnostic.diagnosticCode.severity == DiagnosticSeverity.ERROR) {
        actualErrorCount++;
      } else {
        actualWarningCount++;
      }
    }
    if (expectedErrorCount != actualErrorCount ||
        expectedWarningCount != actualWarningCount) {
      fail(
        'Expected $expectedErrorCount errors '
        'and $expectedWarningCount warnings, '
        'found $actualErrorCount errors '
        'and $actualWarningCount warnings',
      );
    }
  }

  /// Assert that no errors have been gathered.
  void assertNoErrors() {
    assertErrors(ExpectedError.NO_ERRORS);
  }

  /// Return the line information associated with the given [source], or `null`
  /// if no line information has been associated with the source.
  LineInfo? getLineInfo(Source source) => _lineInfoMap[source];

  /// Returns whether a diagnostic with the given [diagnosticCode] has been
  /// gathered.
  bool hasDiagnostic(DiagnosticCode diagnosticCode) {
    for (var diagnostic in _diagnostics) {
      if (identical(diagnostic.diagnosticCode, diagnosticCode)) {
        return true;
      }
    }
    return false;
  }

  @override
  void onDiagnostic(Diagnostic diagnostic) {
    _diagnostics.add(diagnostic);
  }

  /// Set the line information associated with the given [source] to the given
  /// list of [lineStarts].
  void setLineInfo(Source source, List<int> lineStarts) {
    _lineInfoMap[source] = LineInfo(lineStarts);
  }
}
