// Copyright (c) 2024, 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 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analyzer/src/test_utilities/test_code_format.dart';
import 'package:dtd/dtd.dart';
import 'package:json_rpc_2/json_rpc_2.dart';
import 'package:test/test.dart';

import '../../integration_test/support/dart_tooling_daemon.dart';
import '../../integration_test/support/web_sockets.dart';
import '../lsp/request_helpers_mixin.dart';
import '../tool/lsp_spec/matchers.dart';
import '../utils/lsp_protocol_extensions.dart';
import '../utils/test_code_extensions.dart';

/// The name of the DTD service that LSP methods are registered against.
const lspServiceName = 'Lsp';

/// The name of the DTD stream that events/notifications will be posted to.
const lspStreamName = 'Lsp';

/// A wrapper around [DartToolingDaemon] that allows using the
/// [LspRequestHelpersMixin] methods to send requests over DTD.
class DtdHelper with LspRequestHelpersMixin {
  final DartToolingDaemon connection;

  new(this.connection);

  @override
  Future<T> expectSuccessfulResponseTo<T, R>(
    RequestMessage request,
    T Function(R) fromJson,
  ) async {
    var response = await sendRequestToServer(request);
    var error = response.error;
    if (error != null) {
      throw error;
    } else {
      // response.result should only be null when error != null if T allows null.
      return response.result == null
          ? null as T
          : fromJson(response.result as R);
    }
  }

  @override
  Future<ResponseMessage> sendRequestToServer(RequestMessage request) async {
    var response = await connection.call(
      lspServiceName,
      request.method.toString(),
      params: request.params as Map<String, Object?>,
    );

    return ResponseMessage(
      jsonrpc: jsonRpcVersion,
      // The LSP result is in the 'result' field, and DTD provides the whole
      // result in `response.result`.
      result: response.result['result'],
    );
  }
}

/// Shared DTD tests that are used by both LSP and legacy server integration
/// tests.
mixin SharedDtdTests
    on LspRequestHelpersMixin, LspEditHelpersMixin, LspVerifyEditHelpersMixin {
  /// The name of the DTD service that methods will be registered under.

  /// The `dart tooling-daemon` process we've spawned to connect to.
  late DtdProcess dtdProcess;

  /// The URI we can connect to [dtdProcess] using.
  late Uri dtdUri;

  /// A helper wrapping the tests connection to DTD which can be used to
  /// interact with DTD and send LSP requests using the shared request helper
  /// methods.
  ///
  /// Calling methods like `getHover` on this instance will send the request
  /// DTD whereas calling [getHover] would send the request directly to the
  /// server (via the real or simulated stdin/stdout streams).
  late DtdHelper dtd;

  /// A list of service/methods that the test client has seen registered (and
  /// not yet unregistered) over the DTD connection.
  ///
  /// The service name is a nullable String because DTD-internal methods and
  /// services do not have a service name.
  final availableMethods = <(String?, Method)>[];

  /// An invalid DTD URI used for testing connection failures.
  final invalidUri = Uri.parse('ws://invalid:345/invalid');

  // TODO(dantup): Support this for LSP-over-Legacy shared tests.
  set failTestOnErrorDiagnostic(bool value);

  /// Overridden by test subclasses to provide the path of a file for testing.
  String get testFile;

  /// Overridden by test subclasses to provide a URI for [testFile].
  Uri get testFileUri;

  /// Overridden by test subclasses to create a new file.
  void createFile(String path, String content);

  /// Sets up a file with [code] and expects/returns the [Command] code action
  /// with [title].
  Future<Command> expectCommandCodeAction(TestCode code, String title) async {
    createFile(testFile, code.code);
    await initializeServer();
    await sendConnectToDtdRequest();

    // Ensure the codeAction service is available.
    expectMethod(Method.textDocument_codeAction);

    // Fetch code actions at the marked location.
    var actions = await dtd.getCodeActions(
      testFileUri,
      range: code.range.range,
    );

    // Ensure all returned actions are Commands not CodeActionLiterals, because
    // our simple DTD client capabilities did not advertise CodeActionLiteral
    // support (because we cannot accept inline edits at a non-editor caller).
    var commands = actions.map((action) => action.asCommand).toList();

    // Find the one with the matching title.
    expect(commands.map((command) => command.title), contains(title));
    return commands.singleWhere((command) => command.title == title);
  }

  Future<void> expectCommandCodeActionEdits(
    TestCode code,
    String title,
    String expected,
  ) async {
    var command = await expectCommandCodeAction(code, title);

    // Invoke the command over DTD, expecting edits to be sent back to us
    // (not over DTD).
    var verifier = await executeForEdits(() => dtd.executeCommand(command));

    verifier.verifyFiles(expected);
  }

  void expectMethod(Method method, {bool available = true}) {
    if (available) {
      expect(availableMethods, contains((lspServiceName, method)));
    } else {
      expect(availableMethods, isNot(contains((lspServiceName, method))));
    }
  }

  /// Overridden by test subclasses to initialize the server.
  Future<void> initializeServer();

  /// Sends a request to connect to DTD and captures all service methods that
  /// are registered/unregistered into [availableMethods] until the `ready`
  /// event is posted.
  ///
  /// [registerExperimentalHandlers] controls whether experimental handlers are
  /// registered.
  Future<void> sendConnectToDtdRequest({
    Uri? uri,
    bool? registerExperimentalHandlers,
  }) async {
    // Set up a completer to listen for the 'initialized' event on the Lsp
    // stream so that we know when the services have finished registering.
    var lspInitializedCompleter = Completer<void>();
    var lspEventSub = dtd.connection.onEvent(lspStreamName).listen((e) {
      switch (e.kind) {
        case 'initialized':
          lspInitializedCompleter.complete();
      }
    });
    await dtd.connection.streamListen(lspStreamName);

    try {
      await connectToDtd(
        uri ?? dtdUri,
        registerExperimentalHandlers: registerExperimentalHandlers,
      );

      // Wait for the event.
      await lspInitializedCompleter.future;
    } finally {
      // Unsubscribe.
      await lspEventSub.cancel();
      await dtd.connection.streamCancel(lspStreamName);
    }
  }

  Future<void> setUpDtd() async {
    // Start the DTD process like an editor would.
    dtdProcess = await DtdProcess.start();

    // Create our own (logged) connection to it
    dtdUri = await dtdProcess.dtdUri;
    dtd = DtdHelper(
      DartToolingDaemon.fromStreamChannel(
        await createLoggedWebSocketChannel(dtdUri),
      ),
    );

    // Capture service method registrations/unregistrations.
    dtd.connection.onEvent('Service').listen((e) {
      switch (e.kind) {
        case 'ServiceRegistered':
          availableMethods.add((
            e.data['service'] as String?,
            Method(e.data['method'] as String),
          ));
        case 'ServiceUnregistered':
          availableMethods.remove((
            e.data['service'] as String?,
            Method(e.data['method'] as String),
          ));
      }
    });

    // Start listening to the stream.
    await dtd.connection.streamListen('Service');
  }

  /// Overridden by test subclasses to instruct the server to shut down (which
  /// should result in DTD services being unregistered).
  Future<void> shutdownServer();

  Future<void> tearDownDtd() async {
    await dtdProcess.dispose();
  }

  Future<void> test_connectToDtd_failure_alreadyRegistered() async {
    await initializeServer();
    await sendConnectToDtdRequest();
    await expectLater(
      sendConnectToDtdRequest(),
      throwsA(
        isResponseError(
          ServerErrorCodes.stateError,
          message: 'Server is already connected to DTD',
        ),
      ),
    );
  }

  Future<void> test_connectToDtd_failure_invalidUri() async {
    await initializeServer();
    await expectLater(
      sendConnectToDtdRequest(uri: invalidUri),
      throwsA(
        isResponseError(
          ErrorCodes.RequestFailed,
          message: startsWith(
            'Failed to connect to DTD at ws://invalid:345/invalid\nWebSocketChannelException:',
          ),
        ),
      ),
    );
  }

  Future<void> test_connectToDtd_success_afterFailureToConnect() async {
    await initializeServer();

    // Perform a failed connection.
    await expectLater(
      sendConnectToDtdRequest(uri: invalidUri),
      throwsA(
        isResponseError(
          ErrorCodes.RequestFailed,
          message: startsWith(
            'Failed to connect to DTD at ws://invalid:345/invalid\nWebSocketChannelException:',
          ),
        ),
      ),
    );

    // Expect complete with no error.
    await sendConnectToDtdRequest();
  }

  Future<void> test_connectToDtd_success_afterPreviousDtdShutdown() async {
    await initializeServer();

    // Connect to the initial DTD.
    await sendConnectToDtdRequest();

    // Shut down the initial DTD process as if it crashed. Server should notice
    // this and now allow us to connect a new one.
    await tearDownDtd();

    // Start up a new DTD.
    await setUpDtd();

    // Connect to the new DTD and ensure completion with no error.
    await sendConnectToDtdRequest();
  }

  Future<void>
  test_connectToDtd_success_doesNotRegister_connectToDtdMethod() async {
    await initializeServer();
    await sendConnectToDtdRequest();

    expectMethod(CustomMethods.connectToDtd, available: false);
  }

  Future<void>
  test_connectToDtd_success_doesNotRegister_experimentalMethods() async {
    await initializeServer();
    await sendConnectToDtdRequest();

    expectMethod(CustomMethods.experimentalEcho, available: false);
  }

  Future<void>
  test_connectToDtd_success_doesNotRegister_fileStateMethods() async {
    await initializeServer();
    await sendConnectToDtdRequest();

    // Methods that modify state that is owned by the server shouldn't be
    // registered.
    expectMethod(Method.textDocument_didOpen, available: false);
    expectMethod(Method.textDocument_didClose, available: false);
    expectMethod(Method.textDocument_didChange, available: false);
  }

  Future<void>
  test_connectToDtd_success_doesNotRegister_initializationMethods() async {
    await initializeServer();
    await sendConnectToDtdRequest();

    // No initialization request/notifications should be available.
    expectMethod(Method.initialize, available: false);
    expectMethod(Method.initialized, available: false);
  }

  Future<void> test_connectToDtd_success_registers_experimentalMethods() async {
    await initializeServer();
    await sendConnectToDtdRequest(registerExperimentalHandlers: true);

    expectMethod(CustomMethods.experimentalEcho);
  }

  Future<void> test_connectToDtd_success_registers_standardLspMethods() async {
    await initializeServer();
    await sendConnectToDtdRequest();

    // Check some known methods that should be available.
    expectMethod(Method.textDocument_documentSymbol);
    expectMethod(Method.textDocument_hover);
    expectMethod(Method.textDocument_formatting);
    expectMethod(Method.textDocument_implementation);
    expectMethod(Method.textDocument_documentColor);
  }

  Future<void> test_service_codeAction_assist() async {
    setApplyEditSupport();

    var code = TestCode.parseNormalized('''
var a = [!''!];
''');

    var title = 'Convert to double quoted string';
    var expected = r'''
>>>>>>>>>> lib/main.dart
var a = "";
''';

    await expectCommandCodeActionEdits(code, title, expected);
  }

  Future<void> test_service_codeAction_fix() async {
    failTestOnErrorDiagnostic = false;
    setApplyEditSupport();

    var code = TestCode.parseNormalized('''
Future<void> [!f!]() {}
''');

    var title = "Add 'async' modifier";
    var expected = r'''
>>>>>>>>>> lib/main.dart
Future<void> f() async {}
''';

    await expectCommandCodeActionEdits(code, title, expected);
  }

  Future<void> test_service_codeAction_refactor() async {
    setApplyEditSupport();

    var code = TestCode.parseNormalized('''
void f() {
  [!print('');!]
}
''');

    var title = 'Extract Method';
    var expected = r'''
>>>>>>>>>> lib/main.dart
void f() {
  newMethod();
}

void newMethod() {
  print('');
}
''';

    await expectCommandCodeActionEdits(code, title, expected);
  }

  Future<void> test_service_codeAction_source() async {
    setApplyEditSupport();

    var code = TestCode.parseNormalized('''
[!!]import 'dart:async';
import 'dart:io';

FutureOr<void>? a;
''');

    var title = 'Organize Imports';
    var expected = r'''
>>>>>>>>>> lib/main.dart
import 'dart:async';

FutureOr<void>? a;
''';

    await expectCommandCodeActionEdits(code, title, expected);
  }

  Future<void> test_service_failure_hover() async {
    await initializeServer();
    await sendConnectToDtdRequest();

    // Attempt an unsuccessful request to textDocument/hover over DTD.
    expectMethod(Method.textDocument_hover);
    var nonExistantFilePath = testFile.replaceAll('.dart', '.notExist.dart');
    var call = dtd.getHover(
      Uri.file(nonExistantFilePath),
      Position(line: 1, character: 1),
    );

    // Expect a proper RPC Exception with the standard LSP error code/message.
    var expectedException = isA<RpcException>()
        .having(
          (e) => e.code,
          'code',
          ServerErrorCodes.invalidFilePath.toJson(),
        )
        .having((e) => e.message, 'message', 'File does not exist')
        .having((e) => e.data, 'data', nonExistantFilePath);
    await expectLater(call, throwsA(expectedException));
  }

  Future<void> test_service_success_echo() async {
    await initializeServer();
    await sendConnectToDtdRequest(registerExperimentalHandlers: true);

    var response = await dtd.connection.call(
      lspServiceName,
      CustomMethods.experimentalEcho.toString(),
      params: {'a': 'b'},
    );

    var result = response.result['result'] as Map<String, Object?>?;

    expect(result, equals({'a': 'b'}));
  }

  Future<void>
  test_service_success_echo_nullResponse_with_empty_params() async {
    await initializeServer();
    await sendConnectToDtdRequest(registerExperimentalHandlers: true);

    var response = await dtd.connection.call(
      lspServiceName,
      CustomMethods.experimentalEcho.toString(),
      params: const <String, Object?>{},
    );

    var result = response.result['result'] as Map<String, Object?>?;

    expect(response.type, 'Null');
    expect(result, isNull);
  }

  Future<void> test_service_success_echo_nullResponse_with_null_params() async {
    await initializeServer();
    await sendConnectToDtdRequest(registerExperimentalHandlers: true);

    var response = await dtd.connection.call(
      lspServiceName,
      CustomMethods.experimentalEcho.toString(),
    );

    var result = response.result['result'] as Map<String, Object?>?;

    expect(response.type, 'Null');
    expect(result, isNull);
  }

  Future<void> test_service_success_hover() async {
    var code = TestCode.parseNormalized('''
/// A function.
void [!myFun^ction!]() {}
''');
    createFile(testFile, code.code);

    await initializeServer();
    await sendConnectToDtdRequest();

    // Attempt a successful request to textDocument/hover over DTD.
    expectMethod(Method.textDocument_hover);
    var hoverResult = await dtd.getHover(testFileUri, code.position.position);

    // Verify the result.
    var hoverStringContent = hoverResult!.contents.map(
      (markup) => markup.value,
      (string) => string,
    );
    expect(hoverResult.range, equals(code.range.range));
    expect(hoverStringContent, contains('A function.'));
  }

  Future<void> test_service_unregisteredOnShutdown() async {
    await initializeServer();
    await sendConnectToDtdRequest();

    var lspMethods = availableMethods.where(
      (serviceMethod) => serviceMethod.$1 == 'Lsp',
    );
    expect(lspMethods, isNotEmpty);

    // Send a request to the server to connect to DTD. This will only complete
    // once all services are registered, however there's no guarantee about the
    // time DTD takes to forward those service registrations to us, so we also
    // need some delay.
    await shutdownServer();

    // Wait for the services to be unregistered.
    while (lspMethods.isNotEmpty) {
      await pumpEventQueue(times: 5000);
    }
    expect(lspMethods, isEmpty);
  }
}
