# frozen_string_literal: true

require 'open3'
require 'timeout'

require 'command_line/result'
require 'command_line/version'

# CommandLine provides an easier way to run command-line applications.  It
# captures all outputs, can handle applications that require stdin, and can pass
# environment variables. It's also helpful for testing commmand-line
# applications.
module CommandLine
  # Top-level error class. All other errors subclass this.
  class Error < StandardError; end

  # Raised if CommandLine#command_line! is called an has an exit failure.
  class ExitFailureError < Error; end

  # Raised if a timeout is given and exceeded.
  class TimeoutError < Error; end

  module_function

  # Run a command and get back the result.
  #
  # @param command [String] The command to run.
  # @param args [Array] Any arguments passed to the command. All arguments will
  #   be converted to strings using `to_s`.
  # @param [Hash] env: Pass environment variables to use. The key is the name
  #   of the environment variable. The value is the value you want that variable
  #   to have.
  # @param [Integer, Float, nil] Number of seconds to wait for the block to
  #   terminate. Floats can be used to specify fractional seconds. A value of 0
  #   or nil will execute the block without any timeout.
  #
  # @yield [stdin] Handle any input on stdin that the command needs.
  # @yieldparam stdin [IO]
  #
  # @example
  #   command_line('echo', 'hello')
  #
  # @example
  #   command_line('command_expecting_input') do |stdin|
  #     stdin.puts "first input"
  #     stdin.puts "second input"
  #   end
  #
  # @example
  #   command_line('some_webserver', env: { PORT: '80' })
  #
  # @return [Result]
  def command_line(command, *args, env: {}, timeout: nil)
    stdout = ''
    stderr = ''
    status = nil

    full_command = [command, *args].map(&:to_s).join(' ')
    environment = env.transform_keys(&:to_s)
    Open3.popen3(environment, full_command) do |i, o, e, wait_thr|
      threads = []

      Timeout.timeout(timeout, TimeoutError) do
        yield i if block_given?

        threads << Thread.new { stdout = o.read }
        threads << Thread.new { stderr = e.read }
        threads.each(&:join)
        status = wait_thr.value
      end
    rescue TimeoutError => e
      threads.map(&:kill)

      raise e
    end

    Result.new(stdout, stderr, status)
  end

  # Same as {CommandLine.command_line} except that a failure on exit raises an
  # error.
  #
  # @see CommandLine.command_line
  #
  # @example
  #   command_line!('echo', 'hello')
  #
  # @example
  #   command_line!('grep')
  #   # => CommandLine::ExitFailureError (usage: grep ...
  #
  # @return [Result]
  # @raise [CommandLine::ExitFailureError] If the application exits with an
  #   error status. The message will be the contents of Result#stderr.
  def command_line!(*args, **kwargs, &block)
    command_line(*args, **kwargs, &block).tap do |result|
      raise ExitFailureError, result.stderr if result.failure?
    end
  end
end
