// 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:async';
import 'dart:convert';
import 'dart:io';

import 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:analysis_server/src/lsp/channel/lsp_byte_stream_channel.dart';
import 'package:analysis_server/src/services/pub/pub_command.dart';
import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:analyzer/instrumentation/instrumentation.dart';
import 'package:analyzer_plugin/src/utilities/client_uri_converter.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;

import '../../test/constants.dart';
import '../../test/lsp/request_helpers_mixin.dart';
import '../../test/lsp/server_abstract.dart';
import '../../test/support/sdk_paths.dart';

abstract class AbstractLspAnalysisServerIntegrationTest
    with
        ClientCapabilitiesHelperMixin,
        LspRequestHelpersMixin,
        LspReverseRequestHelpersMixin,
        LspEditHelpersMixin,
        LspVerifyEditHelpersMixin,
        LspNotificationsMixin,
        LspAnalysisServerTestMixin {
  final List<String> vmArgs = [];
  LspServerClient? client;
  InstrumentationService? instrumentationService;
  final Map<Either2<int, String>, Completer<ResponseMessage>> _completers = {};
  String dartSdkPath = path.dirname(path.dirname(Platform.resolvedExecutable));

  @override
  late final ClientUriConverter uriConverter = ClientUriConverter.noop(
    pathContext,
  );

  /// Tracks the current overlay content so that when we apply edits they can
  /// be applied in the same way a real client would apply them.
  final _overlayContent = <Uri, String>{};

  /// Temporary folders created by the test that should be deleted (recursively)
  /// during [tearDown].
  final List<String> _temporaryFolders = [];

  LspByteStreamServerChannel get channel => client!.channel!;

  @override
  path.Context get pathContext => PhysicalResourceProvider.INSTANCE.pathContext;

  @override
  Stream<Message> get serverToClient => client!.serverToClient;

  @override
  Future<void> closeFile(Uri uri) {
    _overlayContent.remove(uri);
    return super.closeFile(uri);
  }

  /// Sends a request to the server and unwraps the result. Throws if the
  /// response was not successful or returned an error.
  @override
  Future<T> expectSuccessfulResponseTo<T, R>(
    RequestMessage request,
    T Function(R) fromJson,
  ) async {
    var resp = await sendRequestToServer(request);
    var error = resp.error;
    if (error != null) {
      throw error;
    } else if (T == Null) {
      return resp.result == null
          ? null as T
          : throw 'Expected Null response but got ${resp.result}';
    } else {
      return fromJson(resp.result as R);
    }
  }

  @override
  String? getCurrentFileContent(Uri uri) {
    // First try and overlay the test has set.
    if (_overlayContent.containsKey(uri)) {
      return _overlayContent[uri];
    }

    // Otherwise fall back to the disk.
    try {
      return File(uri.toFilePath()).readAsStringSync();
    } catch (_) {
      return null;
    }
  }

  void newFile(String path, String content) =>
      File(path).writeAsStringSync(content);

  void newFolder(String path) => Directory(path).createSync(recursive: true);

  @override
  Future<void> openFile(Uri uri, String content, {int version = 1}) {
    _overlayContent[uri] = content;
    return super.openFile(uri, content, version: version);
  }

  @override
  Future<void> replaceFile(int newVersion, Uri uri, String content) {
    _overlayContent[uri] = content;
    return super.replaceFile(newVersion, uri, content);
  }

  @override
  void sendNotificationToServer(NotificationMessage notification) =>
      channel.sendNotification(notification);

  @override
  Future<ResponseMessage> sendRequestToServer(RequestMessage request) {
    var completer = Completer<ResponseMessage>();
    var id = request.id;
    _completers[id] = completer;

    channel.sendRequest(request);

    return completer.future;
  }

  @override
  void sendResponseToServer(ResponseMessage response) =>
      channel.sendResponse(response);

  @mustCallSuper
  Future<void> setUp() async {
    // Set up temporary folder for the test.
    projectFolderPath = Directory.systemTemp
        .createTempSync('analysisServer_test_integration_lspProject')
        .resolveSymbolicLinksSync();
    _temporaryFolders.add(projectFolderPath);
    newFolder(projectFolderPath);
    newFolder(path.join(projectFolderPath, 'lib'));
    mainFilePath = path.join(projectFolderPath, 'lib', 'main.dart');
    analysisOptionsPath = path.join(projectFolderPath, 'analysis_options.yaml');

    var client = LspServerClient(instrumentationService);
    this.client = client;
    await client.start(dartSdkPath: dartSdkPath, vmArgs: vmArgs);
    client.serverToClient.listen((message) {
      if (message is ResponseMessage) {
        var id = message.id;
        var completer = _completers[id];
        if (completer == null) {
          throw 'Response with ID $id was unexpected';
        } else {
          _completers.remove(id);
          completer.complete(message);
        }
      }
    });
  }

  void tearDown() {
    // TODO(dantup): Graceful shutdown?
    client?.close();
    for (var temporaryFolder in _temporaryFolders) {
      Directory(temporaryFolder).deleteSync(recursive: true);
    }
  }
}

class LspServerClient {
  final InstrumentationService? instrumentationService;
  Process? _process;
  LspByteStreamServerChannel? channel;
  final StreamController<Message> _serverToClient =
      StreamController<Message>.broadcast();

  /// Whether the first line of output from the server was already checked.
  bool _firstLineChecked = false;

  /// If the first line of output turned out to be the DevTools URI line,
  /// these whole line is used for this completer.
  final Completer<String> _devToolsLineCompleter = Completer<String>();

  new(this.instrumentationService);

  /// Completes with the DevTools URI line, maybe never.
  Future<String> get devToolsLine => _devToolsLineCompleter.future;

  Future<int> get exitCode => _process!.exitCode;

  path.Context get pathContext => PhysicalResourceProvider.INSTANCE.pathContext;

  Stream<Message> get serverToClient => _serverToClient.stream;

  void close() {
    channel?.close();
    _process?.kill();
  }

  Future<void> start({
    required String dartSdkPath,
    List<String>? vmArgs,
  }) async {
    if (_process != null) {
      throw Exception('Process already started');
    }

    var dartBinary = path.join(dartSdkPath, 'bin', 'dart');
    var serverPath = await getAnalysisServerPath(dartSdkPath);

    var arguments = [...?vmArgs, serverPath, '--lsp', '--suppress-analytics'];
    var process = await Process.start(
      dartBinary,
      arguments,
      environment: {PubCommand.disablePubCommandEnvironmentKey: 'true'},
    );
    _process = process;
    unawaited(
      process.exitCode.then((int code) {
        if (code != 0) {
          // TODO(dantup): Log/fail tests...
        }
      }),
    );

    // If the server writes to stderr, fail tests with a more useful message
    // (rather than having the test just hang waiting for a response).
    process.stderr.listen((data) {
      var message = String.fromCharCodes(data);
      throw 'Analysis Server wrote to stderr:\n\n$message';
    });

    var inputStream = _extractDevToolsLine(process.stdout);
    var outputStream = process.stdin;

    channel = LspByteStreamServerChannel(
      inputStream,
      outputStream,
      instrumentationService ?? InstrumentationLogAdapter(PrintableLogger()),
    )..listen(_serverToClient.add);
  }

  /// Checks the first line in the [input], and if it is the DevTools URI
  /// line, completes [devToolsLine] with it, and excludes it from the sink.
  /// Otherwise, and after the first line, passes data to the sink.
  Stream<List<int>> _extractDevToolsLine(Stream<List<int>> input) {
    var buffer = <int>[];
    return input.transform(
      StreamTransformer.fromHandlers(
        handleData: (bytes, sink) {
          if (_firstLineChecked) {
            sink.add(bytes);
          } else {
            buffer.addAll(bytes);
            var lineFeedIndex = buffer.indexOf(0x0A);
            if (lineFeedIndex != -1) {
              _firstLineChecked = true;
              var lineBytes = buffer.sublist(0, lineFeedIndex);
              var line = utf8.decode(lineBytes);
              if (line.startsWith('The Dart DevTools')) {
                _devToolsLineCompleter.complete(line);
                sink.add(buffer.sublist(lineFeedIndex + 1));
              } else {
                sink.add(buffer);
              }
            }
          }
        },
      ),
    );
  }
}

/// An [InstrumentationLogger] that buffers logs until [debugStdio()] is called.
class PrintableLogger extends InstrumentationLogger {
  bool _printLogs = debugPrintCommunication;
  final _buffer = StringBuffer();

  void debugStdio() {
    print(_buffer.toString());
    _buffer.clear();
    _printLogs = true;
  }

  @override
  void log(String message) {
    if (_printLogs) {
      print(message);
    } else {
      _buffer.writeln(message);
    }
  }

  @override
  Future<void> shutdown() async {
    _printLogs = false;
    _buffer.clear();
  }
}
