// Copyright (c) 2022, 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/protocol/protocol_generated.dart';
import 'package:analysis_server/src/protocol/protocol_internal.dart';
import 'package:analysis_server/src/server/driver.dart';
import 'package:analyzer/dart/analysis/analysis_context.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:args/args.dart';
import 'package:path/path.dart' as path;

import 'completion_metrics_base.dart';
import 'metrics_util.dart';
import 'output_utilities.dart';
import 'relevance_table_generator.dart';
import 'visitors.dart';

Future<void> main(List<String> args) async {
  var parser = _createArgParser();
  var result = parser.parse(args);

  if (!_validArguments(parser, result)) {
    return;
  }

  var rootPath = result.rest[0];
  var targets = <Directory>[];
  if (Directory(rootPath).existsSync()) {
    targets.add(Directory(rootPath));
  } else {
    throw "Directory doesn't exist: $rootPath";
  }

  var options = CompletionMetricsOptions(result);
  var stopwatch = Stopwatch()..start();
  var client = _AnalysisServerClient(Directory(_sdk.sdkPath), targets);
  await _CompletionClientMetricsComputer(
    rootPath,
    options,
    client,
  ).computeMetrics();
  stopwatch.stop();

  var duration = Duration(milliseconds: stopwatch.elapsedMilliseconds);
  print('');
  print('Metrics computed in $duration');
}

final _Sdk _sdk = _Sdk._instance;

/// Given a data structure which is a Map of String to dynamic values, returns
/// the same structure (`Map<String, dynamic>`) with the correct runtime types.
Map<String, dynamic> _castStringKeyedMap(dynamic untyped) {
  Map<dynamic, dynamic> map = untyped! as Map<dynamic, dynamic>;
  return map.cast<String, dynamic>();
}

/// Creates a parser that can be used to parse the command-line arguments.
ArgParser _createArgParser() {
  return ArgParser(
      usageLineLength: stdout.hasTerminal ? stdout.terminalColumns : 80,
    )
    ..addFlag('help', abbr: 'h', help: 'Print this help message.')
    ..addOption(
      CompletionMetricsOptions.overlayOption,
      allowed: [
        OverlayMode.none.flag,
        OverlayMode.removeRestOfFile.flag,
        OverlayMode.removeToken.flag,
      ],
      defaultsTo: OverlayMode.none.flag,
      help:
          'Before attempting a completion at the location of each token, the '
          'token can be removed, or the rest of the file can be removed to '
          'test code completion with diverse methods. The default mode is to '
          'complete at the start of the token without modifying the file.',
    )
    ..addOption(
      CompletionMetricsOptions.prefixLengthOption,
      defaultsTo: '0',
      help:
          'The number of characters to include in the prefix. Each '
          'completion will be requested this many characters in from the '
          'start of the token being completed.',
    )
    ..addFlag(
      CompletionMetricsOptions.printSlowestResultsFlag,
      help:
          'Print information about the completion requests that were the '
          'slowest to return suggestions.',
      negatable: false,
    );
}

/// Prints usage information for this tool.
void _printUsage(ArgParser parser, {String? error}) {
  if (error != null) {
    print(error);
    print('');
  }
  print('usage: dart completion_metrics_client.dart [options] packagePath');
  print('');
  print('Compute code completion health metrics.');
  print('');
  print(parser.usage);
}

/// Trims [suffix] from the end of [text].
String _trimEnd(String text, String suffix) {
  if (text.endsWith(suffix)) {
    return text.substring(0, text.length - suffix.length);
  }
  return text;
}

/// Returns `true` if the command-line arguments (represented by the [result]
/// and parsed by the [parser]) are valid.
bool _validArguments(ArgParser parser, ArgResults result) {
  if (result.wasParsed('help')) {
    _printUsage(parser);
    return false;
  } else if (result.rest.length != 1) {
    _printUsage(parser, error: 'No package path specified.');
    return false;
  }
  return validateDir(parser, result.rest[0]);
}

class CompletionMetrics {
  /// A percentile computer which tracks the total time to create and send a
  /// completion request, and receive and decode a completion response, using
  /// 2.000 seconds as the max value to use in percentile calculations.
  final PercentileComputer totalPercentileComputer = PercentileComputer(
    'ms for total duration',
    valueLimit: 2000,
  );

  /// A percentile computer which tracks the time to send a completion request,
  /// and receive a completion response, not including any time to encode or
  /// decode, using 2.000 seconds as the max value to use in percentile
  /// calculations.
  final PercentileComputer requestResponsePercentileComputer =
      PercentileComputer('ms for request/response duration', valueLimit: 2000);

  /// A percentile computer which tracks the time to decode each completion
  /// response into JSON, using 2.000 seconds as the max value to use in
  /// percentile calculations.
  final PercentileComputer decodePercentileComputer = PercentileComputer(
    'ms for decode duration',
    valueLimit: 2000,
  );

  /// A percentile computer which tracks the time to deserialize each completion
  /// response JSON into Dart objects, using 2.000 seconds as the max value to
  /// use in percentile calculations.
  final PercentileComputer deserializePercentileComputer = PercentileComputer(
    'ms for deserialize duration',
    valueLimit: 2000,
  );
}

/// A client for communicating with the analysis server over stdin/stdout.
class _AnalysisServerClient {
  // This class is copied from package:dartdev/src/analysis_server.dart and
  // stripped.

  final Directory sdkPath;
  final List<FileSystemEntity> analysisRoots;

  Process? _process;

  /// When not null, this is a [Completer] which completes when analysis has
  /// finished, otherwise `null`.
  Completer<bool>? _analysisFinished;

  int _id = 0;

  bool _shutdownResponseReceived = false;

  final Map<String, StreamController<Map<String, dynamic>>> _streamControllers =
      {};

  final _onCrash = Completer<void>();

  final Map<String, Completer<Map<String, dynamic>>> _requestCompleters = {};

  final Map<String, _RequestMetadata> _requestMetadata = {};

  new(this.sdkPath, this.analysisRoots);

  /// Completes when we next receive an analysis finished event (unless there's
  /// no current analysis and we've already received a complete event, in which
  /// case this future completes immediately).
  Future<bool>? get analysisFinished => _analysisFinished?.future;

  Stream<bool> get onAnalyzing {
    // {"event":"server.status","params":{"analysis":{"isAnalyzing":true}}}
    return _streamController('server.status').stream
        .where((event) => event!['analysis'] != null)
        .map((event) => event!['analysis']['isAnalyzing']! as bool);
  }

  /// Completes when an analysis server crash has been detected.
  Future<void> get onCrash => _onCrash.future;

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

  Future<AnalysisUpdateContentResult> addOverlay(
    String file,
    String content,
  ) async {
    var response = await _sendCommand(
      'analysis.updateContent',
      params: {
        'files': {
          file: {'type': 'add', 'content': content},
        },
      },
    );
    var result = response['result'] as Map<String, dynamic>;

    return AnalysisUpdateContentResult.fromJson(
      ResponseDecoder(null),
      'result',
      result,
      clientUriConverter: null,
    );
  }

  Future<bool> dispose() async {
    return _process?.kill() ?? true;
  }

  Future<AnalysisUpdateContentResult> removeOverlay(String file) async {
    var response = await _sendCommand(
      'analysis.updateContent',
      params: {
        'files': {
          file: {'type': 'remove'},
        },
      },
    );
    var result = response['result'] as Map<String, dynamic>;

    return AnalysisUpdateContentResult.fromJson(
      ResponseDecoder(null),
      'result',
      result,
      clientUriConverter: null,
    );
  }

  /// Requests a completion for [file] at [offset].
  Future<_SuggestionsData> requestCompletion(
    String file,
    int offset,
    int maxResults,
  ) async {
    var response = await _sendCommand(
      'completion.getSuggestions2',
      params: {'file': file, 'offset': offset, 'maxResults': maxResults},
    );
    var result = response['result'] as Map<String, dynamic>;
    var metadata = _requestMetadata[response['id']]!;

    var deserializeStopwatch = Stopwatch()..start();
    var suggestionsResult = CompletionGetSuggestions2Result.fromJson(
      ResponseDecoder(null),
      'result',
      result,
      clientUriConverter: null,
    );
    deserializeStopwatch.stop();
    metadata.deserializeDuration = deserializeStopwatch.elapsedMilliseconds;

    return _SuggestionsData(suggestionsResult, metadata);
  }

  Future<void> shutdown({Duration timeout = const Duration(seconds: 5)}) async {
    // Request shutdown.
    await _sendCommand('server.shutdown')
        .then((value) {
          _shutdownResponseReceived = true;
          return null;
        })
        .timeout(
          timeout,
          onTimeout: () async {
            logger.stderr('The analysis server timed out while shutting down.');
            await dispose();
          },
        )
        .then((value) async {
          await dispose();
        });
  }

  Future<void> start({bool setAnalysisRoots = true}) async {
    var process = await _startDartProcess(_sdk, [
      _sdk.analysisServerSnapshot,
      '--${Driver.suppressAnalyticsFlag}',
      '--${Driver.clientIdOption}=completion-metrics-client',
      '--sdk',
      sdkPath.path,
    ]);
    _process = process;
    _shutdownResponseReceived = false;
    // This callback hookup can't throw.
    unawaited(
      process.exitCode.whenComplete(() {
        _process = null;

        if (!_shutdownResponseReceived) {
          // The process exited unexpectedly. Report the crash.
          // If `server.error` reported an error, that has been logged by
          // `_handleServerError`.

          var error = StateError('The analysis server crashed unexpectedly');

          var analysisFinished = _analysisFinished;
          if (analysisFinished != null && !analysisFinished.isCompleted) {
            // Complete this completer in order to unstick the process.
            analysisFinished.completeError(error);
          }

          // Complete these completers in order to unstick the process.
          for (var completer in _requestCompleters.values) {
            completer.completeError(error);
          }

          _onCrash.complete();
        }
      }),
    );

    var errorStream = process.stderr
        .transform<String>(utf8.decoder)
        .transform<String>(const LineSplitter());
    errorStream.listen(logger.stderr);

    var inStream = process.stdout
        .transform<String>(utf8.decoder)
        .transform<String>(const LineSplitter());
    inStream.listen(_handleServerResponse);

    _streamController('server.error').stream.listen(_handleServerError);

    await _sendCommand(
      'server.setSubscriptions',
      params: <String, dynamic>{
        'subscriptions': <String>['STATUS'],
      },
    );

    // Reference and trim off any trailing slash, the Dart Analysis Server
    // protocol throws an error (INVALID_FILE_PATH_FORMAT) if there is a
    // trailing slash.
    //
    // The call to `absolute.resolveSymbolicLinksSync()` canonicalizes the path
    // to be passed to the analysis server.
    var analysisRootPaths = [
      for (final root in analysisRoots)
        _trimEnd(
          root.absolute.resolveSymbolicLinksSync(),
          path.context.separator,
        ),
    ];

    onAnalyzing.listen((isAnalyzing) {
      var analysisFinished = _analysisFinished;
      if (isAnalyzing && (analysisFinished?.isCompleted ?? true)) {
        // Start a new completer, to be completed when we receive the
        // corresponding analysis complete event.
        _analysisFinished = Completer();
      } else if (!isAnalyzing &&
          analysisFinished != null &&
          !analysisFinished.isCompleted) {
        analysisFinished.complete(true);
      }
    });

    if (setAnalysisRoots) {
      await _sendCommand(
        'analysis.setAnalysisRoots',
        params: {'included': analysisRootPaths, 'excluded': []},
      );
    }
  }

  void _handleServerError(Map<String, dynamic>? error) {
    var err = error!;
    // Fields are 'isFatal', 'message', and 'stackTrace'.
    logger.stderr('Error from the analysis server: ${err['message']}');
    if (err['stackTrace'] != null) {
      logger.stderr(err['stackTrace'] as String);
    }
  }

  void _handleServerResponse(String line) {
    logger.trace('<== $line');

    var responseTime = DateTime.now().millisecondsSinceEpoch;

    var decodeStopwatch = Stopwatch()..start();
    dynamic response = json.decode(line);
    decodeStopwatch.stop();
    var decodeDuration = decodeStopwatch.elapsedMilliseconds;

    if (response is Map<String, dynamic>) {
      if (response['event'] != null) {
        var event = response['event'] as String;
        dynamic params = response['params'];

        if (params is Map<String, dynamic>) {
          _streamController(event).add(_castStringKeyedMap(params));
        }
      } else if (response['id'] != null) {
        var id = response['id'];
        var metadata = _requestMetadata[id]!;
        metadata.responseMilliseconds = responseTime;
        metadata.decodeDuration = decodeDuration;

        if (response['error'] != null) {
          var error = _castStringKeyedMap(response['error']);
          _requestCompleters
              .remove(id)
              ?.completeError(_RequestError.parse(error));
        } else {
          _requestCompleters.remove(id)?.complete(response);
        }
      }
    }
  }

  Future<Map<String, dynamic>> _sendCommand(
    String method, {
    Map<String, dynamic>? params,
  }) {
    String id = (++_id).toString();
    String message = json.encode({
      'id': id,
      'method': method,
      'params': params,
    });
    _requestMetadata[id] = _RequestMetadata(
      DateTime.now().millisecondsSinceEpoch,
    );
    _requestCompleters[id] = Completer();
    _process!.stdin.writeln(message);
    logger.trace('==> $message');
    return _requestCompleters[id]!.future;
  }

  /// A utility method to start a Dart VM instance with the given arguments and an
  /// optional current working directory.
  ///
  /// [arguments] should contain the snapshot path.
  Future<Process> _startDartProcess(
    _Sdk sdk,
    List<String> arguments, {
    String? cwd,
  }) {
    logger.trace('${sdk.dart} ${arguments.join(' ')}');
    return Process.start(sdk.dart, arguments, workingDirectory: cwd);
  }

  StreamController<Map<String, dynamic>?> _streamController(String streamId) {
    return _streamControllers.putIfAbsent(
      streamId,
      () => StreamController<Map<String, dynamic>>.broadcast(),
    );
  }
}

class _CompletionClientMetricsComputer extends CompletionMetricsComputer {
  final _AnalysisServerClient client;

  final CompletionMetrics targetMetric = CompletionMetrics();

  final metrics = CompletionMetrics();

  new(super.rootPath, super.options, this.client);

  @override
  Future<void> applyOverlay(
    AnalysisContext context,
    String filePath,
    ExpectedCompletion expectedCompletion,
  ) async {
    if (options.overlay != OverlayMode.none) {
      var overlayContent = CompletionMetricsComputer.getOverlayContent(
        resolvedUnitResult.content,
        expectedCompletion,
        options.overlay,
        options.prefixLength,
      );

      provider.setOverlay(
        filePath,
        content: overlayContent,
        modificationStamp: overlayModificationStamp++,
      );
      await client.addOverlay(filePath, overlayContent);
    }
  }

  @override
  Future<void> computeMetrics() async {
    await client.start();
    await super.computeMetrics();
    await client.shutdown();

    // A row containing the name, median, p90, and p95 scores in [computer].
    List<String> m9095Row(PercentileComputer computer) => [
      computer.name,
      computer.median.toString(),
      computer.p90.toString(),
      computer.p95.toString(),
    ];

    var table = [
      ['', 'median', 'p90', 'p95'],
      m9095Row(metrics.totalPercentileComputer),
      m9095Row(metrics.requestResponsePercentileComputer),
      m9095Row(metrics.decodePercentileComputer),
      m9095Row(metrics.deserializePercentileComputer),
    ];

    rightJustifyColumns(table, range(1, table[0].length));
    printTable(table);
  }

  @override
  Future<void> computeSuggestionsAndMetrics(
    ExpectedCompletion expectedCompletion,
    AnalysisContext context,
  ) async {
    var stopwatch = Stopwatch()..start();
    var suggestionsData = await client.requestCompletion(
      expectedCompletion.filePath,
      expectedCompletion.offset,
      1000,
    );
    stopwatch.stop();
    var metadata = suggestionsData.metadata;

    metrics.totalPercentileComputer.addValue(stopwatch.elapsedMilliseconds);
    metrics.requestResponsePercentileComputer.addValue(
      metadata.requestResponseDuration,
    );
    metrics.decodePercentileComputer.addValue(metadata.decodeDuration);
    metrics.deserializePercentileComputer.addValue(
      metadata.deserializeDuration,
    );
  }

  @override
  Future<void> removeOverlay(AnalysisContext context, String filePath) async {
    if (options.overlay != OverlayMode.none) {
      await client.removeOverlay(filePath);
      context.changeFile(filePath);
      await context.applyPendingFileChanges();
      resolvedUnitResult =
          await context.currentSession.getResolvedUnit(filePath)
              as ResolvedUnitResult;
    }
  }

  @override
  void setupForResolution(AnalysisContext context) {}
}

class _RequestError {
  // This is copied from package:dartdev/src/analysis_server.dart.

  final String code;

  final String message;
  final String stackTrace;
  new(this.code, this.message, {required this.stackTrace});

  @override
  String toString() => '[RequestError code: $code, message: $message]';

  static _RequestError parse(dynamic error) {
    return _RequestError(
      error['code'] as String,
      error['message'] as String,
      stackTrace: error['stackTrace'] as String,
    );
  }
}

class _RequestMetadata {
  /// The timestamp of when a request was started, in milliseconds.
  ///
  /// This does not include the time it takes to encode the request into JSON.
  final int startMilliseconds;

  /// The timestamp of when a response was received, in milliseconds.
  late final int responseMilliseconds;

  /// The duration of decoding a response, in milliseconds.
  late final int decodeDuration;

  /// The duration of deserializing a response, in milliseconds.
  late final int deserializeDuration;

  new(this.startMilliseconds);

  /// The duration of time between sending a completion request and receiving a
  /// completion response, not including the time to decode the response.
  int get requestResponseDuration => responseMilliseconds - startMilliseconds;
}

/// A utility class for finding and referencing paths within the Dart SDK.
class _Sdk {
  // This is copied from package:dartdev/src/sdk.dart and stripped.

  static final _Sdk _instance = _createSingleton();

  /// Path to SDK directory.
  final String sdkPath;

  factory() => _instance;

  new _(this.sdkPath);

  String get analysisServerSnapshot => path.absolute(
    sdkPath,
    'bin',
    'snapshots',
    'analysis_server.dart.snapshot',
  );

  // Assume that we want to use the same Dart executable that we used to spawn
  // DartDev. We should be able to run programs with out/ReleaseX64/dart even
  // if the SDK isn't completely built.
  String get dart => Platform.resolvedExecutable;

  static _Sdk _createSingleton() {
    // Find SDK path.

    // The common case, and how cli_util.dart computes the Dart SDK directory,
    // [path.dirname] called twice on Platform.resolvedExecutable. We confirm by
    // asserting that the directory `./bin/snapshots/` exists in this directory:
    var sdkPath = path.absolute(
      path.dirname(path.dirname(Platform.resolvedExecutable)),
    );
    var snapshotsDir = path.join(sdkPath, 'bin', 'snapshots');
    if (!Directory(snapshotsDir).existsSync()) {
      // This is the less common case where the user is in
      // the checked out Dart SDK, and is executing `dart` via:
      // ./out/ReleaseX64/dart ...
      // We confirm in a similar manner with the snapshot directory existence
      // and then return the correct sdk path:
      var altPath = path.absolute(
        path.dirname(Platform.resolvedExecutable),
        'dart-sdk',
      );
      var snapshotsDir = path.join(altPath, 'bin', 'snapshots');
      if (Directory(snapshotsDir).existsSync()) {
        sdkPath = altPath;
      }
      // If that snapshot dir does not exist either,
      // we use the first guess anyway.
    }

    return _Sdk._(sdkPath);
  }
}

/// A container which pairs a [CompletionGetSuggestions2Result] with the
/// [_RequestMetadata] which is associated with the result's completion request
/// and response.
class _SuggestionsData {
  final CompletionGetSuggestions2Result result;
  final _RequestMetadata metadata;

  new(this.result, this.metadata);
}
