///|
/// Namespace for executing `Cmd` and `Pipeline` values.
///
/// Constructing a command does not execute it. Execution only happens through
/// these methods, which delegate each process to `moonbitlang/async/process`.
pub enum Process {}

///|
/// Execute one command and capture stdout and stderr.
///
/// Standard input is closed by default. Use `Cmd::stdin` to supply text.
/// Captured stdout and stderr share the `max_output_bytes` limit (8 MiB by
/// default). When `timeout` expires, the direct child is killed immediately;
/// descendants require enforcement by the host's native process sandbox.
pub async fn Process::run(
  cmd : Cmd,
  timeout? : Int,
  max_output_bytes? : Int = 8 * 1024 * 1024,
) -> Output {
  validate_cmd(cmd)
  validate_output_limit(max_output_bytes)
  let pipeline = Pipeline::new([cmd])
  match timeout {
    None => run_pipeline(pipeline, max_output_bytes)
    Some(milliseconds) =>
      @async.with_timeout(milliseconds, () => {
        run_pipeline(pipeline, max_output_bytes)
      })
  }
}

///|
/// Execute a pipeline and capture its final stdout and every stage's stderr.
///
/// `Pipeline` uses real operating-system pipes. All stages run concurrently
/// under one structured-concurrency scope. When `timeout` is present, the whole
/// pipeline is cancelled if it does not finish within that many milliseconds.
/// The direct child for each stage is killed immediately on cancellation;
/// descendants require enforcement by the host's native process sandbox.
pub async fn Process::pipeline(
  pipeline : Pipeline,
  timeout? : Int,
  max_output_bytes? : Int = 8 * 1024 * 1024,
) -> Output {
  if pipeline.commands_.is_empty() {
    raise ProcessError::EmptyPipeline
  }
  validate_output_limit(max_output_bytes)
  for index, cmd in pipeline.commands_ {
    validate_cmd(cmd)
    if index > 0 && cmd.stdin_ is Some(_) {
      raise StdinOnNonFirstStage(index)
    }
  }
  match timeout {
    None => run_pipeline(pipeline, max_output_bytes)
    Some(milliseconds) =>
      @async.with_timeout(milliseconds, () => {
        run_pipeline(pipeline, max_output_bytes)
      })
  }
}

///|
fn validate_cmd(cmd : Cmd) -> Unit raise ProcessError {
  if cmd.program_.is_empty() {
    raise EmptyProgram
  }
  if cmd.program_.contains("\u{0000}") {
    raise NulByte("program")
  }
  for index, argument in cmd.arguments_ {
    if argument.contains("\u{0000}") {
      raise NulByte("argument[\{index}]")
    }
  }
  match cmd.cwd_ {
    Some(directory) if directory.contains("\u{0000}") =>
      raise NulByte("working directory")
    _ => ()
  }
  for name, value in cmd.environment_ {
    if name.contains("\u{0000}") {
      raise NulByte("environment name")
    }
    if value.contains("\u{0000}") {
      raise NulByte("environment value for \{name}")
    }
  }
}

///|
fn validate_output_limit(limit : Int) -> Unit raise ProcessError {
  if limit <= 0 {
    raise InvalidOutputLimit(limit)
  }
}

///|
async fn[X] spawn_stage(
  group : @async.TaskGroup[X],
  cmd : Cmd,
  stdin : @pipe.PipeRead,
  stdout : @pipe.PipeWrite,
  stderr : @pipe.PipeWrite,
) -> @process.Process {
  @process.spawn(
    group,
    cmd.program_,
    cmd.arguments_,
    extra_env=cmd.environment_,
    inherit_env=cmd.inherit_environment_,
    stdin~,
    stdout~,
    stderr~,
    cwd?=cmd.cwd_.map(directory => directory[:]),
    no_console_window=cmd.no_console_window_,
    cancel_handler=@process.hard_cancel(),
  )
}

///|
fn pipefail_exit_code(exit_codes : ArrayView[Int]) -> Int {
  let mut result = 0
  for code in exit_codes {
    if code != 0 {
      result = code
    }
  }
  result
}

///|
fn close_pipes(pipes : Array[(@pipe.PipeRead, @pipe.PipeWrite)]) -> Unit {
  for pair in pipes {
    pair.0.close()
    pair.1.close()
  }
}

///|
async fn read_bounded(
  reader : @pipe.PipeRead,
  captured : Ref[Int],
  limit : Int,
  stream : String,
) -> Bytes {
  let buffer = @buffer.Buffer(size_hint=4096)
  for ;; {
    let remaining = limit - captured.val
    let chunk_size = if remaining < 4096 { remaining + 1 } else { 4096 }
    match reader.read_some(max_len=chunk_size) {
      None => break
      Some(chunk) => {
        let next_total = captured.val + chunk.length()
        if next_total > limit {
          raise OutputLimitExceeded(stream~, limit~)
        }
        captured.val = next_total
        buffer.write_bytes(chunk)
      }
    }
  }
  buffer.to_bytes()
}

///|
async fn run_pipeline(pipeline : Pipeline, max_output_bytes : Int) -> Output {
  let commands = pipeline.commands_
  let count = commands.length()
  let links : Array[(@pipe.PipeRead, @pipe.PipeWrite)] = []
  defer close_pipes(links)
  for _ in 0..<(count - 1) {
    links.push(@pipe.pipe())
  }
  let stderr_pipes : Array[(@pipe.PipeRead, @pipe.PipeWrite)] = []
  defer close_pipes(stderr_pipes)
  for _ in 0.. {
    let captured = Ref(0)
    let stdout_task = group.spawn(() => {
      defer final_stdout_reader.close()
      read_bounded(final_stdout_reader, captured, max_output_bytes, "stdout")
    })
    let stderr_tasks : Array[@async.Task[Bytes]] = []
    for pair in stderr_pipes {
      let (reader, _) = pair
      stderr_tasks.push(
        group.spawn(() => {
          defer reader.close()
          read_bounded(reader, captured, max_output_bytes, "stderr")
        }),
      )
    }
    group.spawn_bg() <| () => {
      defer first_stdin_writer.close()
      match commands[0].stdin_ {
        Some(input) => first_stdin_writer.write(input)
        None => ()
      }
    }
    let processes : Array[@process.Process] = []
    for index, cmd in commands {
      let stdin = if index == 0 {
        first_stdin_reader
      } else {
        links[index - 1].0
      }
      let stdout = if index + 1 == count {
        final_stdout_writer
      } else {
        links[index].1
      }
      let stderr = stderr_pipes[index].1
      let child = spawn_stage(group, cmd, stdin, stdout, stderr)
      processes.push(child)
      stdin.close()
      stdout.close()
      stderr.close()
    }
    let exit_codes : Array[Int] = []
    for child in processes {
      exit_codes.push(child.wait())
    }
    let stdout_bytes = stdout_task.wait()
    let stage_stderr_bytes : Array[Bytes] = []
    let stage_stderr : Array[String] = []
    let stderr_bytes = @buffer.Buffer()
    let stderr = StringBuilder()
    for task in stderr_tasks {
      let stage_bytes = task.wait()
      let stage = @utf8.decode_lossy(stage_bytes)
      stage_stderr_bytes.push(stage_bytes)
      stage_stderr.push(stage)
      stderr_bytes.write_bytes(stage_bytes)
      stderr.write_string(stage)
    }
    {
      exit_code: pipefail_exit_code(exit_codes),
      stage_exit_codes: exit_codes,
      stdout_bytes,
      stdout: @utf8.decode_lossy(stdout_bytes),
      stderr_bytes: stderr_bytes.to_bytes(),
      stderr: stderr.to_string(),
      stage_stderr_bytes,
      stage_stderr,
    }
  }
}