defmodule OsCmd.Port do
  @moduledoc false

  import NimbleParsec
  @behaviour OsCmd.Program

  def start(command, opts) do
    with {:ok, cmd_args} <- normalize_command(command),
         {terminate_cmd, opts} = Keyword.pop(opts, :terminate_cmd, ""),
         {:ok, terminate_cmd_parts} <- normalize_command(terminate_cmd),
         args =
           args(opts)
           |> Enum.concat(Enum.flat_map(terminate_cmd_parts, &["-terminate-cmd-part", &1]))
           |> Enum.concat(cmd_args),
         {:ok, executable} <- executable() do
      port =
        Port.open(
          {:spawn_executable, executable},
          [
            :exit_status,
            :binary,
            packet: 4,
            args: args
          ] ++ Keyword.take(opts, ~w/env/a)
        )

      receive do
        {^port, {:data, "started"}} ->
          {:ok, port}

        {^port, {:data, "not started " <> error}} ->
          Port.close(port)
          {:error, %OsCmd.Error{message: "#{Enum.join(cmd_args, " ")} failed: #{error}"}}

        {^port, {:exit_status, _exit_status}} ->
          {:error,
           %OsCmd.Error{message: "#{Enum.join(cmd_args, " ")} failed: unexpected port exit"}}
      end
    end
  end

  def executable do
    Application.app_dir(:ci, "priv")
    |> Path.join("os_cmd*")
    |> Path.wildcard()
    |> case do
      [executable] -> {:ok, executable}
      _ -> {:error, %OsCmd.Error{message: "can't find os_cmd executable"}}
    end
  end

  defp args(opts) do
    Enum.flat_map(
      opts,
      fn
        {:cd, dir} -> ["-dir", dir]
        {:pty, value} -> ["-pty=#{value}"]
        _other -> []
      end
    )
  end

  defp normalize_command(list) when is_list(list), do: {:ok, list}

  defp normalize_command(input) when is_binary(input) do
    case parse_arguments(input) do
      {:ok, args, "", _context, _line, _column} -> {:ok, args}
      {:error, reason, rest, _context, _line, _column} -> raise "#{reason}: #{rest}"
    end
  end

  whitespaces = [?\s, ?\n, ?\r, ?\t]

  unquoted = utf8_string(Enum.map(whitespaces, &{:not, &1}), min: 1)

  quoted = fn quote_char ->
    ignore(utf8_char([quote_char]))
    |> repeat(
      choice([
        ignore(utf8_char([?\\])) |> utf8_char([quote_char, ?\\]),
        utf8_char([{:not, quote_char}])
      ])
    )
    |> ignore(utf8_char([quote_char]))
    |> reduce({Kernel, :to_string, []})
  end

  arguments =
    repeat(
      ignore(repeat(utf8_char(whitespaces)))
      |> choice([
        quoted.(?"),
        quoted.(?'),
        lookahead_not(utf8_char([?", ?'])) |> concat(unquoted)
      ])
      |> ignore(repeat(utf8_char(whitespaces)))
    )
    |> eos()

  defparsecp :parse_arguments, arguments
end
