# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

class Ldap

  # Class for handling LDAP Groups.
  class User
    include Ldap::FilterLookup

    IGNORED_ATTRIBUTES = %i[
      admincount
      accountexpires
      badpasswordtime
      badpwdcount
      countrycode
      distinguishedname
      dnshostname
      dscorepropagationdata
      instancetype
      iscriticalsystemobject
      useraccountcontrol
      usercertificate
      objectclass
      objectcategory
      objectsid
      primarygroupid
      pwdlastset
      lastlogoff
      lastlogon
      lastlogontimestamp
      localpolicyflags
      lockouttime
      logoncount
      logonhours
      msdfsr-computerreferencebl
      msds-supportedencryptiontypes
      ridsetreferences
      samaccounttype
      memberof
      serverreferencebl
      serviceprincipalname
      showinadvancedviewonly
      usnchanged
      usncreated
      whenchanged
      whencreated
    ].freeze

    # Returns the uid attribute.
    #
    # @param attributes [Hash{Symbol=>Array<String>}] A list of LDAP User attributes which should get checked for available uids.
    #
    # @example
    #  Ldap::User.uid_attribute(attributes)
    #
    # @return [String] The uid attribute.
    def self.uid_attribute(attributes)
      result = nil
      %i[objectguid entryuuid samaccountname userprincipalname uid dn].each do |attribute|
        next if attributes[attribute].blank?

        result = attribute.to_s
        break
      end
      result
    end

    # Initializes a wrapper around Net::LDAP and ::Ldap to handle LDAP users.
    #
    # @param [Hash] config the configuration for establishing a LDAP connection. Default is Setting 'ldap_config'.
    # @option config [String] :uid_attribute The uid attribute. Default is determined automatically.
    # @option config [String] :filter The filter for LDAP users. Default is determined automatically.
    # @param ldap [Ldap] An optional existing Ldap class instance. Default is a new connection with given configuration.
    #
    # @example
    #  ldap_user = Ldap::User.new
    #
    # @return [nil]
    def initialize(config, ldap: nil)
      @config = config
      @ldap   = ldap || ::Ldap.new(@config)

      handle_config
    end

    # Checks if given username and password combination is valid for the connected LDAP.
    #
    # @param username [String] The username.
    # @param password [String] The password.
    #
    # @example
    #  ldap_user.valid?('example_user', 'pw1234')
    #  #=> true
    #
    # @return [Boolean] The valid state of the username and password combination.
    def valid?(username, password)
      bind_success = @ldap.connection.bind_as(
        base:     @ldap.base_dn,
        filter:   @user_filter ? "(&(#{login_attribute}=#{username})#{@user_filter})" : "(#{login_attribute}=#{username})",
        password: password
      )

      message = bind_success ? 'successful' : 'failed'
      Rails.logger.info "ldap authentication for user '#{username}' (#{login_attribute}) #{message}!"
      bind_success.present?
    end

    # Determines possible User attributes with example values.
    #
    # @param filter [String] The filter for listing users. Default is initialization parameter.
    # @param base_dn [String] The applied base DN for listing users. Default is Ldap#base_dn.
    #
    # @example
    #  ldap_user.attributes
    #  #=> {:dn=>"dn (e. g. CN=Administrator,CN=Users,DC=domain,DC=tld)", ...}
    #
    # @return [Hash{Symbol=>String}] The available User attributes as key and the name and an example as value.
    def attributes(custom_filter: nil, base_dn: nil)
      @attributes ||= begin
        attributes     = {}.with_indifferent_access
        lookup_counter = 0

        # collect sample attributes
        @ldap.search(custom_filter || filter, base: base_dn) do |entry|
          pre_merge_count = attributes.count

          attributes.reverse_merge!(entry.to_h
                                         .except(*IGNORED_ATTRIBUTES)
                                         .transform_values(&:first)
                                         .compact)

          # check max 50 entries with the same attributes in a row
          lookup_counter = (pre_merge_count < attributes.count ? 0 : lookup_counter.next)
          break if lookup_counter >= 50
        end

        # format sample values for presentation
        attributes.each do |name, value|
          attributes[name] = if value.encoding == Encoding.find('ascii-8bit')
                               "#{name} (binary data)"
                             else
                               "#{name} (e.g., #{value.utf8_encode})"
                             end
        end
      end
    end

    # The active filter of the instance. If none give on initialization an automatic lookup is performed.
    #
    # @example
    #  ldap_user.filter
    #  #=> '(objectClass=user)'
    #
    # @return [String, nil] The active or found filter or nil if none could be found.
    def filter
      @filter ||= lookup_filter(['(&(objectClass=user)(samaccountname=*)(!(samaccountname=*$)))', '(objectClass=user)', '(objectClass=posixaccount)', '(objectClass=person)'])
    end

    # The active uid attribute of the instance. If none give on initialization an automatic lookup is performed.
    #
    # @example
    #  ldap_user.uid_attribute
    #  #=> 'samaccountname'
    #
    # @return [String, nil] The active or found uid attribute or nil if none could be found.
    def uid_attribute
      @uid_attribute ||= self.class.uid_attribute(attributes)
    end

    private

    attr_reader :config

    def login_attribute
      @login_attribute ||= config[:user_attributes]&.key('login') || uid_attribute
    end

    def handle_config
      return if config.blank?

      @uid_attribute = config[:uid_attribute]
      @filter        = config[:filter]
      @user_filter   = config[:user_filter]
    end
  end
end