require 'kameleon/utils'
require 'shellwords'


module Kameleon
  class Shell
    ECHO_CMD = "echo"
    READ_CHUNK_SIZE = 1048576
    EXIT_TIMEOUT = 60

    attr :exit_status
    attr :process
    attr :shell_cmd

    def initialize(context)
      @cmd = context.cmd.chomp
      @interactive_cmd = context.interactive_cmd.chomp
      @context_name = context.name
      @local_workdir = context.local_workdir
      @shell_workdir = context.workdir
      @proxy = context.proxy
      @env_files = context.env_files
      @bash_scripts_dir = File.join("kameleon_scripts", @context_name)
      @bashrc_file = File.join(@bash_scripts_dir, "bash_rc")
      @bash_history_file = File.join(@bash_scripts_dir, "bash_history")
      @bash_env_file = File.join(@bash_scripts_dir, "bash_env")
      @bash_status_file = File.join(@bash_scripts_dir, "bash_status")
      if File::directory?(f = File.join(Kameleon.source_root, "contrib"))
        @contrib_dir = f
      elsif File::directory?(f = '/usr/share/kameleon/contrib')
        @contrib_dir = f
      else
        raise "Could not find contrib dir"
      end
      @default_bashrc_file =  File.join(@contrib_dir, 'kameleon_bashrc.sh')
      @cmd_tpl = ERB.new(File.read(File.join(@contrib_dir,
                                                     "kameleon_exec_cmd.sh")))
      @cmd_wrapper_tpl = ERB.new(File.read(File.join(@contrib_dir,
                                                     "kameleon_exec_cmd_wrapper.sh")))

      if @shell_workdir
        @bash_scripts_dir = File.join(@shell_workdir, @bash_scripts_dir)
        @bashrc_file = File.join(@shell_workdir, @bashrc_file)
        @bash_history_file = File.join(@shell_workdir, @bash_history_file)
        @bash_env_file = File.join(@shell_workdir, @bash_env_file)
        @bash_status_file = File.join(@shell_workdir, @bash_status_file)
      end

      ## Changing the default bashrc if the cache is activated
      @cache = Kameleon::Persistent_cache.instance

      @shell_cmd = "#{@cmd} --rcfile #{@bashrc_file}"
      @interactive_shell_cmd = "#{@interactive_cmd} --rcfile #{@bashrc_file}"

      Kameleon.ui.debug("Initialize shell (#{self})")
      # Injecting all variables of the options and assign the variables
      instance_variables.each do |v|
        Kameleon.ui.debug("  #{v} = #{instance_variable_get(v)}")
      end
    end

    def start
      @sent_first_cmd = false
      @process, @stdout, @stderr = fork("pipe")
    end

    def stop
      unless @process.nil?
        @process.stop(0)
        @process = nil
      end
    end

    def started?
      return !@process.nil?
    end

    def exited?
      return !@process.nil? && @process.exited?
    end

    def restart
      stop
      start
    end

    def send_file(source_path, remote_dest_path, chunk_size=READ_CHUNK_SIZE)
      copy_process, = fork("pipe")
      copy_process.io.stdin << init_shell_cmd
      copy_process.io.stdin << "cat > #{remote_dest_path}\n"
      copy_process.io.stdin.flush
      open(source_path, "rb") do |f|
        begin
          copy_process.io.stdin << f.read(chunk_size)
        end until f.eof?
      end
      copy_process.io.stdin.flush
      copy_process.io.stdin.close
      copy_process.wait
      copy_process.poll_for_exit(EXIT_TIMEOUT)
    end

    def init_shell_cmd
      ## log shell error message
      error = read_io(@stderr)
      init_stdout = read_io(@stdout)
      Kameleon.ui.error(error) unless error.empty?
      Kameleon.ui.info(init_stdout) unless init_stdout.empty?
      bashrc_content = ""
      if File.file?(@default_bashrc_file)
        tpl = ERB.new(File.read(@default_bashrc_file))
        bashrc_content = tpl.result(binding)
        if @proxy != "" then
          tpl = ERB.new(File.read(File.join(Kameleon.source_root, "contrib", "proxy_env.sh")))
          bashrc_content << "\n" + tpl.result(binding)
        end
      end
      # Inject sigint handler
      bashrc_content << <<-SCRIPT
function save_state_handler {
  echo "Saved ENV in #{@bash_env_file} file"
  (comm -3 <(declare | sort) <(declare -f | sort)) > #{@bash_env_file}
}
trap save_state_handler EXIT
SCRIPT
      bashrc = Shellwords.escape(bashrc_content)
      if @shell_workdir
        unless @shell_workdir.eql? "/"
          change_dir_cmd = "mkdir -p #{@shell_workdir} &&"
        end
        change_dir_cmd = "#{change_dir_cmd} cd #{@shell_workdir}"
        change_dir_cmd = Shellwords.escape(change_dir_cmd)
      end
      shell_cmd = "mkdir -p $(dirname #{@bashrc_file})\n"
      shell_cmd << "rm $(dirname #{@bashrc_file})/*\n"
      shell_cmd << "echo #{bashrc} > #{@bashrc_file}\n"
      unless change_dir_cmd.nil?
        shell_cmd << "echo #{change_dir_cmd} >> #{@bashrc_file}\n"
      end
      shell_cmd << "export KAMELEON_WORKDIR=$PWD\n"
      @env_files.each do |env_file|
        resolved_erb = "\n" + ERB.new(File.read(env_file)).result(binding)
        env_content = <<-SCRIPT
#====================================================
# Begin content of user script '#{env_file.basename}'
#====================================================
#{resolved_erb}
#====================================================
# End content of user script '#{env_file.basename}'
#====================================================
SCRIPT
        env_escaped_content = Shellwords.escape(env_content)
        shell_cmd << "echo #{env_escaped_content} >> #{@bashrc_file}\n"
      end
      shell_cmd << "source #{@bashrc_file}\n"
      shell_cmd << "(comm -3 <(declare | sort) <(declare -f | sort)) > #{@bash_env_file}\n"
      shell_cmd
    end

    def send_command cmd
      shell_cmd = ""
      unless @sent_first_cmd
        shell_cmd << init_shell_cmd
        @sent_first_cmd = true
      end
      cmd_content = Shellwords.escape(@cmd_tpl.result(binding))
      cmd_wrapper_content = Shellwords.escape(@cmd_wrapper_tpl.result(binding))
      shell_cmd << "mkdir -p $(dirname #{File.join(@bash_scripts_dir, "#{cmd}.sh" )})\n"
      shell_cmd << "echo #{cmd_content} > #{File.join(@bash_scripts_dir, "#{cmd}.sh" )}\n"
      shell_cmd << "echo #{cmd_wrapper_content} > #{File.join(@bash_scripts_dir, "#{cmd}_wrapper.sh" )}\n"
      shell_cmd << "bash #{File.join(@bash_scripts_dir, "#{cmd}_wrapper.sh" )}\n"
      @process.io.stdin.puts shell_cmd
      @process.io.stdin.flush
    end

    def execute(cmd, kwargs = {})
      cmd_obj = Command.new(cmd)
      send_command(cmd_obj)
      iodata = {:stderr => { :io        => @stderr,
                             :name      => 'stderr',
                             :begin     => false,
                             :end       => false,
                             :begin_pat => cmd_obj.begin_err_pat,
                             :end_pat   => cmd_obj.end_err_pat,
                             :redirect  => kwargs[:stderr],
                             :yield     => lambda{|buf| yield(nil, buf)} },
                :stdout => { :io        => @stdout,
                             :name      => 'stdout',
                             :begin     => false,
                             :end       => false,
                             :begin_pat => cmd_obj.begin_out_pat,
                             :end_pat   => cmd_obj.end_out_pat,
                             :redirect  => kwargs[:stdout],
                             :yield     => lambda{|buf| yield(buf, nil)} }
                }
      while true
        if @process.exited?
          raise ShellExited, "Process '#{@cmd}' exited..."
        end
        iodata.each do |_, iodat|
          if iodat[:end] and not iodat[:begin]
            if @process.exited?
              raise ShellExited, "Process '#{@cmd}' exited..."
            end
            raise ShellError, "Cannot read #{iodat[:begin]} from shell"
          end
        end
        if iodata.all? { |k, iodat| iodat[:end] and iodat[:begin]}
          break
        end
        readers = (iodata.map { |_, v| v[:io] unless v[:end] })
        ready = IO.select(readers.compact, nil, nil, 0.1)
        ready ||= [[]]
        readers = ready[0]
        # Check the readers to see if they're ready
        if readers && !readers.empty?
          readers.each do |r|
            # Read from the IO object
            iodat = r == @stdout ? iodata[:stdout] : iodata[:stderr]
            data = read_io(r)
            # We don't need to do anything if the data is empty
            next if data.empty?
            if !iodat[:begin] && (m = iodat[:begin_pat].match(data))
                iodat[:begin] = true
                data = m[1]
            end
            next unless iodat[:begin] and not iodat[:end]  # ignore chaff
            if !iodat[:end] && (m = iodat[:end_pat].match(data))
              iodat[:end] = true
              data = m[1]
            end
            next if data.empty?
            if iodat[:redirect]
              iodat[:redirect] << data
            else
              iodat[:yield].call data if block_given?
            end
          end
        end
      end
      if @process.exited?
        raise ShellExited, "Process '#{@cmd}' exited..."
      end
      iodata = nil
      return get_status
    end

    def start_interactive
      process, _, _ = fork("interactive")
      process.wait()
    end

    protected

    def get_status
      var_name = "__exit_status__"
      @process.io.stdin << "#{ ECHO_CMD } \"#{ var_name }=$(cat #{@bash_status_file})\"\n"
      @process.io.stdin.flush
      while((line = @stdout.gets))
        if (m = %r/#{ var_name }\s*=\s*(.*)/.match line)
          exit_status = m[1]
          unless exit_status =~ /^\s*\d+\s*$/o
            raise ShellError, "could not determine exit status from <#{ exit_status.inspect }>"
          end
          @exit_status = Integer exit_status
          return @exit_status
        end
      end
    end

    def read_io(io)
      data = ""
      while true
        begin
        # Do a simple non-blocking read on the IO object
        data << io.read_nonblock(READ_CHUNK_SIZE)
        rescue Exception => e
          breakable = false
          if e.is_a?(EOFError)
            # An `EOFError` means this IO object is done!
            breakable = true
          elsif defined?(IO::WaitReadable) && e.is_a?(IO::WaitReadable)
            breakable = true
          elsif e.is_a?(Errno::EAGAIN)
            breakable = true
          end
          break if breakable
          raise
        end
      end
      data
    end

    def fork(io)
      if io.eql? "interactive"
        command = ["bash", "-c", @interactive_shell_cmd]
        Kameleon.ui.verbose("Starting interactive command: #{@interactive_shell_cmd.inspect}")
      else
        command = ["bash", "-c", @shell_cmd]
        Kameleon.ui.verbose("Starting command: #{@cmd.inspect}")
      end
      Kameleon.ui.debug("Starting shell process: #{ command.inspect}")
      ChildProcess.posix_spawn = true
      process = ChildProcess.build(*command)
      # Create the pipes so we can read the output in real time as
      # we execute the command.
      if io.eql? "pipe"
        stdout, stdout_writer = IO.pipe
        stderr, stderr_writer = IO.pipe
        process.io.stdout = stdout_writer
        process.io.stderr = stderr_writer
        # sets up pipe so process.io.stdin will be available after .start
        process.duplex = true
      elsif io.eql? "interactive"
        process.io.inherit!
      end

      # Start the process
      begin
        process.cwd = @local_workdir
        process.start
        # Wait to child starting
        sleep(0.2)
      rescue ChildProcess::LaunchError => e
        # Raise our own version of the error
        raise ShellError, "Cannot launch #{command.inspect}: #{e.message}"
      end
      if io.eql? "pipe"
        # Make sure the stdin does not buffer
        process.io.stdin.sync = true
        stdout_writer.close()
        stderr_writer.close()
        return process, stdout, stderr
      else
        return process, $stdout, $stderr
      end
    end

    class Command
      class << self
        def counter; @counter ||= 0; end
        def counter= n; @counter = n; end
      end
      attr :value
      attr :number
      attr :id
      attr :slug
      attr :begin_out
      attr :begin_out_pat
      attr :end_out
      attr :end_out_pat
      attr :begin_err
      attr :begin_err_pat
      attr :end_err
      attr :end_err_pat

      def initialize(raw)
        @value = raw.to_s.strip
        @number = self.class.counter
        @slug = Kameleon::Utils.generate_slug(@value)[0...30]
        @id = "%d_%d_%d" % [@number, $$, rand(Time.now.usec)]
        @begin_out = "__CMD_OUT_%s_BEGIN__" % @id
        @end_out = "__CMD_OUT_%s_END__" % @id
        @begin_out_pat = %r/#{ Regexp.escape(@begin_out) }(.*)/m
        @end_out_pat = %r/(.*)#{ Regexp.escape(@end_out) }/m
        @begin_err = "__CMD_ERR_%s_BEGIN__" % @id
        @end_err = "__CMD_ERR_%s_END__" % @id
        @begin_err_pat = %r/#{ Regexp.escape(@begin_err) }(.*)/m
        @end_err_pat = %r/(.*)#{ Regexp.escape(@end_err) }/m
        self.class.counter += 1
      end

      def to_s
        "%05d_#{@slug}" % @number
      end
    end

  end
end
