defmodule Rambo do
  @moduledoc File.read!("#{__DIR__}/../README.md")
             |> String.split("\n")
             |> Enum.drop(2)
             |> Enum.join("\n")

  defstruct status: nil, out: "", err: ""

  @type t :: %__MODULE__{
          status: integer(),
          out: String.t(),
          err: String.t()
        }
  @type args :: String.t() | [iodata()] | nil
  @type result :: {:ok, t()} | {:error, t() | String.t()} | {:killed, t()}

  alias __MODULE__

  @doc """
  Stop by killing your command.

  Pass the `pid` of the process that called `run/1`. That process will return
  with `{:killed, %Rambo{}}` with results accumulated thus far.

  ## Example

      iex> task = Task.async(fn ->
      ...>   Rambo.run("cat")
      ...> end)
      iex> Rambo.kill(task.pid)
      iex> Task.await(task)
      {:killed, %Rambo{status: nil}}

  """
  @spec kill(pid()) :: {:killed, t()}
  def kill(pid) do
    send(pid, :kill)
  end

  @doc ~S"""
  Runs `command`.

  Executes the `command` and returns `{:ok, %Rambo{}}` or `{:error, reason}`.
  `reason` is a string if the child process failed to start, or a `%Rambo{}`
  struct if the child process started successfully but exited with a non-zero
  status.

  Multiple calls can be chained together with the `|>` pipe operator to
  simulate Unix pipes.

      Rambo.run("ls") |> Rambo.run("sort") |> Rambo.run("head")

  If any command did not exit with `0`, the rest will not be executed and the
  last executed result is returned in an `:error` tuple.

  See `run/2` or `run/3` to pass arguments or options.

  ## Examples

      iex> Rambo.run("echo")
      {:ok, %Rambo{out: "\n", status: 0, err: ""}}

  """
  @spec run(command :: String.t() | result()) :: result()
  def run(command) do
    run(command, nil, [])
  end

  @doc ~S"""
  Runs `command` with arguments or options.

  Arguments can be a string or list of strings. See `run/3` for options.

  ## Examples

      iex> Rambo.run("echo", "john")
      {:ok, %Rambo{out: "john\n", status: 0}}

      iex> Rambo.run("echo", ["-n", "john"])
      {:ok, %Rambo{out: "john", status: 0}}

      iex> Rambo.run("cat", in: "john")
      {:ok, %Rambo{out: "john", status: 0}}

  """
  @spec run(command :: String.t() | result(), args_or_opts :: args() | Keyword.t()) :: result()
  def run(command, args_or_opts) do
    case command do
      {:ok, %{status: 0, out: out}} ->
        command = args_or_opts
        run(command, in: out)

      {:error, reason} ->
        {:error, reason}

      command ->
        if Keyword.keyword?(args_or_opts) do
          run(command, nil, args_or_opts)
        else
          run(command, args_or_opts, [])
        end
    end
  end

  @doc ~S"""
  Runs `command` with arguments and options.

  ## Options

    * `:in` - pipe iodata as standard input
    * `:cd` - the directory to run the command in
    * `:env` - map or list of tuples containing environment key-value as strings
    * `:log` - stream standard output or standard error to console or a
    function. May be `:stdout`, `:stderr`, `true` for both, `false` for
    neither, or a function with one arity. If a function is given, it will be
    passed `{:stdout, output}` or `{:stderr, error}` tuples. Defaults to
    `:stderr`.
    * `:timeout` - kills command after timeout in milliseconds. Defaults to no
    timeout.

  ## Examples

      iex> Rambo.run("/bin/sh", ["-c", "echo $JOHN"], env: %{"JOHN" => "rambo"})
      {:ok, %Rambo{out: "rambo\n", status: 0}}

      iex> Rambo.run("echo", "rambo", log: &IO.inspect/1)
      {:ok, %Rambo{out: "rambo\n", status: 0}}

  """
  @spec run(command :: String.t() | result(), args :: args(), opts :: Keyword.t()) :: result()
  def run(command, args, opts) do
    case command do
      {:ok, %{out: out}} ->
        command = args
        args_or_opts = opts

        if Keyword.keyword?(args_or_opts) do
          run(command, nil, [in: out] ++ args_or_opts)
        else
          run(command, args_or_opts, in: out)
        end

      {:error, reason} ->
        {:error, reason}

      command when byte_size(command) > 0 ->
        {stdin, opts} = Keyword.pop(opts, :in)
        {envs, opts} = Keyword.pop(opts, :env)
        {current_dir, opts} = Keyword.pop(opts, :cd)
        {log, opts} = Keyword.pop(opts, :log, :stderr)
        {timeout, _opts} = Keyword.pop(opts, :timeout)

        log =
          case log do
            log when is_function(log) -> log
            true -> [:stdout, :stderr]
            log -> [log]
          end

        rambo = Mix.Tasks.Compile.Rambo.find_rambo()
        port = Port.open({:spawn, rambo}, [:binary, :exit_status, {:packet, 4}])
        send_command(port, command)

        if args, do: send_arguments(port, args)
        if stdin, do: send_stdin(port, stdin)
        if envs, do: send_envs(port, envs)
        if current_dir, do: send_current_dir(port, current_dir)

        timer_ref =
          if is_integer(timeout) do
            Process.send_after(self(), :kill, timeout)
          end

        run_command(port)

        port
        |> receive_result(%Rambo{}, log)
        |> cancel_timer(timer_ref)
        |> output_to_binary()

      command ->
        raise ArgumentError, message: "invalid command '#{inspect(command)}'"
    end
  end

  @doc false
  @spec run(result :: result(), command :: String.t(), args :: args(), opts :: Keyword.t()) ::
          result()
  def run(result, command, args, opts) do
    case result do
      {:ok, %{out: out}} -> run(command, args, [in: out] ++ opts)
      {:error, reason} -> {:error, reason}
    end
  end

  @messages [
    :command,
    :arg,
    :stdin,
    :env,
    :current_dir,
    :eot,
    :error,
    :stdout,
    :stderr,
    :exit_status
  ]

  for {message, index} <- Enum.with_index(@messages) do
    Module.put_attribute(__MODULE__, message, <<index>>)
  end

  defp send_command(port, command) do
    Port.command(port, [@command, command])
  end

  defp send_arguments(port, args) when is_list(args) do
    for arg <- args do
      send_arguments(port, arg)
    end
  end

  defp send_arguments(port, arg) when is_binary(arg) do
    Port.command(port, [@arg, arg])
  end

  defp send_stdin(port, stdin) do
    Port.command(port, [@stdin, stdin])
  end

  defp send_envs(port, envs) do
    for {name, value} <- envs do
      Port.command(port, [@env, <<byte_size(name)::32>>, name, value])
    end
  end

  defp send_current_dir(port, current_dir) do
    Port.command(port, [@current_dir, current_dir])
  end

  defp run_command(port) do
    Port.command(port, @eot)
  end

  defp receive_result(port, result, log) do
    receive do
      {^port, {:data, @error <> message}} ->
        Port.close(port)
        {:error, message}

      {^port, {:data, @stdout <> stdout}} ->
        maybe_log(:stdout, stdout, log)
        result = Map.update(result, :out, [], &[&1 | stdout])
        receive_result(port, result, log)

      {^port, {:data, @stderr <> stderr}} ->
        maybe_log(:stderr, stderr, log)
        result = Map.update(result, :err, [], &[&1 | stderr])
        receive_result(port, result, log)

      {^port, {:data, @exit_status <> <<exit_status::32>>}} ->
        result = Map.put(result, :status, exit_status)
        receive_result(port, result, log)

      {^port, {:data, @eot}} ->
        Port.close(port)

        if result.status == 0 do
          {:ok, result}
        else
          {:error, result}
        end

      {^port, {:exit_status, exit_status}} ->
        {:error, "rambo exited with #{exit_status}"}

      :kill ->
        Port.close(port)
        {:killed, result}
    end
  end

  defp maybe_log(to, output, log) when is_function(log) do
    log.({to, output})
  end

  defp maybe_log(to, output, log) do
    if to in log do
      device =
        case to do
          :stdout -> :stdio
          :stderr -> :stderr
        end

      IO.binwrite(device, output)
    end
  end

  defp output_to_binary({reason, %Rambo{out: out, err: err} = result}) do
    {reason, %{result | out: to_binary(out), err: to_binary(err)}}
  end

  defp output_to_binary(result) do
    result
  end

  defp to_binary(iodata) when is_list(iodata) do
    IO.iodata_to_binary(iodata)
  end

  defp to_binary(output) do
    output
  end

  defp cancel_timer(result, nil), do: result

  defp cancel_timer(result, timer_ref) do
    Process.cancel_timer(timer_ref)
    result
  end
end
