// Copyright (c) 2021, 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 'dart:math' as math;

import 'package:vm_service/vm_service.dart' as vm;

import '../stream_transformers.dart';
import '../utils.dart';
import 'dart.dart';
import 'mixins.dart';

/// A DAP Debug Adapter for running and debugging Dart test scripts.
class DartTestDebugAdapter extends DartDebugAdapter<DartLaunchRequestArguments,
        DartAttachRequestArguments>
    with PidTracker, VmServiceInfoFileUtils, PackageConfigUtils, TestAdapter {
  Process? _process;

  @override
  final parseLaunchArgs = DartLaunchRequestArguments.fromJson;

  @override
  final parseAttachArgs = DartAttachRequestArguments.fromJson;

  DartTestDebugAdapter(
    super.channel, {
    super.ipv6,
    super.logger,
    super.onError,
  });

  /// Whether the VM Service closing should be used as a signal to terminate the
  /// debug session.
  ///
  /// Since we do not support attaching for tests, this is always false.
  @override
  bool get terminateOnVmServiceClose => false;

  @override
  Future<void> debuggerConnected(vm.VM vmInfo) async {
    // Capture the PID from the VM Service so that we can terminate it when
    // cleaning up. Terminating the process might not be enough as it could be
    // just a shell script (e.g. pub on Windows) and may not pass the
    // signal on correctly.
    // See: https://github.com/Dart-Code/Dart-Code/issues/907
    final pid = vmInfo.pid;
    if (pid != null) {
      pidsToTerminate.add(pid);
    }
  }

  /// Called by [disconnectRequest] to request that we forcefully shut down the
  /// app being run (or in the case of an attach, disconnect).
  @override
  Future<void> disconnectImpl() async {
    terminatePids(ProcessSignal.sigkill);
  }

  /// Called by [launchRequest] to request that we actually start the app to be
  /// run/debugged.
  ///
  /// For debugging, this should start paused, connect to the VM Service, set
  /// breakpoints, and resume.
  @override
  Future<void> launchImpl() async {
    final args = this.args as DartLaunchRequestArguments;
    File? vmServiceInfoFile;

    final debug = !(args.noDebug ?? false);
    if (debug) {
      vmServiceInfoFile = generateVmServiceInfoFile();
      unawaited(waitForVmServiceInfoFile(logger, vmServiceInfoFile)
          .then((uri) => connectDebugger(uri)));
    }

    var vmArgs = <String>[
      ...?args.vmAdditionalArgs,
      if (debug) ...[
        '--enable-vm-service=${args.vmServicePort ?? 0}${ipv6 ? '/::1' : ''}',
      ],
      if (debug && vmServiceInfoFile != null) ...[
        '-DSILENT_VM_SERVICE=true',
        '--write-service-info=${Uri.file(vmServiceInfoFile.path)}'
      ],
    ];

    final toolArgs = args.toolArgs ?? [];
    if (debug) {
      // If the user has explicitly set pause-isolates-on-start we need to
      // not add it ourselves, and specify that we didn't set it.
      if (containsVmFlag(toolArgs, '--pause_isolates_on_start') ||
          containsVmFlag(vmArgs, '--pause_isolates_on_start')) {
        pauseIsolatesOnStartSetByDap = false;
      } else {
        vmArgs.add('--pause_isolates_on_start');
      }
    }

    vmArgs = [
      ...vmArgs,
      // TODO(dantup): This should be changed from "dart run test:test" to
      // "dart test" once the start-paused flags are working correctly.
      // Currently they start paused but do not write the vm-service-info file
      // to give us the VM-service URI.
      // https://github.com/dart-lang/sdk/issues/44200#issuecomment-726869539
      // We should also ensure DDS is disabled (this may need a new flag since
      // we can't disable-dart-dev to get "dart test") and devtools is not
      // served.
      'run',
      '--no-serve-devtools',
      'test:test',
      '-r',
      'json',
    ];

    // Handle customTool and deletion of any arguments for it.
    final executable = normalizePath(
      args.customTool ?? Platform.resolvedExecutable,
    );
    final removeArgs = args.customToolReplacesArgs;
    if (args.customTool != null && removeArgs != null) {
      vmArgs.removeRange(0, math.min(removeArgs, vmArgs.length));
    }

    final processArgs = [
      ...vmArgs,
      ...?args.toolArgs,
      normalizePath(args.program),
      ...?args.args,
    ];

    var cwd = args.cwd;
    if (cwd != null) {
      cwd = normalizePath(cwd);
    }

    await launchAsProcess(
      executable,
      processArgs,
      workingDirectory: cwd,
      env: args.env,
    );
  }

  /// Launches the test script as a process controlled by the debug adapter.
  Future<void> launchAsProcess(
    String executable,
    List<String> processArgs, {
    required String? workingDirectory,
    required Map<String, String>? env,
  }) async {
    logger?.call('Spawning $executable with $processArgs in $workingDirectory');
    final process = await Process.start(
      executable,
      processArgs,
      workingDirectory: workingDirectory,
      environment: env,
    );
    _process = process;
    pidsToTerminate.add(process.pid);

    process.stdout.transform(ByteToLineTransformer()).listen(_handleStdout);
    process.stderr.listen(_handleStderr);
    unawaited(process.exitCode.then(_handleExitCode));
  }

  /// Called by [attachRequest] to request that we actually connect to the app
  /// to be debugged.
  @override
  Future<void> attachImpl() async {
    sendConsoleOutput('Attach is not supported for test runs');
    handleSessionTerminate();
  }

  /// Called by [terminateRequest] to request that we gracefully shut down the
  /// app being run (or in the case of an attach, disconnect).
  @override
  Future<void> terminateImpl() async {
    terminatePids(ProcessSignal.sigterm);

    // Sending a kill signal to pkg:test doesn't cause it to exit immediately,
    // instead it waits for the current test to complete. If the user is at
    // a breakpoint this will never happen, which will encourage them to click
    // Stop again. In VS Code, a second Stop is a non-graceful shutdown which
    // may leave orphaned processes. To avoid this, attempt to resume and avoid
    // any further pausing.
    await preventBreakingAndResume();

    await _process?.exitCode;
  }

  void _handleExitCode(int code) {
    final codeSuffix = code == 0 ? '' : ' ($code)';
    logger?.call('Process exited ($code)');
    handleSessionTerminate(codeSuffix);
  }

  void _handleStderr(List<int> data) {
    sendOutput('stderr', utf8.decode(data));
  }

  void _handleStdout(String data) {
    // Output to stdout is expected to be JSON from the test runner. If we
    // get non-JSON output we will just pass it through to the front-end so it
    // shows up in the client and can be seen (although we generally expect
    // package:test to have captured output and sent it in "print" events).
    try {
      final payload = jsonDecode(data);
      sendTestEvents(payload);
    } catch (e) {
      sendOutput('stdout', data);
    }
  }
}
