# SPDX-FileCopyrightText: 2020 Justin Schneck
# SPDX-FileCopyrightText: 2021 Frank Hunleth
# SPDX-FileCopyrightText: 2022 Jon Carstens
#
# SPDX-License-Identifier: Apache-2.0
#
defmodule Nerves.Port do
  @moduledoc """
  The code from this file was copied in from MuonTrap
  https://github.com/fhunleth/muontrap
  """

  @spec exec_path() :: String.t()
  def exec_path() do
    Application.app_dir(:nerves, ["priv", "port"])
  end

  @doc """
  Run a command in a similar way to System.cmd/3.
  """
  @spec cmd(binary(), [binary()], keyword()) ::
          {Collectable.t(), exit_status :: non_neg_integer()}
  def cmd(command, args, opts \\ []) when is_binary(command) and is_list(args) do
    exec_path()
    |> File.exists?()
    |> do_cmd(command, args, opts)
  end

  # Port process was compiled
  defp do_cmd(true, command, args, opts) do
    options = validate(command, args, opts)
    opts = port_options(options)
    {initial, fun} = Collectable.into(options.into)

    try do
      do_port_cmd(Port.open({:spawn_executable, to_charlist(exec_path())}, opts), initial, fun)
    catch
      kind, reason ->
        fun.(initial, :halt)
        :erlang.raise(kind, reason, __STACKTRACE__)
    else
      {acc, status} -> {fun.(acc, :done), status}
    end
  end

  # Port process was not compiled, fail back to System.cmd/3
  defp do_cmd(false, command, args, opts) do
    System.cmd(command, args, opts)
  end

  defp do_port_cmd(port, acc, fun) do
    receive do
      {^port, {:data, data}} ->
        do_port_cmd(port, fun.(acc, {:cont, data}), fun)

      {^port, {:exit_status, status}} ->
        {acc, status}
    end
  end

  defp port_options(options) do
    [
      :use_stdio,
      :exit_status,
      :binary,
      :hide,
      {:args, nerves_args(options)} | Enum.flat_map(options, &port_option/1)
    ]
  end

  defp nerves_args(options) do
    Enum.flat_map(options, &nerves_arg/1) ++ ["--", options.cmd] ++ options.args
  end

  defp nerves_arg({:delay_to_sigkill, delay}), do: ["--delay-to-sigkill", to_string(delay)]
  defp nerves_arg({:arg0, arg0}), do: ["--arg0", arg0]
  defp nerves_arg(_other), do: []

  defp port_option({:stderr_to_stdout, true}), do: [:stderr_to_stdout]
  defp port_option({:env, env}), do: [{:env, env}]

  defp port_option({:cd, bin}), do: [{:cd, bin}]
  defp port_option({:arg0, bin}), do: [{:arg0, bin}]
  defp port_option({:parallelism, bool}), do: [{:parallelism, bool}]
  defp port_option(_other), do: []

  defp validate(cmd, args, opts) do
    assert_no_null_byte!(cmd)

    if !Enum.all?(args, &is_binary/1) do
      raise ArgumentError, "all arguments for Nerves.Port.cmd/3 must be binaries"
    end

    abs_command = System.find_executable(cmd) || :erlang.error(:enoent, [cmd, args, opts])

    validate_options(abs_command, args, opts)
  end

  defp validate_options(cmd, args, opts) do
    Enum.reduce(
      opts,
      %{cmd: cmd, args: args, into: ""},
      &validate_option/2
    )
  end

  # System.cmd/3 options
  defp validate_option({:into, what}, opts), do: Map.put(opts, :into, what)
  defp validate_option({:cd, bin}, opts) when is_binary(bin), do: Map.put(opts, :cd, bin)

  defp validate_option({:arg0, bin}, opts) when is_binary(bin),
    do: Map.put(opts, :arg0, bin)

  defp validate_option({:stderr_to_stdout, bool}, opts) when is_boolean(bool),
    do: Map.put(opts, :stderr_to_stdout, bool)

  defp validate_option({:parallelism, bool}, opts) when is_boolean(bool),
    do: Map.put(opts, :parallelism, bool)

  defp validate_option({:env, enum}, opts),
    do: Map.put(opts, :env, validate_env(enum))

  defp validate_option({:delay_to_sigkill, delay}, opts) when is_integer(delay),
    do: Map.put(opts, :delay_to_sigkill, delay)

  defp validate_option({key, val}, _opts),
    do: raise(ArgumentError, "invalid option #{inspect(key)} with value #{inspect(val)}")

  defp validate_env(enum) do
    Enum.map(enum, fn
      {k, nil} ->
        {String.to_charlist(k), false}

      {k, v} ->
        {String.to_charlist(k), String.to_charlist(v)}

      other ->
        raise ArgumentError, "invalid environment key-value #{inspect(other)}"
    end)
  end

  # Copied from Elixir's system.ex to make Nerves.Port.cmd pass System.cmd's tests
  defp assert_no_null_byte!(binary) do
    case :binary.match(binary, "\0") do
      {_, _} ->
        raise ArgumentError,
              "cannot execute Nerves.Port.cmd/3 for program with null byte, got: #{inspect(binary)}"

      :nomatch ->
        :ok
    end
  end
end
