// Copyright (c) 2014, 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:collection';

import 'package:analyzer_plugin/protocol/protocol_common.dart';
import 'package:test/test.dart';

import 'domain_completion_util.dart';

/// A base class for classes containing completion tests.
abstract class CompletionTestCase extends AbstractCompletionDomainTest {
  static const String _cursorMarker = '!';

  List<String> get suggestedCompletions => suggestions
      .map((CompletionSuggestion suggestion) => suggestion.completion)
      .toList();

  void assertHasCompletion(
    String completion, {
    ElementKind? elementKind,
    bool? isDeprecated,
    Matcher? libraryUri,
  }) {
    var expectedOffset = completion.indexOf(_cursorMarker);
    if (expectedOffset >= 0) {
      if (completion.contains(_cursorMarker, expectedOffset + 1)) {
        fail(
          "Invalid completion, contains multiple cursor positions: '$completion'",
        );
      }
      completion = completion.replaceFirst(_cursorMarker, '');
    } else {
      expectedOffset = completion.length;
    }
    CompletionSuggestion? matchingSuggestion;
    for (var suggestion in suggestions) {
      if (suggestion.completion == completion) {
        if (matchingSuggestion == null) {
          matchingSuggestion = suggestion;
        } else {
          // It is OK to have a class and its default constructor suggestions.
          if (matchingSuggestion.element?.kind == ElementKind.CLASS &&
                  suggestion.element?.kind == ElementKind.CONSTRUCTOR ||
              matchingSuggestion.element?.kind == ElementKind.CONSTRUCTOR &&
                  suggestion.element?.kind == ElementKind.CLASS) {
            return;
          }
          fail(
            "Expected exactly one '$completion' but found multiple:\n  $suggestedCompletions",
          );
        }
      }
    }
    if (matchingSuggestion == null) {
      fail("Expected '$completion' but found none:\n  $suggestedCompletions");
    }
    expect(matchingSuggestion.selectionOffset, equals(expectedOffset));
    expect(matchingSuggestion.selectionLength, equals(0));
    if (elementKind != null) {
      expect(matchingSuggestion.element!.kind, elementKind);
    }
    if (isDeprecated != null) {
      expect(matchingSuggestion.isDeprecated, isDeprecated);
    }
    if (libraryUri != null) {
      expect(matchingSuggestion.libraryUri, libraryUri);
    }
  }

  void assertHasNoCompletion(String completion) {
    if (suggestions.any(
      (CompletionSuggestion suggestion) => suggestion.completion == completion,
    )) {
      fail(
        "Did not expect completion '$completion' but found:\n  $suggestedCompletions",
      );
    }
  }

  /// Discard any results that do not start with the characters the user has
  /// "already typed".
  void filterResults(String content) {
    var charsAlreadyTyped = content
        .substring(replacementOffset!, completionOffset)
        .toLowerCase();
    suggestions = suggestions
        .where(
          (CompletionSuggestion suggestion) =>
              suggestion.completion.toLowerCase().startsWith(charsAlreadyTyped),
        )
        .toList();
  }

  Future<void> runTest(
    LocationSpec spec, [
    Map<String, String>? extraFiles,
  ]) async {
    await super.setUp();

    try {
      extraFiles?.forEach((String fileName, String content) {
        newFile(fileName, content);
      });

      newFile(testFile.path, spec.source);
      completionOffset = spec.testLocation;
      await getSuggestions(
        path: testFile.path,
        completionOffset: completionOffset,
        maxResults: 1 << 10,
      );

      filterResults(spec.source);
      for (var result in spec.positiveResults) {
        assertHasCompletion(result);
      }
      for (var result in spec.negativeResults) {
        assertHasNoCompletion(result);
      }
    } finally {
      await super.tearDown();
    }
  }
}

/// A specification of the completion results expected at a given location.
class LocationSpec {
  String id;
  int testLocation = -1;
  List<String> positiveResults = <String>[];
  List<String> negativeResults = <String>[];
  late String source;

  new(this.id);

  /// Parse a set of tests from the given `originalSource`. Return a list of the
  /// specifications that were parsed.
  ///
  /// The source string has test locations embedded in it, which are identified
  /// by '!X' where X is a single character. Each X is matched to positive or
  /// negative results in the array of [validationStrings]. Validation strings
  /// contain the name of a prediction with a two character prefix. The first
  /// character of the prefix corresponds to an X in the [originalSource]. The
  /// second character is either a '+' or a '-' indicating whether the string is
  /// a positive or negative result. If logical not is needed in the source it
  /// can be represented by '!!'.
  ///
  /// The [originalSource] is the source for a test that contains test
  /// locations. The [validationStrings] are the positive and negative
  /// predictions.
  static List<LocationSpec> from(
    String originalSource,
    List<String> validationStrings,
  ) {
    Map<String, LocationSpec> tests = HashMap<String, LocationSpec>();
    var modifiedSource = originalSource;
    var modifiedPosition = 0;
    while (true) {
      var index = modifiedSource.indexOf('!', modifiedPosition);
      if (index < 0) {
        break;
      }
      var n = 1; // only delete one char for double-bangs
      var id = modifiedSource.substring(index + 1, index + 2);
      if (id != '!') {
        n = 2;
        var test = LocationSpec(id);
        tests[id] = test;
        test.testLocation = index;
      } else {
        modifiedPosition = index + 1;
      }
      modifiedSource =
          modifiedSource.substring(0, index) +
          modifiedSource.substring(index + n);
    }
    if (modifiedSource == originalSource) {
      throw StateError('No tests in source: $originalSource');
    }
    for (var result in validationStrings) {
      if (result.length < 3) {
        throw StateError('Invalid location result: $result');
      }
      var id = result.substring(0, 1);
      var sign = result.substring(1, 2);
      var value = result.substring(2);
      var test = tests[id];
      if (test == null) {
        throw StateError('Invalid location result id: $id for: $result');
      }
      test.source = modifiedSource;
      if (sign == '+') {
        test.positiveResults.add(value);
      } else if (sign == '-') {
        test.negativeResults.add(value);
      } else {
        var err = 'Invalid location result sign: $sign for: $result';
        throw StateError(err);
      }
    }
    var badPoints = <String>[];
    var badResults = <String>[];
    for (var test in tests.values) {
      if (test.testLocation == -1) {
        badPoints.add(test.id);
      }
      if (test.positiveResults.isEmpty && test.negativeResults.isEmpty) {
        badResults.add(test.id);
      }
    }
    if (!(badPoints.isEmpty && badResults.isEmpty)) {
      var err = StringBuffer();
      if (badPoints.isNotEmpty) {
        err.write('No test location for tests:');
        for (var ch in badPoints) {
          err
            ..write(' ')
            ..write(ch);
        }
        err.write(' ');
      }
      if (badResults.isNotEmpty) {
        err.write('No results for tests:');
        for (var ch in badResults) {
          err
            ..write(' ')
            ..write(ch);
        }
      }
      throw StateError(err.toString());
    }
    return tests.values.toList();
  }
}
