// 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.

import 'package:analyzer/src/test_utilities/platform.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart';
import 'package:meta/meta.dart';
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';

import '../analysis_server_base.dart';
import '../services/completion/dart/completion_check.dart';
import '../services/completion/dart/completion_printer.dart' as printer;
import '../services/completion/dart/text_expectations.dart';
import 'impl/completion_driver.dart';

void main() {
  defineReflectiveSuite(() {
    defineReflectiveTests(BasicCompletionTest);
    defineReflectiveTests(CompletionWithSuggestionsTest);
  });
}

typedef SuggestionMatcher = bool Function(CompletionSuggestion suggestion);

abstract class AbstractCompletionDriverTest
    extends PubPackageAnalysisServerTest {
  late CompletionDriver driver;

  late List<CompletionSuggestion> suggestions;

  late CompletionResponseForTesting response;

  /// The configuration used by [assertResponse] to limit the number of
  /// suggestions that will be compared by a test. The reasons for the filter
  /// are
  /// - to prevent uninteresting changes to the SDK from breaking the completion
  ///   tests, and
  /// - to keep the expected response text shorter.
  ///
  /// The default filter, initialized in [setUp], prints
  /// - identifier suggestions consisting of a single letter followed by one or
  ///   more digits as per [identifierRegExp],
  /// - identifier suggestions that are in the set of [allowedIdentifiers], and
  /// - non-identifier suggestions.
  ///
  /// Tests can override this configuration to change the set of suggestions to
  /// be printed.
  late printer.Configuration printerConfiguration;

  /// A set of identifiers that will be included in the printed version of the
  /// suggestions. Individual tests can replace the default set.
  Set<String> allowedIdentifiers = const {};

  /// The regular expression used to validate identifier names on the expected
  /// completion list.
  ///
  /// If the expected name(s) is(are) invalid, fill [allowedIdentifiers] with
  /// the expected name(s).
  RegExp identifierRegExp = RegExp(r'^_?[a-zA-Z][0-9]+$');

  /// A set of completion kinds that should be included in the printed version
  /// of the suggestions. Individual tests can replace the default set.
  Set<CompletionSuggestionKind> allowedKinds = {};

  /// Whether keywords should be included in the text to be compared.
  bool includeKeywords = true;

  /// Return `true` if closures (suggestions starting with a left paren) should
  /// be included in the text to be compared.
  bool includeClosures = false;

  /// Return `true` if overrides should be included in the text to be compared.
  bool get includeOverrides => true;

  @override
  @protected
  Future<List<CompletionSuggestion>> addTestFile(String content) async {
    content = normalizeNewlinesForPlatform(content);
    driver.addTestFile(content);

    // Wait after adding the test file, this might affect diagnostics.
    await pumpEventQueue(times: 1000);

    // For sanity, ensure that there are no errors recorded for project files
    // since that may lead to unexpected results.
    _assertNoErrorsInProjectFiles();

    await getSuggestions();
    return suggestions;
  }

  void assertNoSuggestion({
    required String completion,
    ElementKind? element,
    CompletionSuggestionKind? kind,
    String? file,
  }) {
    expect(
      suggestionsWith(
        completion: completion,
        element: element,
        kind: kind,
        file: file,
      ),
      isEmpty,
    );
  }

  /// Asserts that the [response] has the [expected] textual dump produced
  /// using [printerConfiguration].
  ///
  /// If the expected response contains an identifier/invocation where
  /// the name(s) is(are) invalid to [identifierRegExp], add the expected name
  /// to [allowedIdentifiers].
  void assertResponse(String expected, {String where = ''}) {
    // The generated response will always be using the EOLs being tested so
    // normalize the expected test to the same to ensure that all actual output
    // uses the expected line endings.
    expected = normalizeNewlinesForPlatform(expected);

    var buffer = StringBuffer();
    printer.CompletionResponsePrinter(
      buffer: buffer,
      configuration: printerConfiguration,
      response: response,
    ).writeResponse();
    var actual = buffer.toString();

    if (actual != expected) {
      if (where.isEmpty) {
        var target =
            driver.server.server.completionState.currentRequest?.target;
        if (target != null) {
          var containingNode = target.containingNode.runtimeType;
          var entity = target.entity;
          where = ' (containingNode = $containingNode, entity = $entity)';
        }
      }

      // The source code in our test files will always be written with only `\n`
      // (due to git settings) so strip any `\r` that might be in the actual
      // result for any output intended to go into source files.
      var actualForSourceCode = actual.replaceAll('\r', '');

      TextExpectationsCollector.add(actualForSourceCode);
      fail('''
The actual suggestions do not match the expected suggestions$where.

To accept the current state change the expectation to
\r${'-' * 64}
\r${actualForSourceCode.trimRight().split('\n').join('\n\r')}
\r${'-' * 64}
''');
    }
  }

  void assertSuggestion({
    required String completion,
    ElementKind? element,
    CompletionSuggestionKind? kind,
    String? file,
    String? libraryUri,
  }) {
    expect(
      suggestionWith(
        completion: completion,
        element: element,
        kind: kind,
        file: file,
        libraryUri: libraryUri,
      ),
      isNotNull,
    );
  }

  Future<void> computeSuggestions(String content) async {
    // Give the server time to create analysis contexts.
    await pumpEventQueue(times: 1000);

    await addTestFile(content);

    // Extract the internal request object.
    var dartRequest = server.completionState.currentRequest;

    response = CompletionResponseForTesting(
      requestOffset: driver.completionOffset,
      requestLocationName: dartRequest?.collectorLocationName,
      opTypeLocationName: dartRequest?.opType.completionLocation,
      replacementOffset: driver.replacementOffset,
      replacementLength: driver.replacementLength,
      isIncomplete: false, // TODO(scheglov): not correct
      suggestions: suggestions,
    );
  }

  Future<List<CompletionSuggestion>> getSuggestions() async {
    suggestions = await driver.getSuggestions();
    return suggestions;
  }

  @override
  Future<void> setUp() async {
    super.setUp();

    writeTestPackagePubspecYamlFile(r'''
name: test
''');

    driver = CompletionDriver(server: this);
    await driver.createProject();

    // TODO(pq): add logic (possibly to driver) that waits for SDK suggestions

    printerConfiguration = printer.Configuration(
      filter: (suggestion) {
        var kind = suggestion.kind;
        if (kind == CompletionSuggestionKind.IDENTIFIER ||
            kind == CompletionSuggestionKind.INVOCATION) {
          var completion = suggestion.completion;
          if (includeClosures && completion.startsWith('(')) {
            return true;
          }
          var periodIndex = completion.indexOf('.');
          if (periodIndex > 0) {
            completion = completion.substring(0, periodIndex);
          }
          return identifierRegExp.hasMatch(completion) ||
              allowedIdentifiers.contains(completion);
        } else if (kind == CompletionSuggestionKind.KEYWORD) {
          return includeKeywords;
        } else if (kind == CompletionSuggestionKind.OVERRIDE) {
          return includeOverrides;
        } else if (allowedKinds.contains(kind)) {
          return true;
        }
        return true;
      },
    );
  }

  SuggestionMatcher suggestionHas({
    required String completion,
    ElementKind? element,
    CompletionSuggestionKind? kind,
    String? file,
    String? libraryUri,
  }) => (CompletionSuggestion s) {
    if (s.completion != completion) {
      return false;
    }
    if (element != null && s.element?.kind != element) {
      return false;
    }
    if (kind != null && s.kind != kind) {
      return false;
    }

    if (file != null && s.element?.location?.file != convertPath(file)) {
      return false;
    }
    if (libraryUri != null && s.libraryUri != libraryUri) {
      return false;
    }
    return true;
  };

  Iterable<CompletionSuggestion> suggestionsWith({
    required String completion,
    ElementKind? element,
    CompletionSuggestionKind? kind,
    String? file,
    String? libraryUri,
  }) => suggestions.where(
    suggestionHas(
      completion: completion,
      element: element,
      kind: kind,
      file: file,
      libraryUri: libraryUri,
    ),
  );

  CompletionSuggestion suggestionWith({
    required String completion,
    ElementKind? element,
    CompletionSuggestionKind? kind,
    String? file,
    String? libraryUri,
  }) {
    var matches = suggestionsWith(
      completion: completion,
      element: element,
      kind: kind,
      file: file,
      libraryUri: libraryUri,
    );
    expect(matches, hasLength(1));
    return matches.first;
  }

  void _assertNoErrorsInProjectFiles() {
    var errors = <AnalysisError>[];
    driver.filesErrors.forEach((file, fileErrors) {
      // Completion test files are likely to be incomplete and so may have
      // errors
      if (file != convertPath(testFilePath)) {
        errors.addAll(fileErrors);
      }
    });
    expect(errors, isEmpty);
  }
}

@reflectiveTest
class BasicCompletionTest extends AbstractCompletionDriverTest
    with BasicCompletionTestCases {}

mixin BasicCompletionTestCases on AbstractCompletionDriverTest {
  Future<void> test_deprecated_class_relevance() async {
    await computeSuggestions('''
class A1 {}

@Deprecated()
class A2 {}

void f() {
  var a = A^
}
''');

    expect(
      suggestionWith(
        completion: 'A2',
        element: ElementKind.CONSTRUCTOR,
        kind: CompletionSuggestionKind.INVOCATION,
      ).relevance,
      lessThan(
        suggestionWith(
          completion: 'A1',
          element: ElementKind.CONSTRUCTOR,
          kind: CompletionSuggestionKind.INVOCATION,
        ).relevance,
      ),
    );
  }

  /// Duplicates (and potentially replaces) [DeprecatedMemberRelevanceTest].
  Future<void> test_deprecated_member_relevance() async {
    await computeSuggestions('''
class A {
  void a1() { }
  @deprecated
  void a2() { }
}

void f() {
  var a = A();
  a.^
}
''');

    expect(
      suggestionWith(
        completion: 'a2',
        element: ElementKind.METHOD,
        kind: CompletionSuggestionKind.INVOCATION,
      ).relevance,
      lessThan(
        suggestionWith(
          completion: 'a1',
          element: ElementKind.METHOD,
          kind: CompletionSuggestionKind.INVOCATION,
        ).relevance,
      ),
    );
  }

  Future<void> test_deprecatedExtend_class_relevance() async {
    await computeSuggestions('''
class A1 {}

@Deprecated.extend()
class A2 {}

void f() {
  var a = A^
}
''');

    expect(
      suggestionWith(
        completion: 'A2',
        element: ElementKind.CONSTRUCTOR,
        kind: CompletionSuggestionKind.INVOCATION,
      ).relevance,
      equals(
        suggestionWith(
          completion: 'A1',
          element: ElementKind.CONSTRUCTOR,
          kind: CompletionSuggestionKind.INVOCATION,
        ).relevance,
      ),
    );
  }
}

@reflectiveTest
class CompletionWithSuggestionsTest extends AbstractCompletionDriverTest
    with CompletionWithSuggestionsTestCases {}

mixin CompletionWithSuggestionsTestCases on AbstractCompletionDriverTest {
  Future<void> test_project_filterImports_defaultConstructor() async {
    newFile('$testPackageLibPath/a.dart', r'''
class A {}
''');

    newFile('$testPackageLibPath/b.dart', r'''
export 'a.dart';
''');

    await computeSuggestions('''
import 'a.dart';
void f() {
  ^
}
''');

    assertSuggestion(
      completion: 'A',
      element: ElementKind.CONSTRUCTOR,
      kind: CompletionSuggestionKind.INVOCATION,
    );
  }

  /// See: https://github.com/dart-lang/sdk/issues/40620
  Future<void> test_project_filterImports_enumValues() async {
    newFile('$testPackageLibPath/a.dart', r'''
enum E {
  e,
}
''');

    newFile('$testPackageLibPath/b.dart', r'''
export 'a.dart';
''');

    await computeSuggestions('''
import 'a.dart';
void f() {
  E v = ^
}
''');
    assertSuggestion(completion: 'E.e', element: ElementKind.ENUM_CONSTANT);
  }

  /// See: https://github.com/dart-lang/sdk/issues/40620
  Future<void> test_project_filterImports_namedConstructors() async {
    newFile('$testPackageLibPath/a.dart', r'''
class A {
  A.a();
}
''');

    newFile('$testPackageLibPath/b.dart', r'''
export 'a.dart';
''');

    await computeSuggestions('''
import 'a.dart';
void f() {
  ^
}
''');

    assertSuggestion(
      completion: 'A.a',
      element: ElementKind.CONSTRUCTOR,
      kind: CompletionSuggestionKind.INVOCATION,
    );
  }

  Future<void> test_project_filterMultipleImports() async {
    newFile('$testPackageLibPath/a.dart', r'''
class A {}
''');

    newFile('$testPackageLibPath/b.dart', r'''
export 'a.dart';
''');

    await computeSuggestions('''
import 'a.dart';
import 'b.dart';
void f() {
  ^
}
''');

    assertSuggestion(completion: 'A', element: ElementKind.CLASS);
  }

  Future<void> test_project_lib() async {
    newFile('$testPackageLibPath/a.dart', r'''
class A {}
enum E {
  e,
}
extension Ex on A {}
mixin M { }
typedef T = Function(Object);
typedef T2 = double;
var v = 0;
''');

    await computeSuggestions('''
void f() {
  ^
}
''');

    assertSuggestion(
      completion: 'A',
      element: ElementKind.CONSTRUCTOR,
      kind: CompletionSuggestionKind.INVOCATION,
    );
    assertSuggestion(completion: 'A', element: ElementKind.CLASS);
    assertSuggestion(completion: 'E', element: ElementKind.ENUM);
    assertSuggestion(completion: 'Ex', element: ElementKind.EXTENSION);
    assertSuggestion(completion: 'M', element: ElementKind.MIXIN);
    assertSuggestion(completion: 'T', element: ElementKind.TYPE_ALIAS);
    assertSuggestion(completion: 'T2', element: ElementKind.TYPE_ALIAS);
    assertSuggestion(completion: 'v', element: ElementKind.TOP_LEVEL_VARIABLE);
  }

  Future<void> test_project_lib_fields_class() async {
    newFile('$testPackageLibPath/a.dart', r'''
class A {
  int f = 0;
}
''');

    await computeSuggestions('''
void m() {
  ^
}
''');

    assertNoSuggestion(completion: 'f');
  }

  Future<void> test_project_lib_getters_class() async {
    newFile('$testPackageLibPath/a.dart', r'''
class A {
  int get g => 0;
}
''');

    await computeSuggestions('''
void f() {
  ^
}
''');

    assertNoSuggestion(completion: 'g');
  }

  /// See: https://github.com/dart-lang/sdk/issues/40626
  Future<void> test_project_lib_getters_topLevel() async {
    newFile('$testPackageLibPath/a.dart', r'''
int get g => 0;
''');

    await computeSuggestions('''
void f() {
  ^
}
''');

    assertSuggestion(completion: 'g', element: ElementKind.GETTER);
  }

  Future<void> test_project_lib_methods_class() async {
    newFile('$testPackageLibPath/a.dart', r'''
class A {
  void foo() => 0;
}
''');

    await computeSuggestions('''
void f() {
  ^
}
''');

    assertNoSuggestion(completion: 'A.foo');
  }

  Future<void> test_project_lib_methods_static() async {
    newFile('$testPackageLibPath/a.dart', r'''
class A {
  static void foo() => 0;
}
''');

    await computeSuggestions('''
void f() {
  ^
}
''');

    assertNoSuggestion(completion: 'A.foo');
  }

  Future<void> test_project_lib_multipleExports() async {
    newFile('$testPackageLibPath/a.dart', r'''
class A {}
''');

    newFile('$testPackageLibPath/b.dart', r'''
export 'a.dart';
''');

    await computeSuggestions('''
void f() {
  ^
}
''');

    // Should be suggested from both libraries.
    assertSuggestion(
      completion: 'A',
      libraryUri: 'package:test/a.dart',
      element: ElementKind.CONSTRUCTOR,
      kind: CompletionSuggestionKind.INVOCATION,
    );
    assertSuggestion(
      completion: 'A',
      libraryUri: 'package:test/b.dart',
      element: ElementKind.CONSTRUCTOR,
      kind: CompletionSuggestionKind.INVOCATION,
    );
  }

  Future<void> test_project_lib_multipleExports_filteredByImport() async {
    newFile('$testPackageLibPath/a.dart', r'''
class A {}
''');

    newFile('$testPackageLibPath/b.dart', r'''
export 'a.dart';
''');

    await computeSuggestions('''
import 'b.dart';
void f() {
  ^
}
''');

    // Should be only one suggestion, which comes from the import of 'b.dart'.
    assertSuggestion(
      completion: 'A',
      element: ElementKind.CLASS,
      libraryUri: 'package:test/b.dart',
    );
  }

  Future<void> test_project_lib_multipleExports_filteredByLocal() async {
    newFile('$testPackageLibPath/a.dart', r'''
class A {}
''');

    await computeSuggestions('''
class A {}
void f() {
  ^
}
''');

    // Should be only one suggestion, which comes from local declaration.
    var suggestion = suggestionWith(
      completion: 'A',
      element: ElementKind.CLASS,
    );
    expect(suggestion, isNotNull);
    expect(suggestion.libraryUri, isNull);
  }

  Future<void> test_project_lib_setters_class() async {
    newFile('$testPackageLibPath/a.dart', r'''
class A {
  set s(int s) {}
}
''');

    await computeSuggestions('''
void f() {
  ^
}
''');

    assertNoSuggestion(completion: 's');
  }

  Future<void> test_project_lib_setters_static() async {
    newFile('$testPackageLibPath/a.dart', r'''
class A {
  static set foo(int _) {}
}
''');

    await computeSuggestions('''
void f() {
  ^
}
''');

    assertNoSuggestion(completion: 'A.foo');
  }

  /// See: https://github.com/dart-lang/sdk/issues/40626
  Future<void> test_project_lib_setters_topLevel() async {
    newFile('$testPackageLibPath/a.dart', r'''
set s(int s) {}
''');

    await computeSuggestions('''
void f() {
  ^
}
''');

    assertSuggestion(completion: 's', element: ElementKind.SETTER);
  }

  @FailingTest(issue: 'https://github.com/dart-lang/sdk/issues/38739')
  Future<void>
  test_project_suggestionRelevance_constructorParameterType() async {
    newFile('$testPackageLibPath/a.dart', r'''
import 'b.dart';

class A {
  A.b({O o});
}
''');

    newFile('$testPackageLibPath/b.dart', r'''
class O { }
''');

    await computeSuggestions('''
import 'a.dart';

void f(List<String> args) {
  var a = A.b(o: ^)
}
''');

    expect(
      suggestionWith(
        completion: 'O',
        element: ElementKind.CONSTRUCTOR,
        kind: CompletionSuggestionKind.INVOCATION,
      ).relevance,
      greaterThan(
        suggestionWith(
          completion: 'args',
          element: ElementKind.PARAMETER,
          kind: CompletionSuggestionKind.INVOCATION,
        ).relevance,
      ),
    );
  }

  Future<void> test_project_suggestionRelevance_constructorsAndTypes() async {
    newFile('$testPackageLibPath/a.dart', r'''
class A { }
''');

    await computeSuggestions('''
import 'a.dart';

void f(List<String> args) {
  var a = ^
}
''');

    expect(
      suggestionWith(
        completion: 'A',
        element: ElementKind.CONSTRUCTOR,
      ).relevance,
      greaterThan(
        suggestionWith(completion: 'A', element: ElementKind.CLASS).relevance,
      ),
    );
  }

  /// See: https://github.com/dart-lang/sdk/issues/35529
  Future<void> test_project_suggestMixins() async {
    newFile('$testPackageLibPath/a.dart', r'''
mixin M { }
mixin class A { }
''');

    await computeSuggestions('''
class C extends Object with ^
''');

    assertSuggestion(completion: 'M', element: ElementKind.MIXIN);
    assertSuggestion(completion: 'A', element: ElementKind.CLASS);
  }

  Future<void> test_sdk_lib_future_isNotDuplicated() async {
    await computeSuggestions('''
void f() {
  ^
}
''');

    expect(
      suggestionsWith(
        completion: 'Future.value',
        file: '/sdk/lib/async/async.dart',
        element: ElementKind.CONSTRUCTOR,
        kind: CompletionSuggestionKind.INVOCATION,
      ),
      hasLength(1),
    );
  }

  Future<void> test_sdk_lib_suggestions() async {
    await computeSuggestions('''
void f() {
  ^
}
''');

    // A kind-filtered set of SDK suggestions.

    // Constructors should be visible.
    assertSuggestion(
      completion: 'Timer',
      file: '/sdk/lib/async/async.dart',
      element: ElementKind.CONSTRUCTOR,
      kind: CompletionSuggestionKind.INVOCATION,
    );

    // But not methods.
    assertNoSuggestion(
      // dart:async (StreamSubscription)
      completion: 'asFuture',
      element: ElementKind.METHOD,
      kind: CompletionSuggestionKind.INVOCATION,
    );

    // +  Functions.
    assertSuggestion(
      completion: 'print',
      file: '/sdk/lib/core/core.dart',
      element: ElementKind.FUNCTION,
      kind: CompletionSuggestionKind.INVOCATION,
    );
    assertSuggestion(
      completion: 'tan',
      file: '/sdk/lib/math/math.dart',
      element: ElementKind.FUNCTION,
      kind: CompletionSuggestionKind.INVOCATION,
    );

    // + Classes.
    assertSuggestion(
      completion: 'HashMap',
      file: '/sdk/lib/collection/collection.dart',
      element: ElementKind.CLASS,
    );

    // + Top level variables.
    assertSuggestion(
      completion: 'pi',
      file: '/sdk/lib/math/math.dart',
      element: ElementKind.TOP_LEVEL_VARIABLE,
    );

    // (No typedefs, enums, extensions defined in the Mock SDK.)
  }
}
