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

/// Result of one [runCommand] invocation.
///
/// When [timedOut] is true and the call was made with `killOnTimeout: false`,
/// [rc]/[stdout]/[stderr] are sentinel/empty — the real result only becomes
/// available via [backgroundFuture] once the still-running subprocess exits
/// on its own (D-Critical: a purge must never be reported as success/failure
/// before the actual process result is known).
class CommandResult {
  final int rc;
  final String stdout;
  final String stderr;
  final bool timedOut;
  final Future<CommandResult>? backgroundFuture;

  const CommandResult(
    this.rc,
    this.stdout,
    this.stderr, {
    this.timedOut = false,
    this.backgroundFuture,
  });
}

/// The only subprocess execution entry point in this codebase (D5). [argv]
/// is always an argv list handed straight to [Process.start] with shell
/// interpolation disabled, so there is no command-injection surface
/// regardless of what user input ends up inside [argv].
Future<CommandResult> runCommand(
  List<String> argv, {
  Duration? timeout,
  Map<String, String>? environment,
  String? workingDirectory,
  bool killOnTimeout = true,
}) async {
  if (argv.isEmpty) {
    throw ArgumentError.value(argv, 'argv', 'must not be empty');
  }

  final process = await Process.start(
    argv.first,
    argv.sublist(1),
    environment: environment,
    workingDirectory: workingDirectory,
    runInShell: false,
  );

  final stdoutBuffer = StringBuffer();
  final stderrBuffer = StringBuffer();
  final stdoutDone = process.stdout
      .transform(utf8.decoder)
      .listen(stdoutBuffer.write)
      .asFuture<void>();
  final stderrDone = process.stderr
      .transform(utf8.decoder)
      .listen(stderrBuffer.write)
      .asFuture<void>();

  Future<CommandResult> awaitExit() async {
    final rc = await process.exitCode;
    await stdoutDone;
    await stderrDone;
    return CommandResult(rc, stdoutBuffer.toString(), stderrBuffer.toString());
  }

  final exitFuture = awaitExit();
  if (timeout == null) {
    return exitFuture;
  }

  final timeoutMarker = Object();
  final winner = await Future.any<Object>([
    exitFuture,
    Future<Object>.delayed(timeout, () => timeoutMarker),
  ]);

  if (!identical(winner, timeoutMarker)) {
    return winner as CommandResult;
  }

  // Timed out.
  if (killOnTimeout) {
    process.kill(ProcessSignal.sigterm);
    final rc = await process.exitCode.timeout(
      const Duration(seconds: 5),
      onTimeout: () {
        process.kill(ProcessSignal.sigkill);
        return -9;
      },
    );
    await stdoutDone;
    await stderrDone;
    return CommandResult(
      rc,
      stdoutBuffer.toString(),
      stderrBuffer.toString(),
      timedOut: true,
    );
  }

  // Let the process keep running; hand back a future for its eventual result.
  return CommandResult(-1, '', '',
      timedOut: true, backgroundFuture: exitFuture);
}
