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

// NOTE: this file was originally copied from package:vm_service.

// ignore_for_file: constant_identifier_names

import 'dart:async';
import 'dart:convert';
import 'dart:io' as io;
import 'dart:isolate' as isolate;

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

import 'service_test_common.dart';

export 'service_test_common.dart' show IsolateTest, VMTest;

/// The extra arguments to use
const List<String> extraDebuggingArgs = [];

/// Will be set to the http address of the VM's service protocol before
/// any tests are invoked.
late String serviceHttpAddress;
late String serviceWebsocketAddress;

const String _TESTEE_ENV_KEY = 'SERVICE_TEST_TESTEE';
const Map<String, String> _TESTEE_SPAWN_ENV = {_TESTEE_ENV_KEY: 'true'};

late Uri remoteVmServiceUri;

/// Resolves a path as if it was relative to the `test` dir in this package.
Uri resolveTestRelativePath(String relativePath) =>
    isolate.Isolate.resolvePackageUriSync(Uri.parse('package:dds/'))!
        .resolve('../test/$relativePath');

Future<io.Process> spawnDartProcess(
  String script, {
  bool serveObservatory = true,
  bool pauseOnStart = true,
  bool disableServiceAuthCodes = false,
  bool subscribeToStdio = true,
}) async {
  final executable = io.Platform.executable;
  final tmpDir = await io.Directory.systemTemp.createTemp('dart_service');
  final serviceInfoUri = tmpDir.uri.resolve('service_info.json');
  final serviceInfoFile = await io.File.fromUri(serviceInfoUri).create();

  final arguments = [
    '--no-dds',
    '--observe=0',
    if (!serveObservatory) '--no-serve-observatory',
    if (pauseOnStart) '--pause-isolates-on-start',
    if (disableServiceAuthCodes) '--disable-service-auth-codes',
    '--write-service-info=$serviceInfoUri',
    ...io.Platform.executableArguments,
    resolveTestRelativePath(script).toFilePath(),
  ];
  final process = await io.Process.start(executable, arguments);
  if (subscribeToStdio) {
    process.stdout
        .transform(utf8.decoder)
        .listen((line) => print('TESTEE OUT: $line'));
    process.stderr
        .transform(utf8.decoder)
        .listen((line) => print('TESTEE ERR: $line'));
  }
  while ((await serviceInfoFile.length()) <= 5) {
    await Future.delayed(const Duration(milliseconds: 50));
  }
  final content = await serviceInfoFile.readAsString();
  final infoJson = json.decode(content);
  remoteVmServiceUri = Uri.parse(infoJson['uri']);
  return process;
}

Future<void> executeUntilNextPause(VmService service) async {
  final vm = await service.getVM();
  final isolate = await service.getIsolate(vm.isolates!.first.id!);

  final completer = Completer<void>();
  late StreamSubscription sub;
  sub = service.onDebugEvent.listen((event) async {
    if (event.kind == EventKind.kPauseBreakpoint) {
      completer.complete();
      await sub.cancel();
    }
  });
  await service.streamListen(EventStreams.kDebug);
  await service.resume(isolate.id!);
  await completer.future;
}

/// Returns the resolved URI to the pre-built devtools app.
Uri devtoolsAppUri() {
  return resolveTestRelativePath('../../../third_party/devtools/web');
}

bool _isTestee() {
  return io.Platform.environment.containsKey(_TESTEE_ENV_KEY);
}

Uri _getTestUri(String script) {
  if (io.Platform.script.isScheme('data')) {
    // If running from pub we can assume that we're in the root of the package
    // directory.
    return Uri.parse('test/$script');
  } else if (io.Platform.script.toFilePath().endsWith('out.aotsnapshot')) {
    // We're running an AOT test. In this case, we need to use the exact URI we
    // launched with.
    return io.Platform.script;
  } else {
    // Resolve the script to ensure that test will fail if the provided script
    // name doesn't match the actual script.
    return resolveTestRelativePath(script);
  }
}

class _ServiceTesteeRunner {
  Future<void> run({
    Function()? testeeBefore,
    Function()? testeeConcurrent,
    bool pauseOnStart = false,
    bool pauseOnExit = false,
  }) async {
    if (!pauseOnStart) {
      if (testeeBefore != null) {
        final result = testeeBefore();
        if (result is Future) {
          await result;
        }
      }
      print(''); // Print blank line to signal that testeeBefore has run.
    }
    if (testeeConcurrent != null) {
      final result = testeeConcurrent();
      if (result is Future) {
        await result;
      }
    }
    if (!pauseOnExit) {
      // Wait around for the process to be killed.
      await io.stdin.first.then((_) => io.exit(0));
    }
  }

  void runSync({
    void Function()? testeeBeforeSync,
    void Function()? testeeConcurrentSync,
    bool pauseOnStart = false,
    bool pauseOnExit = false,
  }) {
    if (!pauseOnStart) {
      if (testeeBeforeSync != null) {
        testeeBeforeSync();
      }
      print(''); // Print blank line to signal that testeeBefore has run.
    }
    if (testeeConcurrentSync != null) {
      testeeConcurrentSync();
    }
    if (!pauseOnExit) {
      // Wait around for the process to be killed.
      io.stdin.first.then((_) => io.exit(0));
    }
  }
}

class _ServiceTesteeLauncher {
  io.Process? process;
  List<String> args;

  bool killedByTester = false;
  final _exitCodeCompleter = Completer<int>();

  _ServiceTesteeLauncher(String script)
      : args = [_getTestUri(script).toFilePath()];

  Future<int> get exitCode => _exitCodeCompleter.future;

  // Spawn the testee process.
  Future<io.Process> _spawnProcess(
    bool pauseOnStart,
    bool pauseOnExit,
    bool pauseOnUnhandledExceptions,
    bool testeeControlsServer,
    bool useAuthToken,
    List<String>? experiments,
    List<String>? extraArgs,
  ) {
    return _spawnDartProcess(
      pauseOnStart,
      pauseOnExit,
      pauseOnUnhandledExceptions,
      testeeControlsServer,
      useAuthToken,
      experiments,
      extraArgs,
    );
  }

  Future<io.Process> _spawnDartProcess(
    bool pauseOnStart,
    bool pauseOnExit,
    bool pauseOnUnhandledExceptions,
    bool testeeControlsServer,
    bool useAuthToken,
    List<String>? experiments,
    List<String>? extraArgs,
  ) {
    final String dartExecutable = io.Platform.executable;

    final fullArgs = <String>[];
    if (pauseOnStart) {
      fullArgs.add('--pause-isolates-on-start');
    }
    if (pauseOnExit) {
      fullArgs.add('--pause-isolates-on-exit');
    }
    if (!useAuthToken) {
      fullArgs.add('--disable-service-auth-codes');
    }
    if (pauseOnUnhandledExceptions) {
      fullArgs.add('--pause-isolates-on-unhandled-exceptions');
    }
    fullArgs.add('--profiler');
    if (experiments != null) {
      fullArgs.addAll(experiments.map((e) => '--enable-experiment=$e'));
    }
    if (extraArgs != null) {
      fullArgs.addAll(extraArgs);
    }

    fullArgs.addAll(io.Platform.executableArguments);
    if (!testeeControlsServer) {
      fullArgs.add('--enable-vm-service:0');
    }
    fullArgs.addAll(args);
    return _spawnCommon(dartExecutable, fullArgs, <String, String>{});
  }

  Future<io.Process> _spawnCommon(
    String executable,
    List<String> arguments,
    Map<String, String> dartEnvironment,
  ) {
    final environment = _TESTEE_SPAWN_ENV;
    final bashEnvironment = StringBuffer();
    environment.forEach((k, v) => bashEnvironment.write('$k=$v '));
    dartEnvironment.forEach((k, v) {
      arguments.insert(0, '-D$k=$v');
    });
    print('** Launching $bashEnvironment$executable ${arguments.join(' ')}');
    return io.Process.start(
      executable,
      arguments,
      environment: environment,
    );
  }

  Future<Uri> launch(
    bool pauseOnStart,
    bool pauseOnExit,
    bool pauseOnUnhandledExceptions,
    bool testeeControlsServer,
    bool useAuthToken,
    List<String>? experiments,
    List<String>? extraArgs,
  ) {
    return _spawnProcess(
      pauseOnStart,
      pauseOnExit,
      pauseOnUnhandledExceptions,
      testeeControlsServer,
      useAuthToken,
      experiments,
      extraArgs,
    ).then((p) {
      final Completer<Uri> completer = Completer<Uri>();
      process = p;
      Uri? uri;
      bool blank = false;
      var first = true;
      process!.stdout
          .transform(utf8.decoder)
          .transform(LineSplitter())
          .listen((line) {
        const kDartVMServiceListening = 'The Dart VM service is listening on ';
        if (line.startsWith(kDartVMServiceListening)) {
          uri = Uri.parse(line.substring(kDartVMServiceListening.length));
        }
        if (pauseOnStart || line == '') {
          // Received blank line.
          blank = true;
        }
        if ((uri != null) && (blank == true) && (first == true)) {
          completer.complete(uri!);
          // Stop repeat completions.
          first = false;
          print('** Signaled to run test queries on $uri');
        }
        io.stdout.write('>testee>out> $line\n');
      });
      process!.stderr
          .transform(utf8.decoder)
          .transform(LineSplitter())
          .listen((line) {
        io.stdout.write('>testee>err> $line\n');
      });
      process!.exitCode.then(_exitCodeCompleter.complete);
      return completer.future;
    });
  }

  void requestExit() {
    if (process != null) {
      print('** Killing script');
      if (process!.kill()) {
        killedByTester = true;
      }
    }
  }
}

void setupAddresses(Uri /*!*/ serverAddress) {
  serviceWebsocketAddress =
      'ws://${serverAddress.authority}${serverAddress.path}ws';
  serviceHttpAddress = 'http://${serverAddress.authority}${serverAddress.path}';
}

class _ServiceTesterRunner {
  Future<void> run({
    List<String>? mainArgs,
    List<String>? extraArgs,
    List<String>? experiments,
    List<VMTest>? vmTests,
    List<IsolateTest>? isolateTests,
    required String scriptName,
    bool pauseOnStart = false,
    bool pauseOnExit = false,
    bool verboseVm = false,
    bool pauseOnUnhandledExceptions = false,
    bool testeeControlsServer = false,
    bool useAuthToken = false,
    bool allowForNonZeroExitCode = false,
    VmServiceFactory serviceFactory = VmService.defaultFactory,
  }) async {
    final process = _ServiceTesteeLauncher(scriptName);
    late VmService vm;
    late IsolateRef isolate;
    setUp(() async {
      await process
          .launch(
        pauseOnStart,
        pauseOnExit,
        pauseOnUnhandledExceptions,
        testeeControlsServer,
        useAuthToken,
        experiments,
        extraArgs,
      )
          .then((Uri serverAddress) async {
        if (mainArgs!.contains('--gdb')) {
          final pid = process.process!.pid;
          final wait = Duration(seconds: 10);
          print('Testee has pid $pid, waiting $wait before continuing');
          io.sleep(wait);
        }
        setupAddresses(serverAddress);
        vm = await vmServiceConnectUriWithFactory(
          serviceWebsocketAddress,
          vmServiceFactory: serviceFactory,
        );
        print('Done loading VM');
        isolate = await getFirstIsolate(vm);
      });
    });

    final name = _getTestUri(scriptName).pathSegments.last;

    test(
      name,
      () async {
        // Run vm tests.
        if (vmTests != null) {
          var testIndex = 1;
          final totalTests = vmTests.length;
          for (var t in vmTests) {
            print('$name [$testIndex/$totalTests]');
            await t(vm);
            testIndex++;
          }
        }

        // Run isolate tests.
        if (isolateTests != null) {
          var testIndex = 1;
          final totalTests = isolateTests.length;
          for (var t in isolateTests) {
            print('$name [$testIndex/$totalTests]');
            await t(vm, isolate);
            testIndex++;
          }
        }
      },
      retry: 0,
      timeout: Timeout.none,
    );

    tearDown(() {
      print('All service tests completed successfully.');
      process.requestExit();
    });

    final exitCode = await process.exitCode;
    if (exitCode != 0) {
      if (!(process.killedByTester || allowForNonZeroExitCode)) {
        throw 'Testee exited with unexpected exitCode: $exitCode';
      }
    }
    print('** Process exited: $exitCode');
  }

  Future<IsolateRef> getFirstIsolate(VmService service) async {
    var vm = await service.getVM();
    final vmIsolates = vm.isolates!;
    if (vmIsolates.isNotEmpty) {
      return vmIsolates.first;
    }
    Completer<dynamic>? completer = Completer();
    late StreamSubscription subscription;
    subscription = service.onIsolateEvent.listen((Event event) async {
      if (completer == null) {
        await subscription.cancel();
        return;
      }
      if (event.kind == EventKind.kIsolateRunnable) {
        vm = await service.getVM();
        await subscription.cancel();
        await service.streamCancel(EventStreams.kIsolate);
        completer!.complete(event.isolate!);
        completer = null;
      }
    });
    await service.streamListen(EventStreams.kIsolate);

    // The isolate may have started before we subscribed.
    vm = await service.getVM();
    if (vmIsolates.isNotEmpty) {
      await subscription.cancel();
      completer!.complete(vmIsolates.first);
      completer = null;
    }
    return (await completer!.future) as IsolateRef;
  }
}

/// Runs [tests] in sequence, each of which should take an [Isolate] and
/// return a [Future]. Code for setting up state can run before and/or
/// concurrently with the tests. Uses [mainArgs] to determine whether
/// to run tests or testee in this invocation of the script.
Future<void> runIsolateTests(
  List<String> mainArgs,
  List<IsolateTest> tests,
  String scriptName, {
  Function()? testeeBefore,
  Function()? testeeConcurrent,
  bool pauseOnStart = false,
  bool pauseOnExit = false,
  bool verboseVm = false,
  bool pauseOnUnhandledExceptions = false,
  bool testeeControlsServer = false,
  bool useAuthToken = false,
  bool allowForNonZeroExitCode = false,
  List<String>? experiments,
  List<String>? extraArgs,
}) async {
  assert(!pauseOnStart || testeeBefore == null);
  if (_isTestee()) {
    await _ServiceTesteeRunner().run(
      testeeBefore: testeeBefore,
      testeeConcurrent: testeeConcurrent,
      pauseOnStart: pauseOnStart,
      pauseOnExit: pauseOnExit,
    );
  } else {
    await _ServiceTesterRunner().run(
      mainArgs: mainArgs,
      scriptName: scriptName,
      extraArgs: extraArgs,
      isolateTests: tests,
      pauseOnStart: pauseOnStart,
      pauseOnExit: pauseOnExit,
      verboseVm: verboseVm,
      experiments: experiments,
      pauseOnUnhandledExceptions: pauseOnUnhandledExceptions,
      testeeControlsServer: testeeControlsServer,
      useAuthToken: useAuthToken,
      allowForNonZeroExitCode: allowForNonZeroExitCode,
    );
  }
}

/// Runs [tests] in sequence, each of which should take an [Isolate] and
/// return a [Future]. Code for setting up state can run before and/or
/// concurrently with the tests. Uses [mainArgs] to determine whether
/// to run tests or testee in this invocation of the script.
///
/// This is a special version of this test harness specifically for the
/// pause_on_unhandled_exceptions_test, which cannot properly function
/// in an async context (because exceptions are *always* handled in async
/// functions).
void runIsolateTestsSynchronous(
  List<String> mainArgs,
  List<IsolateTest> tests,
  String scriptName, {
  void Function()? testeeBefore,
  void Function()? testeeConcurrent,
  bool pauseOnStart = false,
  bool pauseOnExit = false,
  bool verboseVm = false,
  bool pauseOnUnhandledExceptions = false,
  List<String>? extraArgs,
}) {
  assert(!pauseOnStart || testeeBefore == null);
  if (_isTestee()) {
    _ServiceTesteeRunner().runSync(
      testeeBeforeSync: testeeBefore,
      testeeConcurrentSync: testeeConcurrent,
      pauseOnStart: pauseOnStart,
      pauseOnExit: pauseOnExit,
    );
  } else {
    _ServiceTesterRunner().run(
      mainArgs: mainArgs,
      scriptName: scriptName,
      extraArgs: extraArgs,
      isolateTests: tests,
      pauseOnStart: pauseOnStart,
      pauseOnExit: pauseOnExit,
      verboseVm: verboseVm,
      pauseOnUnhandledExceptions: pauseOnUnhandledExceptions,
    );
  }
}

/// Runs [tests] in sequence, each of which should take an [Isolate] and
/// return a [Future]. Code for setting up state can run before and/or
/// concurrently with the tests. Uses [mainArgs] to determine whether
/// to run tests or testee in this invocation of the script.
Future<void> runVMTests(
  List<String> mainArgs,
  List<VMTest> tests,
  String scriptName, {
  Function()? testeeBefore,
  Function()? testeeConcurrent,
  bool pauseOnStart = false,
  bool pauseOnExit = false,
  bool verboseVm = false,
  bool pauseOnUnhandledExceptions = false,
  List<String>? extraArgs,
  VmServiceFactory serviceFactory = VmService.defaultFactory,
}) async {
  if (_isTestee()) {
    await _ServiceTesteeRunner().run(
      testeeBefore: testeeBefore,
      testeeConcurrent: testeeConcurrent,
      pauseOnStart: pauseOnStart,
      pauseOnExit: pauseOnExit,
    );
  } else {
    await _ServiceTesterRunner().run(
      mainArgs: mainArgs,
      scriptName: scriptName,
      extraArgs: extraArgs,
      vmTests: tests,
      pauseOnStart: pauseOnStart,
      pauseOnExit: pauseOnExit,
      verboseVm: verboseVm,
      pauseOnUnhandledExceptions: pauseOnUnhandledExceptions,
      serviceFactory: serviceFactory,
    );
  }
}

/// Runs the given [testBody] against the [VmService] at [serviceUri].
///
/// The test will fail if connection goes away prematurely.
Future<void> withServiceConnection(
    Uri serviceUri, Future<void> Function(VmService) testBody) async {
  var disposed = false;
  final service = await vmServiceConnectUri(serviceUri.toString());
  await Future.wait([
    () async {
      try {
        await testBody(service);
      } finally {
        disposed = true;
        await service.dispose();
      }
    }(),
    service.onDone.then((_) {
      expect(disposed, isTrue);
    }),
  ], eagerError: true);
}
