require 'timeout'
require 'shellwords'

module Tests
  class CommandRunner
    TimeoutError = Class.new(StandardError)

    def self.run(*args)
      new(*args).tap do |runner|
        yield runner
        runner.call
      end
    end

    def self.run!(*args)
      run(*args) do |runner|
        runner.run_successfully = true
        yield runner if block_given?
      end
    end

    attr_reader :status, :options, :env
    attr_accessor :command_prefix, :run_quickly, :run_successfully, :retries,
      :timeout

    def initialize(*args)
      @reader, @writer = IO.pipe
      @command_output = ''
      options = (args.last.is_a?(Hash) ? args.pop : {})
      @args = args
      @options = options.merge(
        err: [:child, :out],
        out: writer,
      )
      @env = extract_env_from(@options)

      @wrapper = -> (block) { block.call }
      @command_prefix = ''
      self.directory = Dir.pwd
      @run_quickly = false
      @run_successfully = false
      @retries = 1
      @num_times_run = 0
      @timeout = 20
    end

    def around_command(&block)
      @wrapper = block
    end

    def directory
      @options[:chdir]
    end

    def directory=(directory)
      @options[:chdir] = directory || Dir.pwd
    end

    def formatted_command
      [formatted_env, Shellwords.join(command)].reject(&:empty?).join(' ')
    end

    def call
      possibly_retrying do
        possibly_running_quickly do
          run_with_debugging

          if run_successfully && !success?
            fail!
          end
        end
      end

      self
    end

    def stop
      unless writer.closed?
        writer.close
      end
    end

    def output
      @_output ||= begin
        stop
        without_colors(command_output)
      end
    end

    def elided_output
      lines = output.split(/\n/)
      new_lines = lines[0..4]

      if lines.size > 10
        new_lines << "(...#{lines.size - 10} more lines...)"
      end

      new_lines << lines[-5..]
      new_lines.join("\n")
    end

    def success?
      status.success?
    end

    def exit_status
      status.exitstatus
    end

    def fail!
      raise <<-MESSAGE
Command #{formatted_command.inspect} exited with status #{exit_status}.
Output:
#{divider('START') + output + divider('END')}
      MESSAGE
    end

    def has_output?(expected_output)
      if expected_output.is_a?(Regexp)
        output =~ expected_output
      else
        output.include?(expected_output)
      end
    end

    protected

    attr_reader :args, :command_output, :reader, :writer, :wrapper

    private

    def extract_env_from(options)
      options.delete(:env) { {} }.inject({}) do |hash, (key, value)|
        hash[key.to_s] = value
        hash
      end
    end

    def command
      ([command_prefix] + args).flatten.flat_map do |word|
        Shellwords.split(word)
      end
    end

    def formatted_env
      env.map { |key, value| "#{key}=#{value.inspect}" }.join(' ')
    end

    def run
      pid = spawn(env, *command, options)
      t = Thread.new do
        loop do
          @command_output += reader.read_nonblock(4096)
        rescue IO::WaitReadable
          IO.select([reader])
          retry
        rescue EOFError
          break
        end
      end
      Process.waitpid(pid)
      @status = $?
    ensure
      writer.close unless writer.closed?
      t.join
    end

    def run_with_wrapper
      wrapper.call(method(:run))
    end

    def run_with_debugging
      debug { "\n\e[33mChanging to directory:\e[0m #{directory}" }
      debug { "\e[32mRunning command:\e[0m #{formatted_command}" }

      run_with_wrapper

      debug { "\n#{divider('START')}#{output}#{divider('END')}" }
    end

    def possibly_running_quickly(&block)
      if run_quickly
        begin
          Timeout.timeout(timeout, &block)
        rescue Timeout::Error
          stop

          message =
            "Command timed out after #{timeout} seconds: #{formatted_command}\n"\
            "Output:\n" +
            output

          raise TimeoutError, message
        end
      else
        yield
      end
    end

    def possibly_retrying
      @num_times_run += 1
      yield
    rescue StandardError => e
      debug { "#{e.class}: #{e.message}" }

      if @num_times_run < @retries
        sleep @num_times_run
        retry
      else
        raise e
      end
    end

    def divider(title = '')
      total_length = 72
      start_length = 3

      string = ''
      string << ('-' * start_length)
      string << title
      string << '-' * (total_length - start_length - title.length)
      string << "\n"
      string
    end

    def without_colors(string)
      string.gsub(/\e\[\d+(?:;\d+)?m(.+?)\e\[0m/, '\1')
    end

    def debugging_enabled?
      ENV['DEBUG_COMMANDS'] == '1'
    end

    def debug(&block)
      if debugging_enabled?
        puts block.call
      end
    end
  end
end
