// Copyright (c) 2020, 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';

class SubprocessLauncher {
  final String context;
  final Map<String, String> defaultEnvironment;

  String get prefix => context.isNotEmpty ? '$context: ' : '';

  /// Listen to [stream] as a stream of lines of text, writing them to both
  /// [output] and a returned String.
  ///
  /// This is borrowed from flutter:dev/tools/dartdoc.dart; modified.
  static Future<String> _printStream(Stream<List<int>> stream, Stdout output,
          {required Iterable<String> Function(String line) filter,
          String prefix = ''}) =>
      stream
          .transform(utf8.decoder)
          .transform(const LineSplitter())
          .expand(filter)
          .map(
        (String line) {
          final value = '${'$prefix$line'.trim()}\n';
          output.write(value);
          return value;
        },
      ).join();

  SubprocessLauncher(this.context, [this.defaultEnvironment = const {}]);

  /// A wrapper around start/await `process.exitCode` that displays the output
  /// of [executable] with [arguments] continuously and throws on non-zero exit
  /// codes.
  ///
  /// It will also parse any valid JSON objects (one per line) it encounters
  /// on stdout/stderr, and return them. Returns null if no JSON objects
  /// were encountered, or if DRY_RUN is set to 1 in the execution environment.
  // TODO(jcollins-g): refactor to return a stream of stderr/stdout lines and
  // their associated JSON objects.
  Future<Iterable<Map<String, Object?>>> runStreamed(
    String executable,
    List<String> arguments, {
    String? workingDirectory,
    Map<String, String>? environment,
    bool includeParentEnvironment = true,
    bool withStats = false,
  }) async {
    environment = {...defaultEnvironment, ...?environment};
    var jsonObjects = <Map<String, Object?>>[];

    /// Allow us to pretend we didn't pass the JSON flag in to dartdoc by
    /// printing what dartdoc would have printed without it, yet storing
    /// json objects into [jsonObjects].
    Iterable<String> jsonCallback(String line) {
      Map<String, Object?>? result;
      try {
        result = json.decoder.convert(line) as Map<String, dynamic>?;
      } on FormatException {
        // Assume invalid JSON is actually a line of normal text.
        // ignore: avoid_catching_errors
      } on TypeError catch (e, st) {
        // The convert function returns a String if there is no JSON in the
        // line.  Just ignore it and leave result null.
        print(e);
        print(st);
      }
      if (result != null) {
        jsonObjects.add(result);
        if (result.containsKey('message')) {
          line = result['message'] as String;
        } else if (result.containsKey('data')) {
          var data = result['data'] as Map;
          line = data['text'] as String;
        }
      }
      return line.split('\n');
    }

    stderr.write('$prefix+ ');
    if (workingDirectory != null) stderr.write('(cd "$workingDirectory" && ');
    if (environment.isNotEmpty) {
      stderr.write(environment.entries.map((MapEntry<String, String> entry) {
        if (entry.key.contains(_quotables)) {
          return "${entry.key}='${entry.value}'";
        } else {
          return '${entry.key}=${entry.value}';
        }
      }).join(' '));
      stderr.write(' ');
    }
    stderr.write(executable);
    if (arguments.isNotEmpty) {
      for (var arg in arguments) {
        if (arg.contains(_quotables)) {
          stderr.write(" '$arg'");
        } else {
          stderr.write(' $arg');
        }
      }
    }
    if (workingDirectory != null) stderr.write(')');
    stderr.write('\n');

    if (Platform.environment.containsKey('DRY_RUN')) return {};

    if (withStats) {
      (executable, arguments) = wrapWithTime(executable, arguments);
    }
    if (Platform.isLinux) {
      (executable, arguments) = _wrapWithStdbuf(executable, arguments);
    }

    var process = await Process.start(executable, arguments,
        workingDirectory: workingDirectory,
        environment: environment,
        includeParentEnvironment: includeParentEnvironment);
    // Stream stdout and stderr to _this_ process's stdout and stderr.
    var stdoutFuture = _printStream(process.stdout, stdout,
        prefix: prefix, filter: jsonCallback);
    var stderrFuture = _printStream(process.stderr, stderr,
        prefix: prefix, filter: jsonCallback);
    var (_, _, exitCode) =
        await (stdoutFuture, stderrFuture, process.exitCode).wait;

    if (exitCode != 0) {
      throw SubprocessException(
        command: [executable, ...arguments].join(' '),
        workingDirectory: workingDirectory,
        exitCode: exitCode,
        environment: environment,
      );
    }
    return jsonObjects;
  }

  /// Wraps [command] and [args] with a call to `stdbuf`.
  (String, List<String>) _wrapWithStdbuf(String command, List<String> args) =>
      // Use GNU coreutils to force line buffering.  This makes sure that
      // subprocesses that die due to fatal signals do not chop off the last few
      // lines of their output.
      //
      // Dart does not actually do this (seems to flush manually) unless the VM
      // crashes.
      (
        'stdbuf',
        [
          ...['-o', 'L', '-e', 'L'],
          command,
          ...args,
        ],
      );

  /// Wraps [command] and [args] with a command to `time`.
  static (String, List<String>) wrapWithTime(
          String command, List<String> args) =>
      ('/usr/bin/time', ['-l', command, ...args]);

  Future<Iterable<Map<String, Object?>>> runStreamedDartCommand(
    List<String> arguments, {
    String? workingDirectory,
    Map<String, String>? environment,
    bool includeParentEnvironment = true,
    bool withStats = false,
  }) async =>
      await runStreamed(
        Platform.executable,
        arguments,
        workingDirectory: workingDirectory,
        environment: environment,
        includeParentEnvironment: includeParentEnvironment,
        withStats: withStats,
      );

  static final _quotables = RegExp(r'[ "\r\n\$]');
}

/// An exception that represents an issue during subprocess execution.
class SubprocessException implements Exception {
  final String command;
  final String? workingDirectory;
  final int exitCode;
  final Map<String, String> environment;

  SubprocessException({
    required this.command,
    required this.workingDirectory,
    required this.environment,
    required this.exitCode,
  });

  @override
  String toString() => 'SubprocessException['
      'command: "$command", '
      'workingDirectory: "${workingDirectory ?? '(null)'}", '
      'exitCode: $exitCode, '
      'environment: $environment]';
}
