require "open3"
require "shellwords"
require "vendor/plist/plist"

require "extend/io"

require "hbc/utils/hash_validator"

module Hbc
  class SystemCommand
    attr_reader :command

    def self.run(executable, options = {})
      new(executable, options).run!
    end

    def self.run!(command, options = {})
      run(command, options.merge(must_succeed: true))
    end

    def run!
      @processed_output = { stdout: "", stderr: "" }
      odebug "Executing: #{expanded_command}"

      each_output_line do |type, line|
        case type
        when :stdout
          processed_output[:stdout] << line
          ohai line.chomp if options[:print_stdout]
        when :stderr
          processed_output[:stderr] << line
          ohai line.chomp if options[:print_stderr]
        end
      end

      assert_success if options[:must_succeed]
      result
    end

    def initialize(executable, options)
      @executable = executable
      @options = options
      process_options!
    end

    private

    attr_reader :executable, :options, :processed_output, :processed_status

    def process_options!
      options.extend(HashValidator)
             .assert_valid_keys :input, :print_stdout, :print_stderr, :args, :must_succeed, :sudo
      sudo_prefix = %w[/usr/bin/sudo -E --]
      sudo_prefix = sudo_prefix.insert(1, "-A") unless ENV["SUDO_ASKPASS"].nil?
      @command = [executable]
      options[:print_stderr] = true    unless options.key?(:print_stderr)
      @command.unshift(*sudo_prefix)   if  options[:sudo]
      @command.concat(options[:args])  if  options.key?(:args) && !options[:args].empty?
      @command[0] = Shellwords.shellescape(@command[0]) if @command.size == 1
      nil
    end

    def assert_success
      return if processed_status && processed_status.success?
      raise CaskCommandFailedError.new(command, processed_output[:stdout], processed_output[:stderr], processed_status)
    end

    def expanded_command
      @expanded_command ||= command.map do |arg|
        if arg.respond_to?(:to_path)
          File.absolute_path(arg)
        else
          String(arg)
        end
      end
    end

    def each_output_line(&b)
      raw_stdin, raw_stdout, raw_stderr, raw_wait_thr =
        Open3.popen3(*expanded_command)

      write_input_to(raw_stdin)
      raw_stdin.close_write
      each_line_from [raw_stdout, raw_stderr], &b

      @processed_status = raw_wait_thr.value
    end

    def write_input_to(raw_stdin)
      [*options[:input]].each { |line| raw_stdin.print line }
    end

    def each_line_from(sources)
      loop do
        readable_sources = IO.select(sources)[0]
        readable_sources.delete_if(&:eof?).first(1).each do |source|
          type = ((source == sources[0]) ? :stdout : :stderr)
          begin
            yield(type, source.readline_nonblock || "")
          rescue IO::WaitReadable, EOFError
            next
          end
        end
        break if readable_sources.empty?
      end
      sources.each(&:close_read)
    end

    def result
      Result.new(command,
                 processed_output[:stdout],
                 processed_output[:stderr],
                 processed_status.exitstatus)
    end
  end
end

module Hbc
  class SystemCommand
    class Result
      attr_accessor :command, :stdout, :stderr, :exit_status

      def initialize(command, stdout, stderr, exit_status)
        @command     = command
        @stdout      = stdout
        @stderr      = stderr
        @exit_status = exit_status
      end

      def plist
        @plist ||= self.class._parse_plist(@command, @stdout.dup)
      end

      def success?
        @exit_status.zero?
      end

      def merged_output
        @merged_output ||= @stdout + @stderr
      end

      def to_s
        @stdout
      end

      def self._warn_plist_garbage(command, garbage)
        return true unless garbage =~ /\S/
        external = File.basename(command.first)
        lines = garbage.strip.split("\n")
        opoo "Non-XML stdout from #{external}:"
        $stderr.puts lines.map { |l| "    #{l}" }
      end

      def self._parse_plist(command, output)
        raise CaskError, "Empty plist input" unless output =~ /\S/
        output.sub!(/\A(.*?)(<\?\s*xml)/m, '\2')
        _warn_plist_garbage(command, Regexp.last_match[1]) if ARGV.debug?
        output.sub!(%r{(<\s*/\s*plist\s*>)(.*?)\Z}m, '\1')
        _warn_plist_garbage(command, Regexp.last_match[2])
        xml = Plist.parse_xml(output)
        unless xml.respond_to?(:keys) && !xml.keys.empty?
          raise CaskError, <<-EOS
    Empty result parsing plist output from command.
      command was:
      #{command}
      output we attempted to parse:
      #{output}
          EOS
        end
        xml
      end
    end
  end
end
