require 'stringio'
require 'open3'
require 'English'
require 'tempfile'
require 'fileutils'
require 'timeout'
require 'shellwords'

module Acceptance
  class ChildProcessError < ::StandardError
  end

  class ChildProcessTimeoutError < ::StandardError
  end

  class ChildProcessRecvError < ::StandardError
  end

  # A wrapper around ::Open3.popen2e - allows creating a process, writing to stdin, and reading the process output
  # All of the data is stored for future retrieval/appending to test output
  class ChildProcess
    def initialize
      super

      @default_timeout = ENV['CI'] ? 480 : 40
      @debug = false
      @env ||= {}
      @cmd ||= []
      @options ||= {}

      @stdin = nil
      @stdout_and_stderr = nil
      @wait_thread = nil

      @buffer = StringIO.new
      @all_data = StringIO.new
    end

    # @return [String] All data that was read from stdout/stderr of the running process
    def all_data
      @all_data.string
    end

    # Runs the process
    # @return [nil]
    def run
      self.stdin, self.stdout_and_stderr, self.wait_thread = ::Open3.popen2e(
        @env,
        *@cmd,
        **@options
      )

      stdin.sync = true
      stdout_and_stderr.sync = true

      nil
    rescue StandardError => e
      warn "popen failure #{e}"
      raise
    end

    # @return [String] A line of input
    def recvline(timeout: @default_timeout)
      recvuntil($INPUT_RECORD_SEPARATOR, timeout: timeout)
    end

    alias readline recvline

    # @param [String|Regexp] delim
    def recvuntil(delim, timeout: @default_timeout, drop_delim: false)
      buffer = ''
      result = nil

      with_countdown(timeout) do |countdown|
        while alive? && !countdown.elapsed?
          data_chunk = recv(timeout: [countdown.remaining_time, 1].min)
          if !data_chunk
            next
          end

          buffer += data_chunk
          has_delimiter = delim.is_a?(Regexp) ? buffer.match?(delim) : buffer.include?(delim)
          next unless has_delimiter

          result, matched_delim, remaining = buffer.partition(delim)
          unless drop_delim
            result += matched_delim
          end
          unrecv(remaining)
          # Reset the temporary buffer to avoid the `ensure` mechanism unrecv'ing the buffer unintentionally
          buffer = ''

          return result
        end
      ensure
        unrecv(buffer)
      end

      result
    rescue ChildProcessTimeoutError
      raise ChildProcessRecvError, "Failed #{__method__}: Did not match #{delim.inspect}, process was alive?=#{alive?.inspect}, remaining buffer: #{self.buffer.string[self.buffer.pos..].inspect}"
    end

    # @return [String] Recv until additional reads would cause a block, or eof is reached, or a maximum timeout is reached
    def recv_available(timeout: @default_timeout)
      result = ''
      finished_reading = false

      with_countdown(timeout) do
        until finished_reading do
          data_chunk = recv(timeout: 0, wait_readable: false)
          if !data_chunk
            finished_reading = true
            next
          end

          result += data_chunk
        end
      end

      result
    rescue EOFError, ChildProcessTimeoutError
      result
    end

    # @param [String] data The string of bytes to put back onto the buffer; Future buffered reads will return these bytes first
    def unrecv(data)
      data.bytes.reverse.each { |b| buffer.ungetbyte(b) }
    end

    # @param [Integer] length Reads length bytes from the I/O stream
    # @param [Integer] timeout The timeout in seconds
    # @param [TrueClass] wait_readable True if blocking, false otherwise
    def recv(length = 4096, timeout: @default_timeout, wait_readable: true)
      buffer_result = buffer.read(length)
      return buffer_result if buffer_result

      retry_count = 0

      # Eagerly read, and if we fail - await a response within the given timeout period
      result = nil
      begin
        result = stdout_and_stderr.read_nonblock(length)
        unless result.nil?
          log("[read] #{result}")
          @all_data.write(result)
        end
      rescue IO::WaitReadable
        if wait_readable
          IO.select([stdout_and_stderr], nil, nil, timeout)
          retry_count += 1
          retry if retry_count == 1
        end
      end

      result
    end

    # @param [String] data Write the data to the tdin of the running process
    def write(data)
      log("[write] #{data}")
      @all_data.write(data)
      stdin.write(data)
      stdin.flush
    end

    # @param [String] s Send line of data to the stdin of the running process
    def sendline(s)
      write("#{s}#{$INPUT_RECORD_SEPARATOR}")
    end

    # @return [TrueClass, FalseClass] True if the running process is alive, false otherwise
    def alive?
      wait_thread.alive?
    end

    # Interact with the current process, forwarding the current stdin to the console's stdin,
    # and writing the console's output to stdout. Doesn't support using PTY/raw mode.
    def interact
      $stderr.puts
      $stderr.puts '[*] Opened interactive mode - enter "!next" to continue, or "!exit" to stop entirely. !pry for an interactive pry'
      $stderr.puts

      without_debugging do
        while alive?
          ready = IO.select([stdout_and_stderr, $stdin], [], [], 10)

          next unless ready

          reads, = ready

          reads.to_a.each do |read|
            case read
            when $stdin
              input = $stdin.gets
              if input.chomp == '!continue'
                return
              elsif input.chomp == '!exit'
                exit
              elsif input.chomp == '!pry'
                require 'pry-byebug'; binding.pry
              end

              write(input)
            when stdout_and_stderr
              available_bytes = recv
              $stdout.write(available_bytes)
              $stdout.flush
            end
          end
        end
      end
    end

    def close
      begin
        Process.kill('KILL', wait_thread.pid) if wait_thread.pid
      rescue StandardError => e
        warn "error #{e} for #{@cmd}, pid #{wait_thread.pid}"
      end
      stdin.close if stdin
      stdout_and_stderr.close if stdout_and_stderr
    end

    # @return [IO] the stdin for the child process which can be written to
    attr_reader :stdin
    # @return [IO] the stdout and stderr for the child process which can be read from
    attr_reader :stdout_and_stderr
    # @return [Process::Waiter] the waiter thread for the current process
    attr_reader :wait_thread

    # @return [String] The cmd that was used to execute the current process
    attr_reader :cmd

    private

    # @return [StringIO] the buffer for any data which was read from stdout/stderr which was read, but not consumed
    attr_reader :buffer
    # @return [IO] the stdin of the running process
    attr_writer :stdin
    # @return [IO] the stdout and stderr of the running process
    attr_writer :stdout_and_stderr
    # @return [Process::Waiter] The process wait thread which tracks if the process is alive, its pid, return value, etc.
    attr_writer :wait_thread

    # @param [String] s Log to stderr
    def log(s)
      return unless @debug

      $stderr.puts s
    end

    def without_debugging
      previous_debug_value = @debug
      @debug = false
      yield
    ensure
      @debug = previous_debug_value
    end

    # Yields a timer object that can be used to request the remaining time available
    def with_countdown(timeout)
      countdown = Acceptance::Countdown.new(timeout)
      # It is the caller's responsibility to honor the required countdown limits,
      # but let's wrap the full operation in an explicit for worse case scenario,
      # which may leave object state in a non-determinant state depending on the call
      ::Timeout.timeout(timeout * 1.5) do
        yield countdown
      end
      if countdown.elapsed?
        raise ChildProcessTimeoutError
      end
    rescue ::Timeout::Error
      raise ChildProcessTimeoutError
    end
  end

  # Internally generates a temporary file with Dir::Tmpname instead of a ::Tempfile instance, otherwise windows won't allow the file to be executed
  # at the same time as the current Ruby process having an open handle to the temporary file
  class TempChildProcessFile
    def initialize(basename, extension)
      @file_path = Dir::Tmpname.create([basename, extension]) do |_path, _n, _opts, _origdir|
        # noop
      end

      ObjectSpace.define_finalizer(self, self.class.finalizer_proc_for(@file_path))
    end

    def path
      @file_path
    end

    def to_s
      path
    end

    def inspect
      "#<#{self.class} #{self.path}>"
    end

    def self.finalizer_proc_for(path)
      proc { File.delete(path) if File.exist?(path) }
    end
  end

  ###
  # Stores the data for a payload, including the options used to generate the payload,
  ###
  class Payload
    attr_reader :name, :execute_cmd, :generate_options, :datastore

    def initialize(options)
      @name = options.fetch(:name)
      @execute_cmd = options.fetch(:execute_cmd)
      @generate_options = options.fetch(:generate_options)
      @datastore = options.fetch(:datastore)
      @executable = options.fetch(:executable, false)

      basename = "#{File.basename(__FILE__)}_#{name}".gsub(/[^a-zA-Z]/, '-')
      extension = options.fetch(:extension, '')

      @file_path = TempChildProcessFile.new(basename, extension)
    end

    # @return [TrueClass, FalseClass] True if the payload needs marked as executable before being executed
    def executable?
      @executable
    end

    # @return [String] The path to the payload on disk
    def path
      @file_path.path
    end

    # @return [Integer] The size of the payload on disk. May be 0 when the payload doesn't exist,
    #   or a smaller size than expected if the payload is not fully generated by msfconsole yet.
    def size
      File.size(path)
    rescue StandardError => _e
      0
    end

    def [](k)
      options[k]
    end

    # @return [Array<String>] The command which can be used to execute this payload. For instance ["python3", "/tmp/path.py"]
    def execute_command
      @execute_cmd.map do |val|
        val.gsub('${payload_path}', path)
      end
    end

    # @param [Hash] default_global_datastore
    # @return [String] The setg commands for setting the global datastore
    def setg_commands(default_global_datastore: {})
      commands = []
      # Ensure the global framework datastore is always clear
      commands << "irb -e '(self.respond_to?(:framework) ? framework : self).datastore.user_defined.clear'"
      # Call setg
      global_datastore = default_global_datastore.merge(@datastore[:global])
      global_datastore.each do |key, value|
        commands << "setg #{key} #{value}"
      end
      commands.join("\n")
    end

    # @param [Hash] default_module_datastore
    # @return [String] The command which can be used on msfconsole to generate the payload
    def generate_command(default_module_datastore: {})
      generate_options = @generate_options.map do |key, value|
        "#{key} #{value}"
      end
      "generate -o #{path} #{generate_options.join(' ')} #{datastore_options(default_module_datastore: default_module_datastore)}"
    end

    # @param [Hash] default_module_datastore
    # @return [String] The command which can be used on msfconsole to create the listener
    def handler_command(default_module_datastore: {})
      "to_handler #{datastore_options(default_module_datastore: default_module_datastore)}"
    end

    # @param [Hash] default_module_datastore
    # @return [String] The datastore options string
    def datastore_options(default_module_datastore: {})
      module_datastore = default_module_datastore.merge(@datastore[:module])
      module_options = module_datastore.map do |key, value|
        "#{key}=#{value}"
      end

      module_options.join(' ')
    end

    # @param [Hash] default_global_datastore
    # @param [Hash] default_module_datastore
    # @return [String] A human readable representation of the payload configuration object
    def as_readable_text(default_global_datastore: {}, default_module_datastore: {})
      <<~EOF
        ## Payload
        use #{name}

        ## Set global datastore
        #{setg_commands(default_global_datastore: default_global_datastore)}

        ## Generate command
        #{generate_command(default_module_datastore: default_module_datastore)}

        ## Create listener
        #{handler_command(default_module_datastore: default_module_datastore)}

        ## Execute command
        #{Shellwords.join(execute_command)}
      EOF
    end
  end

  class PayloadProcess
    # @return [Process::Waiter] the waiter thread for the current process
    attr_reader :wait_thread

    # @return [String] the executed command
    attr_reader :cmd

    # @return [String] the payload path on disk
    attr_reader :payload_path

    # @param [Array<String>] cmd The command which can be used to execute this payload. For instance ["python3", "/tmp/path.py"]
    # @param [path] payload_path The payload path on disk
    # @param [Hash] opts the opts to pass to the Process#spawn call
    def initialize(cmd, payload_path, opts = {})
      super()

      @payload_path = payload_path
      @debug = false
      @env = {}
      @cmd = cmd
      @options = opts
    end

    # @return [Process::Waiter] the waiter thread for the payload process
    def run
      pid = Process.spawn(
        @env,
        *@cmd,
        **@options
      )
      @wait_thread = Process.detach(pid)
      @wait_thread
    end

    def alive?
      @wait_thread.alive?
    end

    def close
      begin
        Process.kill('KILL', wait_thread.pid) if wait_thread.pid
      rescue StandardError => e
        warn "error #{e} for #{@cmd}, pid #{wait_thread.pid}"
      end
      [:in, :out, :err].each do |name|
        @options[name].close if @options[name]
      end
      @wait_thread.join
    end
  end

  class ConsoleDriver
    def initialize
      @console = nil
      @payload_processes = []
      ObjectSpace.define_finalizer(self, self.class.finalizer_proc_for(self))
    end

    # @param [Acceptance::Payload] payload
    # @param [Hash] opts
    def run_payload(payload, opts)
      if payload.executable? && !File.executable?(payload.path)
        FileUtils.chmod('+x', payload.path)
      end

      payload_process = PayloadProcess.new(payload.execute_command, payload.path, opts)
      payload_process.run
      @payload_processes << payload_process
      payload_process
    end

    # @return [Acceptance::Console]
    def open_console
      @console = Console.new
      @console.run
      @console.recvuntil(Console.prompt, timeout: 120)

      @console
    end

    def close_payloads
      close_processes(@payload_processes)
    end

    def close
      close_processes(@payload_processes + [@console])
    end

    def self.finalizer_proc_for(instance)
      proc { instance.close }
    end

    private

    def close_processes(processes)
      while (process = processes.pop)
        begin
          process.close
        rescue StandardError => e
          $stderr.puts e.to_s
        end
      end
    end
  end

  class Console < ChildProcess
    def initialize
      super

      framework_root = Dir.pwd
      @debug = true
      @env = {
        'BUNDLE_GEMFILE' => File.join(framework_root, 'Gemfile'),
        'PATH' => "#{framework_root.shellescape}:#{ENV['PATH']}"
      }
      @cmd = [
        'bundle', 'exec', 'ruby', 'msfconsole',
        '--no-readline',
        # '--logger', 'Stdout',
        '--quiet'
      ]
      @options = {
        chdir: framework_root
      }
    end

    def self.prompt
      /msf.*>\s+/
    end

    def reset
      sendline('sessions -K')
      recvuntil(Console.prompt)

      sendline('jobs -K')
      recvuntil(Console.prompt)
    ensure
      @all_data.reopen('')
    end
  end
end
