// Copyright (c) 2015, 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.

/// An example of using the libraries provided by `package:vm_service`.
library;

import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';

import 'package:path/path.dart' as path;
import 'package:test/test.dart';
import 'package:vm_service/vm_service.dart';
import 'package:vm_service/vm_service_io.dart';

const String host = 'localhost';
const int port = 7575;

late VmService serviceClient;

void main() {
  Process? process;

  tearDown(() {
    process?.kill();
  });

  test('integration', () async {
    final sdkPath = path.dirname(path.dirname(Platform.resolvedExecutable));
    print('Using sdk at $sdkPath.');

    // pause_isolates_on_start, pause_isolates_on_exit
    final sampleProcess = process = await Process.start(
      Platform.resolvedExecutable,
      [
        '--pause-isolates-on-start',
        '--enable-vm-service=$port',
        '--disable-service-auth-codes',
        'example/sample_main.dart',
      ],
    );

    print('Dart process started.');

    unawaited(sampleProcess.exitCode.then((code) => print('vm exited: $code')));
    sampleProcess.stdout.transform(utf8.decoder).listen(print);
    sampleProcess.stderr.transform(utf8.decoder).listen(print);

    await Future.delayed(const Duration(milliseconds: 500));

    final wsUri = Uri(scheme: 'ws', host: host, port: port, path: 'ws');
    serviceClient = await vmServiceConnectUri(
      wsUri.toString(),
      log: StdoutLog(),
    );

    print('VM service web socket connected.');

    serviceClient.onSend.listen((str) => print('--> $str'));

    // The next listener will bail out if you toggle this to false, which is
    // needed for some things like the custom service registration tests.
    var checkResponseJsonCompatibility = true;
    serviceClient.onReceive.listen((str) {
      print('<-- $str');

      if (!checkResponseJsonCompatibility) return;

      // For each received event, check that we can deserialize it and
      // reserialize it back to the same exact representation (minus private
      // fields).
      final json = jsonDecode(str);
      var originalJson = json['result'] as Map<String, dynamic>?;
      if (originalJson == null && json['method'] == 'streamNotify') {
        originalJson = json['params']['event'];
      }
      expect(originalJson, isNotNull, reason: 'Unrecognized event type! $json');

      final instance =
          createServiceObject(originalJson!, const ['Event', 'Success']);
      expect(instance, isNotNull,
          reason: 'Failed to deserialize object $originalJson!');

      final reserializedJson = (instance as dynamic).toJson();

      forEachNestedMap(originalJson, (obj) {
        // Remove private fields that we don't reproduce.
        obj.removeWhere((k, v) => k.startsWith('_'));

        // Remove extra fields that aren't specified and we don't reproduce.
        obj.remove('isExport');
        obj.remove('isolate_group');
        obj.remove('parameterizedClass');

        // Convert `Null` instances in the original JSON to
        // just `null` as `createServiceObject` will use `null`
        // to represent the reference.
        obj.updateAll((key, value) {
          if (value is Map &&
              value['type'] == '@Instance' &&
              value['kind'] == 'Null') {
            return null;
          } else {
            return value;
          }
        });
      });

      forEachNestedMap(reserializedJson, (obj) {
        // We provide explicit defaults for these, need to remove them.
        obj.remove('valueAsStringIsTruncated');
      });

      expect(reserializedJson, equals(originalJson));
    });

    serviceClient.onIsolateEvent.listen((e) => print('onIsolateEvent: $e'));
    serviceClient.onDebugEvent.listen((e) => print('onDebugEvent: $e'));
    serviceClient.onGCEvent.listen((e) => print('onGCEvent: $e'));
    serviceClient.onStdoutEvent.listen((e) => print('onStdoutEvent: $e'));
    serviceClient.onStderrEvent.listen((e) => print('onStderrEvent: $e'));

    unawaited(serviceClient.streamListen(EventStreams.kIsolate));
    unawaited(serviceClient.streamListen(EventStreams.kDebug));
    unawaited(serviceClient.streamListen(EventStreams.kStdout));

    final vm = await serviceClient.getVM();
    print('hostCPU=${vm.hostCPU}');
    print(await serviceClient.getVersion());
    final isolates = vm.isolates!;
    print(isolates);

    // Disable the json reserialization checks since custom services are
    // not supported.
    checkResponseJsonCompatibility = false;
    await testServiceRegistration();
    checkResponseJsonCompatibility = true;

    await testScriptParse(vm.isolates!.first);
    await testSourceReport(vm.isolates!.first);

    final isolateRef = isolates.first;
    print(await serviceClient.resume(isolateRef.id!));

    print('Waiting for service client to shut down...');
    await serviceClient.dispose();

    await serviceClient.onDone;
    print('Service client shut down.');
  });
}

/// Deeply traverses the [input] map and calls [cb] with
/// each nested map and the parent map.
void forEachNestedMap(Map input, void Function(Map) cb) {
  final queue = Queue.from([input]);
  while (queue.isNotEmpty) {
    final next = queue.removeFirst();
    if (next is Map) {
      cb(next);
      queue.addAll(next.values);
    } else if (next is List) {
      queue.addAll(next);
    }
  }
}

Future<void> testServiceRegistration() async {
  const String serviceName = 'serviceName';
  const String serviceAlias = 'serviceAlias';
  const String movedValue = 'movedValue';
  serviceClient.registerServiceCallback(serviceName,
      (Map<String, dynamic> params) async {
    assert(params['input'] == movedValue);
    return <String, dynamic>{
      'result': {'output': params['input']}
    };
  });
  await serviceClient.registerService(serviceName, serviceAlias);
  final wsUri = Uri(scheme: 'ws', host: host, port: port, path: 'ws');
  final otherClient = await vmServiceConnectUri(
    wsUri.toString(),
    log: StdoutLog(),
  );
  final completer = Completer();
  otherClient.onEvent('Service').listen((e) async {
    if (e.service == serviceName && e.kind == EventKind.kServiceRegistered) {
      assert(e.alias == serviceAlias);
      final response = await serviceClient.callMethod(
        e.method!,
        args: {'input': movedValue},
      );
      assert(response.json!['output'] == movedValue);
      completer.complete();
    }
  });
  await otherClient.streamListen('Service');
  await completer.future;
  await otherClient.dispose();
}

Future<void> testScriptParse(IsolateRef isolateRef) async {
  final isolateId = isolateRef.id!;
  final isolate = await serviceClient.getIsolate(isolateId);
  final rootLibrary =
      await serviceClient.getObject(isolateId, isolate.rootLib!.id!) as Library;
  final scriptRef = rootLibrary.scripts!.first;

  final script =
      await serviceClient.getObject(isolateId, scriptRef.id!) as Script;
  print(script);
  print(script.uri);
  print(script.library);
  print(script.source!.length);
  print(script.tokenPosTable!.length);
}

Future<void> testSourceReport(IsolateRef isolateRef) async {
  final isolateId = isolateRef.id!;
  final isolate = await serviceClient.getIsolate(isolateId);
  final rootLibrary =
      await serviceClient.getObject(isolateId, isolate.rootLib!.id!) as Library;
  final scriptRef = rootLibrary.scripts!.first;

  // Make sure that some code has run.
  await serviceClient.resume(isolateId);
  await Future.delayed(const Duration(milliseconds: 25));

  final sourceReport = await serviceClient.getSourceReport(
    isolateId,
    [SourceReportKind.kCoverage],
    scriptId: scriptRef.id,
  );
  for (final range in sourceReport.ranges!) {
    print('  $range');
    if (range.coverage != null) {
      print('  ${range.coverage}');
    }
  }
  print(sourceReport);
}

class StdoutLog extends Log {
  @override
  void warning(String message) => print(message);

  @override
  void severe(String message) => print(message);
}
