// Copyright (c) 2017, 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:convert';
import 'dart:io';

import 'package:async/async.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';

/// A wrapper for [Process] that provides a convenient API for testing its
/// standard IO and interacting with it from a test.
///
/// If the test fails, this will automatically print out any stdout and stderr
/// from the process to aid debugging.
///
/// This may be extended to provide custom implementations of [stdoutStream] and
/// [stderrStream]. These will automatically be picked up by the [stdout] and
/// [stderr] queues, but the debug log will still contain the original output.
class TestProcess {
  /// The underlying process.
  final Process _process;

  /// A human-friendly description of this process.
  final String description;

  /// A [StreamQueue] that emits each line of stdout from the process.
  ///
  /// A copy of the underlying stream can be retrieved using [stdoutStream].
  late final StreamQueue<String> stdout = StreamQueue(stdoutStream());

  /// A [StreamQueue] that emits each line of stderr from the process.
  ///
  /// A copy of the underlying stream can be retrieved using [stderrStream].
  late final StreamQueue<String> stderr = StreamQueue(stderrStream());

  /// A splitter that can emit new copies of [stdout].
  final StreamSplitter<String> _stdoutSplitter;

  /// A splitter that can emit new copies of [stderr].
  final StreamSplitter<String> _stderrSplitter;

  /// The standard input sink for this process.
  IOSink get stdin => _process.stdin;

  /// A buffer of mixed stdout and stderr lines.
  final List<String> _log = <String>[];

  /// Whether [_log] has been passed to [printOnFailure] yet.
  bool _loggedOutput = false;

  /// Returns a [Future] which completes to the exit code of the process, once
  /// it completes.
  Future<int> get exitCode => _process.exitCode;

  /// The process ID of the process.
  int get pid => _process.pid;

  /// Completes to [_process]'s exit code if it's exited, otherwise completes to
  /// `null` immediately.
  Future<int?> get _exitCodeOrNull => exitCode
      .then<int?>((value) => value)
      .timeout(Duration.zero, onTimeout: () => null);

  /// Starts a process.
  ///
  /// [executable], [arguments], [workingDirectory], and [environment] have the
  /// same meaning as for [Process.start].
  ///
  /// [description] is a string description of this process; it defaults to the
  /// command-line invocation. [encoding] is the [Encoding] that will be used
  /// for the process's input and output; it defaults to [utf8].
  ///
  /// If [forwardStdio] is `true`, the process's stdout and stderr will be
  /// printed to the console as they appear. This is only intended to be set
  /// temporarily to help when debugging test failures.
  static Future<TestProcess> start(
      String executable, Iterable<String> arguments,
      {String? workingDirectory,
      Map<String, String>? environment,
      bool includeParentEnvironment = true,
      bool runInShell = false,
      String? description,
      Encoding encoding = utf8,
      bool forwardStdio = false}) async {
    var process = await Process.start(executable, arguments.toList(),
        workingDirectory: workingDirectory,
        environment: environment,
        includeParentEnvironment: includeParentEnvironment,
        runInShell: runInShell);

    if (description == null) {
      var humanExecutable = p.isWithin(p.current, executable)
          ? p.relative(executable)
          : executable;
      description = "$humanExecutable ${arguments.join(" ")}";
    }

    return TestProcess(process, description,
        encoding: encoding, forwardStdio: forwardStdio);
  }

  /// Creates a [TestProcess] for [process].
  ///
  /// The [description], [encoding], and [forwardStdio] are the same as those to
  /// [start].
  ///
  /// This is protected, which means it should only be called by subclasses.
  @protected
  TestProcess(Process process, this.description,
      {Encoding encoding = utf8, bool forwardStdio = false})
      : _process = process,
        _stdoutSplitter = StreamSplitter(process.stdout
            .transform(encoding.decoder)
            .transform(const LineSplitter())),
        _stderrSplitter = StreamSplitter(process.stderr
            .transform(encoding.decoder)
            .transform(const LineSplitter())) {
    addTearDown(_tearDown);

    _process.exitCode.whenComplete(_logOutput);

    // Listen eagerly so that the lines are interleaved properly between the two
    // streams.
    //
    // Call [split] explicitly because we don't want to log overridden
    // [stdoutStream] or [stderrStream] output.
    _stdoutSplitter.split().listen((line) {
      _heartbeat();
      if (forwardStdio) print(line);
      _log.add('    $line');
    });

    _stderrSplitter.split().listen((line) {
      _heartbeat();
      if (forwardStdio) print(line);
      _log.add('[e] $line');
    });
  }

  /// A callback that's run when the test completes.
  Future<void> _tearDown() async {
    // If the process is already dead, do nothing.
    if (await _exitCodeOrNull != null) return;

    _process.kill(ProcessSignal.sigkill);

    // Log output now rather than waiting for the exitCode callback so that
    // it's visible even if we time out waiting for the process to die.
    await _logOutput();
  }

  /// Formats the contents of [_log] and passes them to [printOnFailure].
  Future<void> _logOutput() async {
    if (_loggedOutput) return;
    _loggedOutput = true;

    var exitCodeOrNull = await _exitCodeOrNull;

    // Wait a timer tick to ensure that all available lines have been flushed to
    // [_log].
    await Future<void>.delayed(Duration.zero);

    var buffer = StringBuffer();
    buffer.write('Process `$description` ');
    if (exitCodeOrNull == null) {
      buffer.writeln('was killed with SIGKILL in a tear-down. Output:');
    } else {
      buffer.writeln('exited with exitCode $exitCodeOrNull. Output:');
    }

    buffer.writeln(_log.join('\n'));
    printOnFailure(buffer.toString());
  }

  /// Returns a copy of [stdout] as a single-subscriber stream.
  ///
  /// Each time this is called, it will return a separate copy that will start
  /// from the beginning of the process.
  ///
  /// This can be overridden by subclasses to return a derived standard output
  /// stream. This stream will then be used for [stdout].
  Stream<String> stdoutStream() => _stdoutSplitter.split();

  /// Returns a copy of [stderr] as a single-subscriber stream.
  ///
  /// Each time this is called, it will return a separate copy that will start
  /// from the beginning of the process.
  ///
  /// This can be overridden by subclasses to return a derived standard output
  /// stream. This stream will then be used for [stderr].
  Stream<String> stderrStream() => _stderrSplitter.split();

  /// Sends [signal] to the process.
  ///
  /// This is meant for sending specific signals. If you just want to kill the
  /// process, use [kill] instead.
  ///
  /// Throws an [UnsupportedError] on Windows.
  void signal(ProcessSignal signal) {
    if (Platform.isWindows) {
      throw UnsupportedError(
          "TestProcess.signal() isn't supported on Windows.");
    }

    _process.kill(signal);
  }

  /// Kills the process (with SIGKILL on POSIX operating systems), and returns a
  /// future that completes once it's dead.
  ///
  /// If this is called after the process is already dead, it does nothing.
  Future<void> kill() async {
    _process.kill(ProcessSignal.sigkill);
    await exitCode;
  }

  /// Waits for the process to exit, and verifies that the exit code matches
  /// [expectedExitCode] (if given).
  ///
  /// If this is called after the process is already dead, it verifies its
  /// existing exit code.
  Future<void> shouldExit([Object? expectedExitCode]) async {
    var exitCode = await this.exitCode;
    if (expectedExitCode == null) return;
    expect(exitCode, expectedExitCode,
        reason: 'Process `$description` had an unexpected exit code.');
  }

  /// Signal to the test runner that the test is still making progress and
  /// shouldn't time out.
  void _heartbeat() {
    // Interacting with the test runner's asynchronous expectation logic will
    // notify it that the test is alive.
    expectAsync0(() {})();
  }
}
