require 'net/ssh'

module Net; module SSH; module Multi
  # Encapsulates the connection information for a single remote server, as well
  # as the Net::SSH session corresponding to that information. You'll rarely
  # need to instantiate one of these directly: instead, you should use
  # Net::SSH::Multi::Session#use.
  class Server
    include Comparable

    # The Net::SSH::Multi::Session instance that manages this server instance.
    attr_reader :master

    # The host name (or IP address) of the server to connect to.
    attr_reader :host

    # The user name to use when logging into the server.
    attr_reader :user

    # The Hash of additional options to pass to Net::SSH when connecting
    # (including things like :password, and so forth).
    attr_reader :options

    # The Net::SSH::Gateway instance to use to establish the connection. Will
    # be +nil+ if the connection should be established without a gateway.
    attr_reader :gateway

    # Creates a new Server instance with the given connection information. The
    # +master+ argument must be a reference to the Net::SSH::Multi::Session
    # instance that will manage this server reference. The +options+ hash must
    # conform to the options described for Net::SSH::start, with two additions:
    #
    # * :via => a Net::SSH::Gateway instance to use when establishing a
    #   connection to this server.
    # * :user => the name of the user to use when logging into this server.
    #
    # The +host+ argument may include the username and port number, in which
    # case those values take precedence over similar values given in the +options+:
    #
    #   server = Net::SSH::Multi::Server.new(session, 'user@host:1234')
    #   puts server.user #-> user
    #   puts server.port #-> 1234
    def initialize(master, host, options={})
      @master = master
      @options = options.dup

      @user, @host, port = host.match(/^(?:([^;,:=]+)@|)(.*?)(?::(\d+)|)$/)[1,3]

      user_opt, port_opt = @options.delete(:user), @options.delete(:port)

      @user = @user || user_opt || master.default_user
      port ||= port_opt

      @options[:port] = port.to_i if port

      @gateway = @options.delete(:via)
      @failed = false
    end

    # Returns the value of the server property with the given +key+. Server
    # properties are described via the +:properties+ key in the options hash
    # when defining the Server.
    def [](key)
      (options[:properties] || {})[key]
    end

    # Sets the given key/value pair in the +:properties+ key in the options
    # hash. If the options hash has no :properties key, it will be created.
    def []=(key, value)
      (options[:properties] ||= {})[key] = value
    end

    # Returns the port number to use for this connection.
    def port
      options[:port] || 22
    end

    # Gives server definitions a sort order, and allows comparison.
    def <=>(server)
      [host, port, user] <=> [server.host, server.port, server.user]
    end

    alias :eql? :==

    # Generates a +Fixnum+ hash value for this object. This function has the
    # property that +a.eql?(b)+ implies +a.hash == b.hash+. The
    # hash value is used by class +Hash+. Any hash value that exceeds the
    # capacity of a +Fixnum+ will be truncated before being used.
    def hash
      @hash ||= [host, user, port].hash
    end

    # Returns a human-readable representation of this server instance.
    def to_s
      @to_s ||= begin
        s = "#{user}@#{host}"
        s << ":#{options[:port]}" if options[:port]
        s
      end
    end

    # Returns a human-readable representation of this server instance.
    def inspect
      @inspect ||= "#<%s:0x%x %s>" % [self.class.name, object_id, to_s]
    end

    # Returns +true+ if this server has ever failed a connection attempt.
    def failed?
      @failed
    end

    # Indicates (by default) that this server has just failed a connection
    # attempt. If +flag+ is false, this can be used to reset the failed flag
    # so that a retry may be attempted.
    def fail!(flag=true)
      @failed = flag
    end

    # Returns the Net::SSH session object for this server. If +require_session+
    # is false and the session has not previously been created, this will
    # return +nil+. If +require_session+ is true, the session will be instantiated
    # if it has not already been instantiated, via the +gateway+ if one is
    # given, or directly (via Net::SSH::start) otherwise.
    #
    #   if server.session.nil?
    #     puts "connecting..."
    #     server.session(true)
    #   end
    #
    # Note that the sessions returned by this are "enhanced" slightly, to make
    # them easier to deal with in a multi-session environment: they have a
    # :server property automatically set on them, that refers to this object
    # (the Server instance that spawned them).
    #
    #   assert_equal server, server.session[:server]
    def session(require_session=false)
      return @session if @session || !require_session
      @session ||= master.next_session(self)
    end

    # Returns +true+ if the session has been opened, and the session is currently
    # busy (as defined by Net::SSH::Connection::Session#busy?).
    # Also returns false if the server has failed to connect.
    def busy?(include_invisible=false)
      !failed? && session && session.busy?(include_invisible)
    end

    # Closes this server's session. If the session has not yet been opened,
    # this does nothing.
    def close
      session.close if session
    ensure
      master.server_closed(self) if session
      @session = nil
    end

    public # but not published, e.g., these are used internally only...

      # Indicate that the session currently in use by this server instance
      # should be replaced by the given +session+ argument. This is used when
      # a pending session has been "realized". Note that this does not
      # actually replace the session--see #update_session! for that.
      def replace_session(session) #:nodoc:
        @ready_session = session
      end

      # If a new session has been made ready (see #replace_session), this
      # will replace the current session with the "ready" session. This
      # method is called from the event loop to ensure that sessions are
      # swapped in at the appropriate point (instead of in the middle of an
      # event poll).
      def update_session! #:nodoc:
        if @ready_session
          @session, @ready_session = @ready_session, nil
        end
      end

      # Returns a new session object based on this server's connection
      # criteria. Note that this will not associate the session with the
      # server, and should not be called directly; it is called internally
      # from Net::SSH::Multi::Session when a new session is required.
      def new_session #:nodoc:
        session = if gateway
          gateway.ssh(host, user, options)
        else
          Net::SSH.start(host, user, options)
        end

        session[:server] = self
        session
      rescue ::Timeout::Error => error
        raise Net::SSH::ConnectionTimeout.new("#{error.message} for #{host}")
      rescue Net::SSH::AuthenticationFailed => error
        raise Net::SSH::AuthenticationFailed.new("#{error.message}@#{host}")
      end

      # Closes all open channels on this server's session. If the session has
      # not yet been opened, this does nothing.
      def close_channels #:nodoc:
        session.channels.each { |id, channel| channel.close } if session
      end

      # Runs the session's preprocess action, if the session has been opened.
      def preprocess #:nodoc:
        session.preprocess if session
      end

      # Returns all registered readers on the session, or an empty array if the
      # session is not open.
      def readers #:nodoc:
        return [] unless session
        session.listeners.keys.reject { |io| io.closed? }
      end

      # Returns all registered and pending writers on the session, or an empty
      # array if the session is not open.
      def writers #:nodoc:
        readers.select do |io|
          io.respond_to?(:pending_write?) && io.pending_write?
        end
      end

      # Runs the post-process action on the session, if the session has been
      # opened. Only the +readers+ and +writers+ that actually belong to this
      # session will be postprocessed by this server.
      def postprocess(readers, writers) #:nodoc:
        return true unless session
        listeners = session.listeners.keys
        session.postprocess(listeners & readers, listeners & writers)
      end
  end
end; end; end
