require 'rake'
require 'map'
require 'open3'
require 'timeout'
require 'stringio'
require 'albacore/paths'
require 'albacore/logging'
require 'albacore/errors/command_not_found_error'
require 'albacore/errors/command_failed_error'

module Albacore
  # module for normalising slashes across operating systems
  # and running commands
  module CrossPlatformCmd
    include Logging

    # Exit status class for times the system just gives us a nil.
    class PseudoStatus
      attr_reader :exitstatus
      def initialize(code=0)
        @exitstatus = code
      end
      def to_i
        @exitstatus << 8
      end
      def >>(n)
        to_i >> n
      end
      def stopped?
        false
      end
      def exited?
        true
      end
    end

    class << self
      include CrossPlatformCmd
    end

    attr_reader :pid

    KILL_TIMEOUT = 2 # seconds

    def initialize
      pid = Process.pid
      at_exit { stop if Process.pid == pid }
    end

    # run executable
    #
    # system(cmd, [args array], Hash(opts), block|ok,status|)
    #  ok => false if bad exit code, or the output otherwise
    #
    # options are passed as the last argument
    #
    # options:
    #  work_dir: a file path (default '.')
    #  silent:   whether to supress all output or not (default false)
    #  output:   whether to supress the command's output (default false)
    #  out:      output pipe
    #  err:      error pipe
    #  clr_command:
    #            whether to include 'mono' in front of the things to run
    #            if the command is a clr command
    #
    def system *cmd, &block
      raise ArgumentError, "cmd is nil" if cmd.nil? # don't allow nothing to be passed
      opts = Map.options((Hash === cmd.last) ? cmd.pop : {}). # same arg parsing as rake
        apply({
          :silent => false,
          :output => true,
          :work_dir => FileUtils.pwd,
          :out => Albacore.application.output,
          :err => Albacore.application.output_err,
          :clr_command => false })

      exe, pars, printable, block = prepare_command cmd, (opts.get('clr_command')), &block

      # TODO: figure out how to interleave output and error streams
      out, _, inmem = opts.get(:out), opts.get(:err), StringIO.new

      trace { "system( exe=#{exe}, pars=[#{pars.join(', ')}], options=#{opts.to_s}), in directory: #{opts.getopt(:work_dir, '<<current>>')} [cross_platform_cmd #system]" }

      puts printable unless opts.get :silent, false # log cmd verbatim

      handle_not_found block do
        # create a pipe for the process to work with
        read, write = IO.pipe
        eread, ewrite = IO.pipe

        # this thread chews through the output
        @out_thread = Thread.new {
          while !read.eof?
            data = read.readpartial(1024)
            out.write data
            inmem.write data # to give the block at the end
          end
        }

        debug 'execute the new process, letting it write to the write FD (file descriptor)'
        @pid = Process.spawn(*[exe, *pars],
          { :out => write,
            #:err => ewrite,
            :chdir => opts.get(:work_dir) })

        debug 'waiting for process completion'
        _, status = Process.wait2 @pid

        ret_str = inmem.string.encode 'utf-8', invalid: :replace, undef: :replace, replace: ''
        return block.call(status.success? && ret_str, status, ret_str)
      end
    end

    def stop
      if pid
        begin
          Process.kill('TERM', pid)

          begin
            Timeout.timeout(KILL_TIMEOUT) { Process.wait(pid) }
          rescue Timeout::Error
            Process.kill('KILL', pid)
            Process.wait(pid)
          end
        rescue Errno::ESRCH, Errno::ECHILD
          # Zed's dead, baby
        end

        @out_thread.kill
        @pid = nil
      end
    end

    # http://www.ruby-doc.org/core-2.0/Process.html#method-c-spawn
    # https://practicingruby.com/articles/shared/ujxrxprnlugz
    # https://github.com/jonleighton/poltergeist/blob/3365dadfb6242b0b91fe00359ff881e582cc2557/lib/capybara/poltergeist/client.rb

    # run in shell
    def sh *cmd, &block
      raise ArgumentError, "cmd is nil" if cmd.nil? # don't allow nothing to be passed
      opts = Map.options((Hash === cmd.last) ? cmd.pop : {}) # same arg parsing as rake

      cmd = cmd.join(' ') # shell needs a single string
      block = handler_with_message cmd unless block_given?

      chdir opts.get(:work_dir) do

        trace { "#sh( ...,  options: #{opts.to_s}) [cross_platform_cmd #sh]" }
        puts cmd unless opts.getopt :silent, false # log cmd verbatim

        lines = ''
        handle_not_found block do
          IO.popen(cmd, 'r') do |io|
            io.each do |line|
              lines << line
              puts line if opts.getopt(:output, true) or not opts.getopt(:silent, false)
            end
          end
        end

        return block.call($? == 0 && lines, $?, lines)
      end
    end

    # shell ignore exit code
    # returns:
    #  [ok, status]
    #  where status:
    #    #exitstatus : Int
    #    #pid      : Int
    def shie *cmd, &block
      block = lambda { |ok, status, output| ok } unless block_given?
      sh *cmd, &block
    end

    def normalise_slashes path
      ::Albacore::Paths.normalise_slashes path
    end

    # create a new command string
    def make_command
      ::Albacore::Paths.make_command @executable, @parameters
    end

    def which executable
      raise ArgumentError, "executable to #which is nil" unless executable

      dir = File.dirname executable
      file = File.basename executable

      cmd = ::Rake::Win32.windows? ? 'where' : 'which'
      parameters = []
      pattern = if dir == '.' then file
      elsif ::Rake::Win32.windows? then "#{dir}:#{file}"
      else executable
      end
      parameters << Paths.normalise_slashes("\"#{pattern}\"")
      parameters << '2> nul' if ::Rake::Win32.windows?
      cmd, parameters = Paths.normalise cmd, parameters
      cmd = "#{cmd} #{parameters.join(' ')}"

      trace { "#{cmd} [cross_platform_cmd #which]" }

      res = IO.popen(cmd) do |io|
        io.read.chomp
      end

      unless $? == 0
        nil
      else
        res
      end
    rescue Errno::ENOENT => e
      trace "which/where returned #{$?}: #{e} [cross_platform_cmd #which]"
      nil
    end

    def chdir wd, &block
      return block.call if wd.nil?
      Dir.chdir wd do
        debug { "pushd #{wd} [cross_platform_cmd #chdir]" }
        res = block.call
        debug { "popd #{wd} [cross_platform_cmd #chdir]" }
        return res
      end
    end

    private

    def prepare_command cmd, clr_command = false, &block
      cmd = cmd.unshift 'mono' if clr_command && ! ::Albacore.windows?
      pars = cmd[1..-1].flatten
      raise ArgumentError, "arguments 1..-1 must be an array" unless pars.is_a? Array
      exe, pars = ::Albacore::Paths.normalise cmd[0], pars
      printable = %Q{#{exe} #{pars.join(' ')}}
      handler = block_given? ? block : handler_with_message(printable)
      [exe, pars, printable, handler]
    end

    def handler_with_message printable
      lambda { |ok, status, output| ok or raise_failure(printable, status, output) }
    end

    # handles the errors from not finding the executable on the system
    def handle_not_found rescue_block
      yield
    rescue Errno::ENOENT => e
      rescue_block.call(nil, PseudoStatus.new(127), e.to_s)
    rescue IOError => e # rescue for JRuby
      rescue_block.call(nil, PseudoStatus.new(127), e.to_s)
    end


    def knowns
      { 127 => 'number 127 in particular means that the operating system could not find the executable' }
    end

    def raise_failure cmd, status, output
      if status.exitstatus == 127
        raise CommandNotFoundError.new(format_failure(cmd, status), cmd)
      else
        raise CommandFailedError.new(format_failure(cmd, status), cmd, output)
      end
    end

    def format_failure cmd, status
      if knowns.has_key? status.exitstatus
        %{Command failed with status (#{status.exitstatus}) - #{knowns[status.exitstatus]}:
  #{cmd}}
      else
        %{Command failed with status (#{status.exitstatus}):
  #{cmd}}
      end
    end

    # shuffle the executable to be a parameter to
    # mono, if not on windows.
    def mono_command
      unless ::Rake::Win32.windows?
        trace 'detected running on mono -- unshifting exe file for mono'
        executable = @executable
        @executable = "mono"
        @parameters.unshift executable
      end
    end
  end
end
