# This is a private class used internally by the library. It should not
# be called by user code.
class Net::LDAP::Connection #:nodoc:
  include Net::LDAP::Instrumentation

  LdapVersion = 3
  MaxSaslChallenges = 10

  def initialize(server)
    @instrumentation_service = server[:instrumentation_service]
    server[:hosts] = [[server[:host], server[:port]]] if server[:hosts].nil?

    if server[:socket]
      prepare_socket(server)
    else
      open_connection(server)
    end

    yield self if block_given?
  end

  def prepare_socket(server)
    @conn = server[:socket]

    if server[:encryption]
      setup_encryption server[:encryption]
    end
  end

  def open_connection(server)
    errors = []
    server[:hosts].each do |host, port|
      begin
        return connect_to_host(host, port, server)
      rescue Net::LDAP::Error
        errors << $!
      end
    end

    raise errors.first if errors.size == 1
    raise Net::LDAP::Error,
      "Unable to connect to any given server: \n  #{errors.join("\n  ")}"
  end

  def connect_to_host(host, port, server)
    begin
      @conn = TCPSocket.new(host, port)
    rescue SocketError
      raise Net::LDAP::Error, "No such address or other socket error."
    rescue Errno::ECONNREFUSED
      raise Net::LDAP::ConnectionRefusedError, "Server #{host} refused connection on port #{port}."
    rescue Errno::EHOSTUNREACH => error
      raise Net::LDAP::Error, "Host #{host} was unreachable (#{error.message})"
    rescue Errno::ETIMEDOUT
      raise Net::LDAP::Error, "Connection to #{host} timed out."
    end

    if server[:encryption]
      setup_encryption server[:encryption]
    end
  end

  module GetbyteForSSLSocket
    def getbyte
      getc.ord
    end
  end

  module FixSSLSocketSyncClose
    def close
      super
      io.close
    end
  end

  def self.wrap_with_ssl(io, tls_options = {})
    raise Net::LDAP::NoOpenSSLError, "OpenSSL is unavailable" unless Net::LDAP::HasOpenSSL

    ctx = OpenSSL::SSL::SSLContext.new

    # By default, we do not verify certificates. For a 1.0 release, this should probably be changed at some point.
    # See discussion in https://github.com/ruby-ldap/ruby-net-ldap/pull/161
    ctx.set_params(tls_options) unless tls_options.empty?

    conn = OpenSSL::SSL::SSLSocket.new(io, ctx)
    conn.connect

    # Doesn't work:
    # conn.sync_close = true

    conn.extend(GetbyteForSSLSocket) unless conn.respond_to?(:getbyte)
    conn.extend(FixSSLSocketSyncClose)

    conn
  end

  #--
  # Helper method called only from prepare_socket or open_connection, and only
  # after we have a successfully-opened @conn instance variable, which is a TCP
  # connection.  Depending on the received arguments, we establish SSL,
  # potentially replacing the value of @conn accordingly. Don't generate any
  # errors here if no encryption is requested. DO raise Net::LDAP::Error objects
  # if encryption is requested and we have trouble setting it up. That includes
  # if OpenSSL is not set up on the machine. (Question: how does the Ruby
  # OpenSSL wrapper react in that case?) DO NOT filter exceptions raised by the
  # OpenSSL library. Let them pass back to the user. That should make it easier
  # for us to debug the problem reports. Presumably (hopefully?) that will also
  # produce recognizable errors if someone tries to use this on a machine
  # without OpenSSL.
  #
  # The simple_tls method is intended as the simplest, stupidest, easiest
  # solution for people who want nothing more than encrypted comms with the
  # LDAP server. It doesn't do any server-cert validation and requires
  # nothing in the way of key files and root-cert files, etc etc. OBSERVE:
  # WE REPLACE the value of @conn, which is presumed to be a connected
  # TCPSocket object.
  #
  # The start_tls method is supported by many servers over the standard LDAP
  # port. It does not require an alternative port for encrypted
  # communications, as with simple_tls. Thanks for Kouhei Sutou for
  # generously contributing the :start_tls path.
  #++
  def setup_encryption(args)
    args[:tls_options] ||= {}
    case args[:method]
    when :simple_tls
      @conn = self.class.wrap_with_ssl(@conn, args[:tls_options])
      # additional branches requiring server validation and peer certs, etc.
      # go here.
    when :start_tls
      message_id = next_msgid
      request    = [
        Net::LDAP::StartTlsOid.to_ber_contextspecific(0)
      ].to_ber_appsequence(Net::LDAP::PDU::ExtendedRequest)

      write(request, nil, message_id)
      pdu = queued_read(message_id)

      if pdu.nil? || pdu.app_tag != Net::LDAP::PDU::ExtendedResponse
        raise Net::LDAP::NoStartTLSResultError, "no start_tls result"
      end

      if pdu.result_code.zero?
        @conn = self.class.wrap_with_ssl(@conn, args[:tls_options])
      else
        raise Net::LDAP::StartTlSError, "start_tls failed: #{pdu.result_code}"
      end
    else
      raise Net::LDAP::EncMethodUnsupportedError, "unsupported encryption method #{args[:method]}"
    end
  end

  #--
  # This is provided as a convenience method to make sure a connection
  # object gets closed without waiting for a GC to happen. Clients shouldn't
  # have to call it, but perhaps it will come in handy someday.
  #++
  def close
    @conn.close
    @conn = nil
  end

  # Internal: Reads messages by ID from a queue, falling back to reading from
  # the connected socket until a message matching the ID is read. Any messages
  # with mismatched IDs gets queued for subsequent reads by the origin of that
  # message ID.
  #
  # Returns a Net::LDAP::PDU object or nil.
  def queued_read(message_id)
    if pdu = message_queue[message_id].shift
      return pdu
    end

    # read messages until we have a match for the given message_id
    while pdu = read
      if pdu.message_id == message_id
        return pdu
      else
        message_queue[pdu.message_id].push pdu
        next
      end
    end

    pdu
  end

  # Internal: The internal queue of messages, read from the socket, grouped by
  # message ID.
  #
  # Used by `queued_read` to return messages sent by the server with the given
  # ID. If no messages are queued for that ID, `queued_read` will `read` from
  # the socket and queue messages that don't match the given ID for other
  # readers.
  #
  # Returns the message queue Hash.
  def message_queue
    @message_queue ||= Hash.new do |hash, key|
      hash[key] = []
    end
  end

  # Internal: Reads and parses data from the configured connection.
  #
  # - syntax: the BER syntax to use to parse the read data with
  #
  # Returns parsed Net::LDAP::PDU object.
  def read(syntax = Net::LDAP::AsnSyntax)
    ber_object =
      instrument "read.net_ldap_connection", :syntax => syntax do |payload|
        @conn.read_ber(syntax) do |id, content_length|
          payload[:object_type_id] = id
          payload[:content_length] = content_length
        end
      end

    return unless ber_object

    instrument "parse_pdu.net_ldap_connection" do |payload|
      pdu = payload[:pdu]  = Net::LDAP::PDU.new(ber_object)

      payload[:message_id] = pdu.message_id
      payload[:app_tag]    = pdu.app_tag

      pdu
    end
  end
  private :read

  # Internal: Write a BER formatted packet with the next message id to the
  # configured connection.
  #
  # - request: required BER formatted request
  # - controls: optional BER formatted controls
  #
  # Returns the return value from writing to the connection, which in some
  # cases is the Integer number of bytes written to the socket.
  def write(request, controls = nil, message_id = next_msgid)
    instrument "write.net_ldap_connection" do |payload|
      packet = [message_id.to_ber, request, controls].compact.to_ber_sequence
      payload[:content_length] = @conn.write(packet)
    end
  end
  private :write

  def next_msgid
    @msgid ||= 0
    @msgid += 1
  end

  def bind(auth)
    instrument "bind.net_ldap_connection" do |payload|
      payload[:method] = meth = auth[:method]
      if [:simple, :anonymous, :anon].include?(meth)
        bind_simple auth
      elsif meth == :sasl
        bind_sasl(auth)
      elsif meth == :gss_spnego
        bind_gss_spnego(auth)
      else
        raise Net::LDAP::AuthMethodUnsupportedError, "Unsupported auth method (#{meth})"
      end
    end
  end

  #--
  # Implements a simple user/psw authentication. Accessed by calling #bind
  # with a method of :simple or :anonymous.
  #++
  def bind_simple(auth)
    user, psw = if auth[:method] == :simple
                  [auth[:username] || auth[:dn], auth[:password]]
                else
                  ["", ""]
                end

    raise Net::LDAP::BindingInformationInvalidError, "Invalid binding information" unless (user && psw)

    message_id = next_msgid
    request    = [
      LdapVersion.to_ber, user.to_ber,
      psw.to_ber_contextspecific(0)
    ].to_ber_appsequence(Net::LDAP::PDU::BindRequest)

    write(request, nil, message_id)
    pdu = queued_read(message_id)

    if !pdu || pdu.app_tag != Net::LDAP::PDU::BindResult
      raise Net::LDAP::NoBindResultError, "no bind result"
    end

    pdu
  end

  #--
  # Required parameters: :mechanism, :initial_credential and
  # :challenge_response
  #
  # Mechanism is a string value that will be passed in the SASL-packet's
  # "mechanism" field.
  #
  # Initial credential is most likely a string. It's passed in the initial
  # BindRequest that goes to the server. In some protocols, it may be empty.
  #
  # Challenge-response is a Ruby proc that takes a single parameter and
  # returns an object that will typically be a string. The
  # challenge-response block is called when the server returns a
  # BindResponse with a result code of 14 (saslBindInProgress). The
  # challenge-response block receives a parameter containing the data
  # returned by the server in the saslServerCreds field of the LDAP
  # BindResponse packet. The challenge-response block may be called multiple
  # times during the course of a SASL authentication, and each time it must
  # return a value that will be passed back to the server as the credential
  # data in the next BindRequest packet.
  #++
  def bind_sasl(auth)
    mech, cred, chall = auth[:mechanism], auth[:initial_credential],
      auth[:challenge_response]
    raise Net::LDAP::BindingInformationInvalidError, "Invalid binding information" unless (mech && cred && chall)

    message_id = next_msgid

    n = 0
    loop {
      sasl = [mech.to_ber, cred.to_ber].to_ber_contextspecific(3)
      request = [
        LdapVersion.to_ber, "".to_ber, sasl
      ].to_ber_appsequence(Net::LDAP::PDU::BindRequest)

      write(request, nil, message_id)
      pdu = queued_read(message_id)

      if !pdu || pdu.app_tag != Net::LDAP::PDU::BindResult
        raise Net::LDAP::NoBindResultError, "no bind result"
      end

      return pdu unless pdu.result_code == Net::LDAP::ResultCodeSaslBindInProgress
      raise Net::LDAP::SASLChallengeOverflowError, "sasl-challenge overflow" if ((n += 1) > MaxSaslChallenges)

      cred = chall.call(pdu.result_server_sasl_creds)
    }

    raise Net::LDAP::SASLChallengeOverflowError, "why are we here?"
  end
  private :bind_sasl

  #--
  # PROVISIONAL, only for testing SASL implementations. DON'T USE THIS YET.
  # Uses Kohei Kajimoto's Ruby/NTLM. We have to find a clean way to
  # integrate it without introducing an external dependency.
  #
  # This authentication method is accessed by calling #bind with a :method
  # parameter of :gss_spnego. It requires :username and :password
  # attributes, just like the :simple authentication method. It performs a
  # GSS-SPNEGO authentication with the server, which is presumed to be a
  # Microsoft Active Directory.
  #++
  def bind_gss_spnego(auth)
    require 'ntlm'

    user, psw = [auth[:username] || auth[:dn], auth[:password]]
    raise Net::LDAP::BindingInformationInvalidError, "Invalid binding information" unless (user && psw)

    nego = proc { |challenge|
      t2_msg = NTLM::Message.parse(challenge)
      t3_msg = t2_msg.response({ :user => user, :password => psw },
                               { :ntlmv2 => true })
      t3_msg.serialize
    }

    bind_sasl(:method => :sasl, :mechanism => "GSS-SPNEGO",
              :initial_credential => NTLM::Message::Type1.new.serialize,
              :challenge_response => nego)
  end
  private :bind_gss_spnego


  #--
  # Allow the caller to specify a sort control
  #
  # The format of the sort control needs to be:
  #
  # :sort_control => ["cn"]  # just a string
  # or
  # :sort_control => [["cn", "matchingRule", true]] #attribute, matchingRule, direction (true / false)
  # or
  # :sort_control => ["givenname","sn"] #multiple strings or arrays
  #
  def encode_sort_controls(sort_definitions)
    return sort_definitions unless sort_definitions

    sort_control_values = sort_definitions.map do |control|
      control = Array(control) # if there is only an attribute name as a string then infer the orderinrule and reverseorder
      control[0] = String(control[0]).to_ber,
      control[1] = String(control[1]).to_ber,
      control[2] = (control[2] == true).to_ber
      control.to_ber_sequence
    end
    sort_control = [
      Net::LDAP::LDAPControls::SORT_REQUEST.to_ber,
      false.to_ber,
      sort_control_values.to_ber_sequence.to_s.to_ber
    ].to_ber_sequence
  end

  #--
  # Alternate implementation, this yields each search entry to the caller as
  # it are received.
  #
  # TODO: certain search parameters are hardcoded.
  # TODO: if we mis-parse the server results or the results are wrong, we
  # can block forever. That's because we keep reading results until we get a
  # type-5 packet, which might never come. We need to support the time-limit
  # in the protocol.
  #++
  def search(args = nil)
    args ||= {}

    # filtering, scoping, search base
    # filter: https://tools.ietf.org/html/rfc4511#section-4.5.1.7
    # base:   https://tools.ietf.org/html/rfc4511#section-4.5.1.1
    # scope:  https://tools.ietf.org/html/rfc4511#section-4.5.1.2
    filter = args[:filter] || Net::LDAP::Filter.eq("objectClass", "*")
    base   = args[:base]
    scope  = args[:scope] || Net::LDAP::SearchScope_WholeSubtree

    # attr handling
    # attrs:      https://tools.ietf.org/html/rfc4511#section-4.5.1.8
    # attrs_only: https://tools.ietf.org/html/rfc4511#section-4.5.1.6
    attrs  = Array(args[:attributes])
    attrs_only = args[:attributes_only] == true

    # references
    # refs:  https://tools.ietf.org/html/rfc4511#section-4.5.3
    # deref: https://tools.ietf.org/html/rfc4511#section-4.5.1.3
    refs   = args[:return_referrals] == true
    deref  = args[:deref] || Net::LDAP::DerefAliases_Never

    # limiting, paging, sorting
    # size: https://tools.ietf.org/html/rfc4511#section-4.5.1.4
    # time: https://tools.ietf.org/html/rfc4511#section-4.5.1.5
    size   = args[:size].to_i
    time   = args[:time].to_i
    paged  = args[:paged_searches_supported]
    sort   = args.fetch(:sort_controls, false)

    # arg validation
    raise ArgumentError, "search base is required" unless base
    raise ArgumentError, "invalid search-size" unless size >= 0
    raise ArgumentError, "invalid search scope" unless Net::LDAP::SearchScopes.include?(scope)
    raise ArgumentError, "invalid alias dereferencing value" unless Net::LDAP::DerefAliasesArray.include?(deref)

    # arg transforms
    filter = Net::LDAP::Filter.construct(filter) if filter.is_a?(String)
    ber_attrs = attrs.map { |attr| attr.to_s.to_ber }
    ber_sort  = encode_sort_controls(sort)

    # An interesting value for the size limit would be close to A/D's
    # built-in page limit of 1000 records, but openLDAP newer than version
    # 2.2.0 chokes on anything bigger than 126. You get a silent error that
    # is easily visible by running slapd in debug mode. Go figure.
    #
    # Changed this around 06Sep06 to support a caller-specified search-size
    # limit. Because we ALWAYS do paged searches, we have to work around the
    # problem that it's not legal to specify a "normal" sizelimit (in the
    # body of the search request) that is larger than the page size we're
    # requesting. Unfortunately, I have the feeling that this will break
    # with LDAP servers that don't support paged searches!!!
    #
    # (Because we pass zero as the sizelimit on search rounds when the
    # remaining limit is larger than our max page size of 126. In these
    # cases, I think the caller's search limit will be ignored!)
    #
    # CONFIRMED: This code doesn't work on LDAPs that don't support paged
    # searches when the size limit is larger than 126. We're going to have
    # to do a root-DSE record search and not do a paged search if the LDAP
    # doesn't support it. Yuck.
    rfc2696_cookie = [126, ""]
    result_pdu = nil
    n_results = 0

    message_id = next_msgid

    instrument "search.net_ldap_connection",
               message_id: message_id,
               filter:     filter,
               base:       base,
               scope:      scope,
               size:       size,
               time:       time,
               sort:       sort,
               referrals:  refs,
               deref:      deref,
               attributes: attrs do |payload|
      loop do
        # should collect this into a private helper to clarify the structure
        query_limit = 0
        if size > 0
          if paged
            query_limit = (((size - n_results) < 126) ? (size -
                                                              n_results) : 0)
          else
            query_limit = size
          end
        end

        request = [
          base.to_ber,
          scope.to_ber_enumerated,
          deref.to_ber_enumerated,
          query_limit.to_ber, # size limit
          time.to_ber,
          attrs_only.to_ber,
          filter.to_ber,
          ber_attrs.to_ber_sequence
        ].to_ber_appsequence(Net::LDAP::PDU::SearchRequest)

        # rfc2696_cookie sometimes contains binary data from Microsoft Active Directory
        # this breaks when calling to_ber. (Can't force binary data to UTF-8)
        # we have to disable paging (even though server supports it) to get around this...

        controls = []
        controls <<
          [
            Net::LDAP::LDAPControls::PAGED_RESULTS.to_ber,
            # Criticality MUST be false to interoperate with normal LDAPs.
            false.to_ber,
            rfc2696_cookie.map{ |v| v.to_ber}.to_ber_sequence.to_s.to_ber
          ].to_ber_sequence if paged
        controls << ber_sort if ber_sort
        controls = controls.empty? ? nil : controls.to_ber_contextspecific(0)

        write(request, controls, message_id)

        result_pdu = nil
        controls = []

        while pdu = queued_read(message_id)
          case pdu.app_tag
          when Net::LDAP::PDU::SearchReturnedData
            n_results += 1
            yield pdu.search_entry if block_given?
          when Net::LDAP::PDU::SearchResultReferral
            if refs
              if block_given?
                se = Net::LDAP::Entry.new
                se[:search_referrals] = (pdu.search_referrals || [])
                yield se
              end
            end
          when Net::LDAP::PDU::SearchResult
            result_pdu = pdu
            controls = pdu.result_controls
            if refs && pdu.result_code == Net::LDAP::ResultCodeReferral
              if block_given?
                se = Net::LDAP::Entry.new
                se[:search_referrals] = (pdu.search_referrals || [])
                yield se
              end
            end
            break
          else
            raise Net::LDAP::ResponseTypeInvalidError, "invalid response-type in search: #{pdu.app_tag}"
          end
        end

        # count number of pages of results
        payload[:page_count] ||= 0
        payload[:page_count]  += 1

        # When we get here, we have seen a type-5 response. If there is no
        # error AND there is an RFC-2696 cookie, then query again for the next
        # page of results. If not, we're done. Don't screw this up or we'll
        # break every search we do.
        #
        # Noticed 02Sep06, look at the read_ber call in this loop, shouldn't
        # that have a parameter of AsnSyntax? Does this just accidentally
        # work? According to RFC-2696, the value expected in this position is
        # of type OCTET STRING, covered in the default syntax supported by
        # read_ber, so I guess we're ok.
        more_pages = false
        if result_pdu.result_code == Net::LDAP::ResultCodeSuccess and controls
          controls.each do |c|
            if c.oid == Net::LDAP::LDAPControls::PAGED_RESULTS
              # just in case some bogus server sends us more than 1 of these.
              more_pages = false
              if c.value and c.value.length > 0
                cookie = c.value.read_ber[1]
                if cookie and cookie.length > 0
                  rfc2696_cookie[1] = cookie
                  more_pages = true
                end
              end
            end
          end
        end

        break unless more_pages
      end # loop

      # track total result count
      payload[:result_count] = n_results

      result_pdu || OpenStruct.new(:status => :failure, :result_code => Net::LDAP::ResultCodeOperationsError, :message => "Invalid search")
    end # instrument
  ensure

    # clean up message queue for this search
    messages = message_queue.delete(message_id)

    # in the exceptional case some messages were *not* consumed from the queue,
    # instrument the event but do not fail.
    if !messages.nil? && !messages.empty?
      instrument "search_messages_unread.net_ldap_connection",
                 message_id: message_id, messages: messages
    end
  end

  MODIFY_OPERATIONS = { #:nodoc:
    :add => 0,
    :delete => 1,
    :replace => 2
  }

  def self.modify_ops(operations)
    ops = []
    if operations
      operations.each { |op, attrib, values|
        # TODO, fix the following line, which gives a bogus error if the
        # opcode is invalid.
        op_ber = MODIFY_OPERATIONS[op.to_sym].to_ber_enumerated
        values = [ values ].flatten.map { |v| v.to_ber if v }.to_ber_set
        values = [ attrib.to_s.to_ber, values ].to_ber_sequence
        ops << [ op_ber, values ].to_ber
      }
    end
    ops
  end

  #--
  # TODO: need to support a time limit, in case the server fails to respond.
  # TODO: We're throwing an exception here on empty DN. Should return a
  # proper error instead, probaby from farther up the chain.
  # TODO: If the user specifies a bogus opcode, we'll throw a confusing
  # error here ("to_ber_enumerated is not defined on nil").
  #++
  def modify(args)
    modify_dn = args[:dn] or raise "Unable to modify empty DN"
    ops = self.class.modify_ops args[:operations]

    message_id = next_msgid
    request    = [
      modify_dn.to_ber,
      ops.to_ber_sequence
    ].to_ber_appsequence(Net::LDAP::PDU::ModifyRequest)

    write(request, nil, message_id)
    pdu = queued_read(message_id)

    if !pdu || pdu.app_tag != Net::LDAP::PDU::ModifyResponse
      raise Net::LDAP::ResponseMissingOrInvalidError, "response missing or invalid"
    end

    pdu
  end

  #--
  # TODO: need to support a time limit, in case the server fails to respond.
  # Unlike other operation-methods in this class, we return a result hash
  # rather than a simple result number. This is experimental, and eventually
  # we'll want to do this with all the others. The point is to have access
  # to the error message and the matched-DN returned by the server.
  #++
  def add(args)
    add_dn = args[:dn] or raise Net::LDAP::EmptyDNError, "Unable to add empty DN"
    add_attrs = []
    a = args[:attributes] and a.each { |k, v|
      add_attrs << [ k.to_s.to_ber, Array(v).map { |m| m.to_ber}.to_ber_set ].to_ber_sequence
    }

    message_id = next_msgid
    request    = [add_dn.to_ber, add_attrs.to_ber_sequence].to_ber_appsequence(Net::LDAP::PDU::AddRequest)

    write(request, nil, message_id)
    pdu = queued_read(message_id)

    if !pdu || pdu.app_tag != Net::LDAP::PDU::AddResponse
      raise Net::LDAP::ResponseMissingOrInvalidError, "response missing or invalid"
    end

    pdu
  end

  #--
  # TODO: need to support a time limit, in case the server fails to respond.
  #++
  def rename(args)
    old_dn = args[:olddn] or raise "Unable to rename empty DN"
    new_rdn = args[:newrdn] or raise "Unable to rename to empty RDN"
    delete_attrs = args[:delete_attributes] ? true : false
    new_superior = args[:new_superior]

    message_id = next_msgid
    request    = [old_dn.to_ber, new_rdn.to_ber, delete_attrs.to_ber]
    request   << new_superior.to_ber_contextspecific(0) unless new_superior == nil

    write(request.to_ber_appsequence(Net::LDAP::PDU::ModifyRDNRequest), nil, message_id)
    pdu = queued_read(message_id)

    if !pdu || pdu.app_tag != Net::LDAP::PDU::ModifyRDNResponse
      raise Net::LDAP::ResponseMissingOrInvalidError.new "response missing or invalid"
    end

    pdu
  end

  #--
  # TODO, need to support a time limit, in case the server fails to respond.
  #++
  def delete(args)
    dn = args[:dn] or raise "Unable to delete empty DN"
    controls   = args.include?(:control_codes) ? args[:control_codes].to_ber_control : nil #use nil so we can compact later
    message_id = next_msgid
    request    = dn.to_s.to_ber_application_string(Net::LDAP::PDU::DeleteRequest)

    write(request, controls, message_id)
    pdu = queued_read(message_id)

    if !pdu || pdu.app_tag != Net::LDAP::PDU::DeleteResponse
      raise Net::LDAP::ResponseMissingOrInvalidError, "response missing or invalid"
    end

    pdu
  end
end # class Connection
