require 'net/ssh/loggable'
require 'net/ssh/connection/constants'
require 'net/ssh/connection/term'

module Net
  module SSH
    module Connection
      # The channel abstraction. Multiple "channels" can be multiplexed onto a
      # single SSH channel, each operating independently and seemingly in parallel.
      # This class represents a single such channel. Most operations performed
      # with the Net::SSH library will involve using one or more channels.
      #
      # Channels are intended to be used asynchronously. You request that one be
      # opened (via Connection::Session#open_channel), and when it is opened, your
      # callback is invoked. Then, you set various other callbacks on the newly
      # opened channel, which are called in response to the corresponding events.
      # Programming with Net::SSH works best if you think of your programs as
      # state machines. Complex programs are best implemented as objects that
      # wrap a channel. See Net::SCP and Net::SFTP for examples of how complex
      # state machines can be built on top of the SSH protocol.
      #
      #   ssh.open_channel do |channel|
      #     channel.exec("/invoke/some/command") do |ch, success|
      #       abort "could not execute command" unless success
      #
      #       channel.on_data do |ch, data|
      #         puts "got stdout: #{data}"
      #         channel.send_data "something for stdin\n"
      #       end
      #
      #       channel.on_extended_data do |ch, type, data|
      #         puts "got stderr: #{data}"
      #       end
      #
      #       channel.on_close do |ch|
      #         puts "channel is closing!"
      #       end
      #     end
      #   end
      #
      #   ssh.loop
      #
      # Channels also have a basic hash-like interface, that allows programs to
      # store arbitrary state information on a channel object. This helps simplify
      # the writing of state machines, especially when you may be juggling
      # multiple open channels at the same time.
      #
      # Note that data sent across SSH channels are governed by maximum packet
      # sizes and maximum window sizes. These details are managed internally
      # by Net::SSH::Connection::Channel, so you may remain blissfully ignorant
      # if you so desire, but you can always inspect the current maximums, as
      # well as the remaining window size, using the reader attributes for those
      # values.
      class Channel
        include Loggable
        include Constants

        # The local id for this channel, assigned by the Net::SSH::Connection::Session instance.
        attr_reader :local_id

        # The remote id for this channel, assigned by the remote host.
        attr_reader :remote_id

        # The type of this channel, usually "session".
        attr_reader :type

        # The underlying Net::SSH::Connection::Session instance that supports this channel.
        attr_reader :connection

        # The maximum packet size that the local host can receive.
        attr_reader :local_maximum_packet_size

        # The maximum amount of data that the local end of this channel can
        # receive. This is a total, not per-packet.
        attr_reader :local_maximum_window_size

        # The maximum packet size that the remote host can receive.
        attr_reader :remote_maximum_packet_size

        # The maximum amount of data that the remote end of this channel can
        # receive. This is a total, not per-packet.
        attr_reader :remote_maximum_window_size

        # This is the remaining window size on the local end of this channel. When
        # this reaches zero, no more data can be received.
        attr_reader :local_window_size

        # This is the remaining window size on the remote end of this channel. When
        # this reaches zero, no more data can be sent.
        attr_reader :remote_window_size

        # A hash of properties for this channel. These can be used to store state
        # information about this channel. See also #[] and #[]=.
        attr_reader :properties

        # The output buffer for this channel. Data written to the channel is
        # enqueued here, to be written as CHANNEL_DATA packets during each pass of
        # the event loop. See Connection::Session#process and #enqueue_pending_output.
        attr_reader :output # :nodoc:

        # The list of pending requests. Each time a request is sent which requires
        # a reply, the corresponding callback is pushed onto this queue. As responses
        # arrive, they are shifted off the front and handled.
        attr_reader :pending_requests # :nodoc:

        # Instantiates a new channel on the given connection, of the given type,
        # and with the given id. If a block is given, it will be remembered until
        # the channel is confirmed open by the server, and will be invoked at
        # that time (see #do_open_confirmation).
        #
        # This also sets the default maximum packet size and maximum window size.
        def initialize(connection, type, local_id, max_pkt_size = 0x8000, max_win_size = 0x20000, &on_confirm_open)
          self.logger = connection.logger

          @connection = connection
          @type       = type
          @local_id   = local_id

          @local_maximum_packet_size = max_pkt_size
          @local_window_size = @local_maximum_window_size = max_win_size

          @on_confirm_open = on_confirm_open

          @output = Buffer.new

          @properties = {}

          @pending_requests = []
          @on_open_failed = @on_data = @on_extended_data = @on_process = @on_close = @on_eof = nil
          @on_request = {}
          @closing = @eof = @sent_eof = @local_closed = @remote_closed = false
        end

        # A shortcut for accessing properties of the channel (see #properties).
        def [](name)
          @properties[name]
        end

        # A shortcut for setting properties of the channel (see #properties).
        def []=(name, value)
          @properties[name] = value
        end

        # Syntactic sugar for executing a command. Sends a channel request asking
        # that the given command be invoked. If the block is given, it will be
        # called when the server responds. The first parameter will be the
        # channel, and the second will be true or false, indicating whether the
        # request succeeded or not. In this case, success means that the command
        # is being executed, not that it has completed, and failure means that the
        # command altogether failed to be executed.
        #
        #   channel.exec "ls -l /home" do |ch, success|
        #     if success
        #       puts "command has begun executing..."
        #       # this is a good place to hang callbacks like #on_data...
        #     else
        #       puts "alas! the command could not be invoked!"
        #     end
        #   end
        def exec(command, &block)
          send_channel_request("exec", :string, command, &block)
        end

        # Syntactic sugar for requesting that a subsystem be started. Subsystems
        # are a way for other protocols (like SFTP) to be run, using SSH as
        # the transport. Generally, you'll never need to call this directly unless
        # you are the implementor of something that consumes an SSH subsystem, like
        # SFTP.
        #
        #   channel.subsystem("sftp") do |ch, success|
        #     if success
        #       puts "subsystem successfully started"
        #     else
        #       puts "subsystem could not be started"
        #     end
        #   end
        def subsystem(subsystem, &block)
          send_channel_request("subsystem", :string, subsystem, &block)
        end

        # Syntactic sugar for setting an environment variable in the remote
        # process' environment. Note that for security reasons, the server may
        # refuse to set certain environment variables, or all, at the server's
        # discretion. If you are connecting to an OpenSSH server, you will
        # need to update the AcceptEnv setting in the sshd_config to include the
        # environment variables you want to send.
        #
        #   channel.env "PATH", "/usr/local/bin"
        def env(variable_name, variable_value, &block)
          send_channel_request("env", :string, variable_name, :string, variable_value, &block)
        end

        # A hash of the valid PTY options (see #request_pty).
        VALID_PTY_OPTIONS = { term: "xterm",
                              chars_wide: 80,
                              chars_high: 24,
                              pixels_wide: 640,
                              pixels_high: 480,
                              modes: {} }

        # Requests that a pseudo-tty (or "pty") be made available for this channel.
        # This is useful when you want to invoke and interact with some kind of
        # screen-based program (e.g., vim, or some menuing system).
        #
        # Note, that without a pty some programs (e.g. sudo, or subversion) on
        # some systems, will not be able to run interactively, and will error
        # instead of prompt if they ever need some user interaction.
        #
        # Note, too, that when a pty is requested, user's shell configuration
        # scripts (.bashrc and such) are not run by default, whereas they are
        # run when a pty is not present.
        #
        #   channel.request_pty do |ch, success|
        #     if success
        #       puts "pty successfully obtained"
        #     else
        #       puts "could not obtain pty"
        #     end
        #   end
        def request_pty(opts = {}, &block)
          extra = opts.keys - VALID_PTY_OPTIONS.keys
          raise ArgumentError, "invalid option(s) to request_pty: #{extra.inspect}" if extra.any?

          opts = VALID_PTY_OPTIONS.merge(opts)

          modes = opts[:modes].inject(Buffer.new) do |memo, (mode, data)|
            memo.write_byte(mode).write_long(data)
          end
          # mark the end of the mode opcode list with a 0 byte
          modes.write_byte(0)

          send_channel_request("pty-req", :string, opts[:term],
                               :long, opts[:chars_wide], :long, opts[:chars_high],
                               :long, opts[:pixels_wide], :long, opts[:pixels_high],
                               :string, modes.to_s, &block)
        end

        # Sends data to the channel's remote endpoint. This usually has the
        # effect of sending the given string to the remote process' stdin stream.
        # Note that it does not immediately send the data across the channel,
        # but instead merely appends the given data to the channel's output buffer,
        # preparatory to being packaged up and sent out the next time the connection
        # is accepting data. (A connection might not be accepting data if, for
        # instance, it has filled its data window and has not yet been resized by
        # the remote end-point.)
        #
        # This will raise an exception if the channel has previously declared
        # that no more data will be sent (see #eof!).
        #
        #   channel.send_data("the password\n")
        def send_data(data)
          raise EOFError, "cannot send data if channel has declared eof" if eof?

          output.append(data.to_s)
        end

        # Returns true if the channel exists in the channel list of the session,
        # and false otherwise. This can be used to determine whether a channel has
        # been closed or not.
        #
        #   ssh.loop { channel.active? }
        def active?
          connection.channels.key?(local_id)
        end

        # Runs the SSH event loop until the channel is no longer active. This is
        # handy for blocking while you wait for some channel to finish.
        #
        #   channel.exec("grep ...") { ... }
        #   channel.wait
        def wait
          connection.loop { active? }
        end

        # True if close() has been called; NOTE: if the channel has data waiting to
        # be sent then the channel will close after all the data is sent. See
        # closed?() to determine if we have actually sent CHANNEL_CLOSE to server.
        # This may be true for awhile before closed? returns true if we are still
        # sending buffered output to server.
        def closing?
          @closing
        end

        # True if we have sent CHANNEL_CLOSE to the remote server.
        def local_closed?
          @local_closed
        end

        def remote_closed?
          @remote_closed
        end

        def remote_closed!
          @remote_closed = true
        end

        # Requests that the channel be closed. It only marks the channel to be closed
        # the CHANNEL_CLOSE message will be sent from event loop
        def close
          return if @closing

          @closing = true
        end

        # Returns true if the local end of the channel has declared that no more
        # data is forthcoming (see #eof!). Trying to send data via #send_data when
        # this is true will result in an exception being raised.
        def eof?
          @eof
        end

        # Tells the remote end of the channel that no more data is forthcoming
        # from this end of the channel. The remote end may still send data.
        # The CHANNEL_EOF packet will be sent once the output buffer is empty.
        def eof!
          return if eof?

          @eof = true
        end

        # If an #on_process handler has been set up, this will cause it to be
        # invoked (passing the channel itself as an argument). It also causes all
        # pending output to be enqueued as CHANNEL_DATA packets (see #enqueue_pending_output).
        def process
          @on_process.call(self) if @on_process
          enqueue_pending_output

          if @eof and not @sent_eof and output.empty? and remote_id and not @local_closed
            connection.send_message(Buffer.from(:byte, CHANNEL_EOF, :long, remote_id))
            @sent_eof = true
          end

          if @closing and not @local_closed and output.empty? and remote_id
            connection.send_message(Buffer.from(:byte, CHANNEL_CLOSE, :long, remote_id))
            @local_closed = true
            connection.cleanup_channel(self)
          end
        end

        # Registers a callback to be invoked when data packets are received by the
        # channel. The callback is called with the channel as the first argument,
        # and the data as the second.
        #
        #   channel.on_data do |ch, data|
        #     puts "got data: #{data.inspect}"
        #   end
        #
        # Data received this way is typically the data written by the remote
        # process to its +stdout+ stream.
        def on_data(&block)
          old, @on_data = @on_data, block
          old
        end

        # Registers a callback to be invoked when extended data packets are received
        # by the channel. The callback is called with the channel as the first
        # argument, the data type (as an integer) as the second, and the data as
        # the third. Extended data is almost exclusively used to send +stderr+ data
        # (+type+ == 1). Other extended data types are not defined by the SSH
        # protocol.
        #
        #   channel.on_extended_data do |ch, type, data|
        #     puts "got stderr: #{data.inspect}"
        #   end
        def on_extended_data(&block)
          old, @on_extended_data = @on_extended_data, block
          old
        end

        # Registers a callback to be invoked for each pass of the event loop for
        # this channel. There are no guarantees on timeliness in the event loop,
        # but it will be called roughly once for each packet received by the
        # connection (not the channel). This callback is invoked with the channel
        # as the sole argument.
        #
        # Here's an example that accumulates the channel data into a variable on
        # the channel itself, and displays individual lines in the input one
        # at a time when the channel is processed:
        #
        #   channel[:data] = ""
        #
        #   channel.on_data do |ch, data|
        #     channel[:data] << data
        #   end
        #
        #   channel.on_process do |ch|
        #     if channel[:data] =~ /^.*?\n/
        #       puts $&
        #       channel[:data] = $'
        #     end
        #   end
        def on_process(&block)
          old, @on_process = @on_process, block
          old
        end

        # Registers a callback to be invoked when the server acknowledges that a
        # channel is closed. This is invoked with the channel as the sole argument.
        #
        #   channel.on_close do |ch|
        #     puts "remote end is closing!"
        #   end
        def on_close(&block)
          old, @on_close = @on_close, block
          old
        end

        # Registers a callback to be invoked when the server indicates that no more
        # data will be sent to the channel (although the channel can still send
        # data to the server). The channel is the sole argument to the callback.
        #
        #   channel.on_eof do |ch|
        #     puts "remote end is done sending data"
        #   end
        def on_eof(&block)
          old, @on_eof = @on_eof, block
          old
        end

        # Registers a callback to be invoked when the server was unable to open
        # the requested channel. The channel itself will be passed to the block,
        # along with the integer "reason code" for the failure, and a textual
        # description of the failure from the server.
        #
        #   channel = session.open_channel do |ch|
        #     # ..
        #   end
        #
        #   channel.on_open_failed { |ch, code, desc| ... }
        def on_open_failed(&block)
          old, @on_open_failed = @on_open_failed, block
          old
        end

        # Registers a callback to be invoked when a channel request of the given
        # type is received. The callback will receive the channel as the first
        # argument, and the associated (unparsed) data as the second. The data
        # will be a Net::SSH::Buffer that you will need to parse, yourself,
        # according to the kind of request you are watching.
        #
        # By default, if the request wants a reply, Net::SSH will send a
        # CHANNEL_SUCCESS response for any request that was handled by a registered
        # callback, and CHANNEL_FAILURE for any that wasn't, but if you want your
        # registered callback to result in a CHANNEL_FAILURE response, just raise
        # Net::SSH::ChannelRequestFailed.
        #
        # Some common channel requests that your programs might want to listen
        # for are:
        #
        # * "exit-status" : the exit status of the remote process will be reported
        #   as a long integer in the data buffer, which you can grab via
        #   data.read_long.
        # * "exit-signal" : if the remote process died as a result of a signal
        #   being sent to it, the signal will be reported as a string in the
        #   data, via data.read_string. (Not all SSH servers support this channel
        #   request type.)
        #
        #     channel.on_request "exit-status" do |ch, data|
        #       puts "process terminated with exit status: #{data.read_long}"
        #     end
        def on_request(type, &block)
          old, @on_request[type] = @on_request[type], block
          old
        end

        # Sends a new channel request with the given name. The extra +data+
        # parameter must either be empty, or consist of an even number of
        # arguments. See Net::SSH::Buffer.from for a description of their format.
        # If a block is given, it is registered as a callback for a pending
        # request, and the packet will be flagged so that the server knows a
        # reply is required. If no block is given, the server will send no
        # response to this request. Responses, where required, will cause the
        # callback to be invoked with the channel as the first argument, and
        # either true or false as the second, depending on whether the request
        # succeeded or not. The meaning of "success" and "failure" in this context
        # is dependent on the specific request that was sent.
        #
        #   channel.send_channel_request "shell" do |ch, success|
        #     if success
        #       puts "user shell started successfully"
        #     else
        #       puts "could not start user shell"
        #     end
        #   end
        #
        # Most channel requests you'll want to send are already wrapped in more
        # convenient helper methods (see #exec and #subsystem).
        def send_channel_request(request_name, *data, &callback)
          info { "sending channel request #{request_name.inspect}" }
          fail "Channel open not yet confirmed, please call send_channel_request(or exec) from block of open_channel" unless remote_id

          msg = Buffer.from(:byte, CHANNEL_REQUEST,
                            :long, remote_id, :string, request_name,
                            :bool, !callback.nil?, *data)
          connection.send_message(msg)
          pending_requests << callback if callback
        end

        public # these methods are public, but for Net::SSH internal use only

        # Enqueues pending output at the connection as CHANNEL_DATA packets. This
        # does nothing if the channel has not yet been confirmed open (see
        # #do_open_confirmation). This is called automatically by #process, which
        # is called from the event loop (Connection::Session#process). You will
        # generally not need to invoke it directly.
        def enqueue_pending_output # :nodoc:
          return unless remote_id

          while output.length > 0
            length = output.length
            length = remote_window_size if length > remote_window_size
            length = remote_maximum_packet_size if length > remote_maximum_packet_size

            if length > 0
              connection.send_message(Buffer.from(:byte, CHANNEL_DATA, :long, remote_id, :string, output.read(length)))
              output.consume!
              @remote_window_size -= length
            else
              break
            end
          end
        end

        # Invoked when the server confirms that a channel has been opened.
        # The remote_id is the id of the channel as assigned by the remote host,
        # and max_window and max_packet are the maximum window and maximum
        # packet sizes, respectively. If an open-confirmation callback was
        # given when the channel was created, it is invoked at this time with
        # the channel itself as the sole argument.
        def do_open_confirmation(remote_id, max_window, max_packet) # :nodoc:
          @remote_id = remote_id
          @remote_window_size = @remote_maximum_window_size = max_window
          @remote_maximum_packet_size = max_packet
          connection.forward.agent(self) if connection.options[:forward_agent] && type == "session"
          forward_local_env(connection.options[:send_env]) if connection.options[:send_env]
          set_remote_env(connection.options[:set_env]) if connection.options[:set_env]
          @on_confirm_open.call(self) if @on_confirm_open
        end

        # Invoked when the server failed to open the channel. If an #on_open_failed
        # callback was specified, it will be invoked with the channel, reason code,
        # and description as arguments. Otherwise, a ChannelOpenFailed exception
        # will be raised.
        def do_open_failed(reason_code, description)
          if @on_open_failed
            @on_open_failed.call(self, reason_code, description)
          else
            raise ChannelOpenFailed.new(reason_code, description)
          end
        end

        # Invoked when the server sends a CHANNEL_WINDOW_ADJUST packet, and
        # causes the remote window size to be adjusted upwards by the given
        # number of bytes. This has the effect of allowing more data to be sent
        # from the local end to the remote end of the channel.
        def do_window_adjust(bytes) # :nodoc:
          @remote_maximum_window_size += bytes
          @remote_window_size += bytes
        end

        # Invoked when the server sends a channel request. If any #on_request
        # callback has been registered for the specific type of this request,
        # it is invoked. If +want_reply+ is true, a packet will be sent of
        # either CHANNEL_SUCCESS or CHANNEL_FAILURE type. If there was no callback
        # to handle the request, CHANNEL_FAILURE will be sent. Otherwise,
        # CHANNEL_SUCCESS, unless the callback raised ChannelRequestFailed. The
        # callback should accept the channel as the first argument, and the
        # request-specific data as the second.
        def do_request(request, want_reply, data) # :nodoc:
          result = true

          begin
            callback = @on_request[request] or raise ChannelRequestFailed
            callback.call(self, data)
          rescue ChannelRequestFailed
            result = false
          end

          if want_reply
            msg = Buffer.from(:byte, result ? CHANNEL_SUCCESS : CHANNEL_FAILURE, :long, remote_id)
            connection.send_message(msg)
          end
        end

        # Invokes the #on_data callback when the server sends data to the
        # channel. This will reduce the available window size on the local end,
        # but does not actually throttle requests that come in illegally when
        # the window size is too small. The callback is invoked with the channel
        # as the first argument, and the data as the second.
        def do_data(data) # :nodoc:
          update_local_window_size(data.length)
          @on_data.call(self, data) if @on_data
        end

        # Invokes the #on_extended_data callback when the server sends
        # extended data to the channel. This will reduce the available window
        # size on the local end. The callback is invoked with the channel,
        # type, and data.
        def do_extended_data(type, data)
          update_local_window_size(data.length)
          @on_extended_data.call(self, type, data) if @on_extended_data
        end

        # Invokes the #on_eof callback when the server indicates that no
        # further data is forthcoming. The callback is invoked with the channel
        # as the argument.
        def do_eof
          @on_eof.call(self) if @on_eof
        end

        # Invokes the #on_close callback when the server closes a channel.
        # The channel is the only argument.
        def do_close
          @on_close.call(self) if @on_close
        end

        # Invokes the next pending request callback with +false+ as the second
        # argument.
        def do_failure
          if callback = pending_requests.shift
            callback.call(self, false)
          else
            error { "channel failure received with no pending request to handle it (bug?)" }
          end
        end

        # Invokes the next pending request callback with +true+ as the second
        # argument.
        def do_success
          if callback = pending_requests.shift
            callback.call(self, true)
          else
            error { "channel success received with no pending request to handle it (bug?)" }
          end
        end

        private

        # Runs the SSH event loop until the remote confirmed channel open
        # experimental api
        def wait_until_open_confirmed
          connection.loop { !remote_id }
        end

        LOCAL_WINDOW_SIZE_INCREMENT = 0x20000
        GOOD_LOCAL_MAXIUMUM_WINDOW_SIZE = 10 * LOCAL_WINDOW_SIZE_INCREMENT

        # Updates the local window size by the given amount. If the window
        # size drops to less than half of the local maximum (an arbitrary
        # threshold), a CHANNEL_WINDOW_ADJUST message will be sent to the
        # server telling it that the window size has grown.
        def update_local_window_size(size)
          @local_window_size -= size
          if local_window_size < local_maximum_window_size / 2
            connection.send_message(
              Buffer.from(:byte, CHANNEL_WINDOW_ADJUST, :long, remote_id, :long, LOCAL_WINDOW_SIZE_INCREMENT)
            )
            @local_window_size += LOCAL_WINDOW_SIZE_INCREMENT

            if @local_maximum_window_size < @local_window_size || @local_maximum_window_size < GOOD_LOCAL_MAXIUMUM_WINDOW_SIZE
              @local_maximum_window_size += LOCAL_WINDOW_SIZE_INCREMENT
            end
          end
        end

        # Gets an +Array+ of local environment variables in the remote process'
        # environment.
        # A variable name can either be described by a +Regexp+ or +String+.
        #
        #   channel.forward_local_env [/^GIT_.*$/, "LANG"]
        def forward_local_env(env_variable_patterns)
          Array(env_variable_patterns).each do |env_variable_pattern|
            matched_variables = ENV.find_all do |env_name, _|
              case env_variable_pattern
              when Regexp then env_name =~ env_variable_pattern
              when String then env_name == env_variable_pattern
              end
            end
            matched_variables.each do |env_name, env_value|
              self.env(env_name, env_value)
            end
          end
        end

        # Set a +Hash+ of environment variables in the remote process' environment.
        #
        #   channel.set_remote_env foo: 'bar', baz: 'whale'
        def set_remote_env(env)
          env.each { |key, value| self.env(key, value) }
        end
      end
    end
  end
end
