require 'digest/md5'

require 'active_ldap/adapter/base'

module ActiveLdap
  module Adapter
    class Base
      class << self
        def net_ldap_connection(options)
          require 'active_ldap/adapter/net_ldap_ext'
          NetLdap.new(options)
        end
      end
    end

    class NetLdap < Base
      METHOD = {
        :ssl => :simple_tls,
        :tls => :start_tls,
        :plain => nil,
      }

      def connect(options={})
        super do |host, port, method|
          config = {
            :host => host,
            :port => port,
          }
          if method
            config[:encryption] = { :method => method }
            config[:encryption][:tls_options] = @tls_options if @tls_options
          end
          begin
            uri = construct_uri(host, port, method == :simple_tls)
            with_start_tls = method == :start_tls
            info = {:uri => uri, :with_start_tls => with_start_tls}
            [log("connect", info) {Net::LDAP::Connection.new(config)},
             uri, with_start_tls]
          rescue Net::LDAP::ConnectionError => error
            raise ConnectionError, error.message
          rescue Net::LDAP::Error => error
            message = "#{error.class}: #{error.message}"
            raise ConnectionError, message, caller(0) + error.backtrace
          end
        end
      end

      def unbind(options={})
        super do
          log("unbind") do
            @connection.close # Net::LDAP doesn't implement unbind.
          end
        end
      end

      def bind(options={})
        begin
          super
        rescue Net::LDAP::Error
          raise AuthenticationError, $!.message
        end
      end

      def bind_as_anonymous(options={})
        super do
          execute(:bind, {:name => "bind: anonymous"}, {:method => :anonymous})
          true
        end
      end

      def search(options={})
        super(options) do |search_options|
          scope = search_options[:scope]
          info = search_options.merge(scope: scope_name(scope))
          args = {
            base: search_options[:base],
            scope: scope,
            filter: search_options[:filter],
            attributes: search_options[:attributes],
            size: search_options[:limit],
            paged_searcheds_supported: search_options[:paged_results_supported],
          }
          execute(:search, info, args) do |entry|
            attributes = {}
            entry.original_attribute_names.each do |name|
              value = entry[name]
              attributes[name] = value if value
            end
            yield([entry.dn, attributes])
          end
        end
      end

      def delete(targets, options={})
        super do |target|
          args = {:dn => target}
          info = args.dup
          execute(:delete, info, args)
        end
      end

      def add(dn, entries, options={})
        super do |_dn, _entries|
          attributes = {}
          _entries.each do |type, key, attrs|
            attrs.each do |name, values|
              attributes[name] = values
            end
          end
          args = {:dn => _dn, :attributes => attributes}
          info = args.dup
          execute(:add, info, args)
        end
      end

      def modify(dn, entries, options={})
        super do |_dn, _entries|
          info = {:dn => _dn, :attributes => _entries}
          execute(:modify, info,
                  :dn => _dn,
                  :operations => parse_entries(_entries))
        end
      end

      def modify_rdn(dn, new_rdn, delete_old_rdn, new_superior, options={})
        super do |_dn, _new_rdn, _delete_old_rdn, _new_superior|
          info = {
            :name => "modify: RDN",
            :dn => _dn,
            :new_rdn => _new_rdn,
            :new_superior => _new_superior,
            :delete_old_rdn => _delete_old_rdn
          }
          execute(:rename, info,
                  :olddn => _dn,
                  :newrdn => _new_rdn,
                  :delete_attributes => _delete_old_rdn,
                  :new_superior => _new_superior)
        end
      end

      private
      def execute(method, info=nil, *args, &block)
        name = (info || {}).delete(:name) || method
        result = log(name, info) do
          begin
            @connection.send(method, *args, &block)
          rescue SystemCallError => error
            message = "#{error.class}: #{error.message}"
            raise ConnectionError, message, caller(0) + error.backtrace
          rescue Net::LDAP::ResponseMissingOrInvalidError => error
            message = "#{error.class}: #{error.message}"
            message << ": connection may be timed out"
            raise ConnectionError, message, caller(0) + error.backtrace
          end
        end
        message = nil
        case result
        when Hash
          message = result[:errorMessage]
          result = result[:resultCode]
        when Net::LDAP::PDU
          message = result.error_message
          result = result.result_code
        end
        unless result.zero?
          klass = LdapError::ERRORS[result]
          klass ||= LdapError
          return if klass == LdapError::SizeLimitExceeded
          message = [Net::LDAP.result2string(result), message].compact.join(": ")
          raise klass, message
        end
      end

      def ensure_method(method)
        method ||= "plain"
        normalized_method = method.to_s.downcase.to_sym
        return METHOD[normalized_method] if METHOD.has_key?(normalized_method)

        available_methods = METHOD.keys.collect {|m| m.inspect}.join(", ")
        format = _("%s is not one of the available connect methods: %s")
        raise ConfigurationError, format % [method.inspect, available_methods]
      end

      def ensure_scope(scope)
        scope_map = {
          :base => Net::LDAP::SearchScope_BaseObject,
          :sub => Net::LDAP::SearchScope_WholeSubtree,
          :one => Net::LDAP::SearchScope_SingleLevel,
        }
        value = scope_map[scope || :sub]
        if value.nil?
          available_scopes = scope_map.keys.inspect
          format = _("%s is not one of the available LDAP scope: %s")
          raise ArgumentError, format % [scope.inspect, available_scopes]
        end
        value
      end

      def scope_name(scope)
        {
          Net::LDAP::SearchScope_BaseObject => :base,
          Net::LDAP::SearchScope_WholeSubtree => :sub,
          Net::LDAP::SearchScope_SingleLevel => :one,
        }[scope]
      end

      def sasl_bind(bind_dn, options={})
        super do |_bind_dn, mechanism, quiet|
          normalized_mechanism = mechanism.downcase.gsub(/-/, '_')
          sasl_bind_setup = "sasl_bind_setup_#{normalized_mechanism}"
          next unless respond_to?(sasl_bind_setup, true)
          initial_credential, challenge_response =
            send(sasl_bind_setup, _bind_dn, options)
          args = {
            :method => :sasl,
            :initial_credential => initial_credential,
            :mechanism => mechanism,
            :challenge_response => challenge_response,
          }
          info = {
            :name => "bind: SASL", :dn => _bind_dn, :mechanism => mechanism,
          }
          execute(:bind, info, args)
          true
        end
      end

      def sasl_bind_setup_digest_md5(bind_dn, options)
        initial_credential = ""
        nonce_count = 1
        challenge_response = Proc.new do |cred|
          params = parse_sasl_digest_md5_credential(cred)
          qops = params["qop"].split(/,/)
          unless qops.include?("auth")
            raise ActiveLdap::AuthenticationError,
                  _("unsupported qops: %s") % qops.inspect
          end
          qop = "auth"
          server = @connection.instance_variable_get("@conn").addr[2]
          realm = params['realm']
          uri = "ldap/#{server}"
          nc = "%08x" % nonce_count
          nonce = params["nonce"]
          cnonce = generate_client_nonce
          requests = {
            :username => bind_dn.inspect,
            :realm => realm.inspect,
            :nonce => nonce.inspect,
            :cnonce => cnonce.inspect,
            :nc => nc,
            :qop => qop,
            :maxbuf => "65536",
            "digest-uri" => uri.inspect,
          }
          a1 = "#{bind_dn}:#{realm}:#{password(cred, options)}"
          a1 = "#{Digest::MD5.digest(a1)}:#{nonce}:#{cnonce}"
          ha1 = Digest::MD5.hexdigest(a1)
          a2 = "AUTHENTICATE:#{uri}"
          ha2 = Digest::MD5.hexdigest(a2)
          response = "#{ha1}:#{nonce}:#{nc}:#{cnonce}:#{qop}:#{ha2}"
          requests["response"] = Digest::MD5.hexdigest(response)
          nonce_count += 1
          requests.collect do |key, value|
            "#{key}=#{value}"
          end.join(",")
        end
        [initial_credential, challenge_response]
      end

      def parse_sasl_digest_md5_credential(cred)
        params = {}
        cred.scan(/(\w+)=(\"?)(.+?)\2(?:,|$)/) do |name, sep, value|
          params[name] = value
        end
        params
      end

      CHARS = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
      def generate_client_nonce(size=32)
        nonce = ""
        size.times do |i|
          nonce << CHARS[rand(CHARS.size)]
        end
        nonce
      end

      def simple_bind(bind_dn, options={})
        super do |_bind_dn, password|
          args = {
            :method => :simple,
            :username => _bind_dn,
            :password => password,
          }
          execute(:bind, {:dn => _bind_dn}, args)
          true
        end
      end

      def parse_entries(entries)
        result = []
        entries.each do |type, key, attributes|
          mod_type = ensure_mod_type(type)
          attributes.each do |name, values|
            result << [mod_type, name, values]
          end
        end
        result
      end

      def ensure_mod_type(type)
        case type
        when :replace, :add, :delete
          type
        else
          raise ArgumentError, _("unknown type: %s") % type
        end
      end
    end
  end
end
