module Gitlab
  module LDAP
    class GroupSync
      attr_reader :provider

      # Open a connection so we can run all queries through it.
      # It's more efficient than the default of opening/closing per LDAP query.
      def self.open(provider, &block)
        Gitlab::LDAP::Adapter.open(provider) do |adapter|
          block.call(self.new(provider, adapter))
        end
      end

      def self.execute
        # Shuffle providers to prevent a scenario where sync fails after a time
        # and only the first provider or two get synced. This shuffles the order
        # so subsequent syncs should eventually get to all providers. Obviously
        # we should avoid failure, but this is an additional safeguard.
        Gitlab::LDAP::Config.providers.shuffle.each do |provider|
          self.open(provider) do |group_sync|
            group_sync.update_permissions
          end
        end

        true
      end

      def initialize(provider, adapter = nil)
        @adapter = adapter
        @provider = provider
      end

      def update_permissions
        if group_base.present?
          logger.debug { "Performing LDAP group sync for '#{provider}' provider" }
          sync_groups
          logger.debug { "Finished LDAP group sync for '#{provider}' provider" }
        else
          logger.debug { "No `group_base` configured for '#{provider}' provider. Skipping" }
        end

        if admin_group.present?
          logger.debug { "Syncing admin users for '#{provider}' provider" }
          sync_admin_users
          logger.debug { "Finished syncing admin users for '#{provider}' provider" }
        else
          logger.debug { "No `admin_group` configured for '#{provider}' provider. Skipping" }
        end

        nil
      end

      # Iterate of all GitLab groups with LDAP links. Build an access hash
      # representing a user's highest access level among the LDAP links within
      # the same GitLab group.
      def sync_groups
        # Order results by last_ldap_sync_at ASC so groups with older last
        # sync time are handled first
        groups_where_group_links_with_provider_ordered.each do |group|
          lease = Gitlab::ExclusiveLease.new(
            "ldap_group_sync:#{provider}:#{group.id}",
            timeout: 3600
          )
          next unless lease.try_obtain

          logger.debug { "Syncing '#{group.name}' group" }

          # Only iterate over group links for the current provider
          group.ldap_group_links.with_provider(provider).each do |group_link|
            if member_dns = dns_for_group_cn(group_link.cn)
              access_levels.set(member_dns, to: group_link.group_access)
              logger.debug do
                "Resolved '#{group.name}' group member access: #{access_levels.to_hash}"
              end
            end
          end

          update_existing_group_membership(group)
          add_new_members(group)

          group.update(last_ldap_sync_at: Time.now)

          logger.debug { "Finished syncing '#{group.name}' group" }
        end
      end

      # Update global administrators based on the specified admin group CN
      def sync_admin_users
        admin_group_member_dns = dns_for_group_cn(admin_group)
        current_admin_users = ::User.admins.with_provider(provider)
        verified_admin_users = []

        # Verify existing admin users and add new ones.
        admin_group_member_dns.each do |member_dn|
          user = Gitlab::LDAP::User.find_by_uid_and_provider(member_dn, provider)

          if user.present?
            user.admin = true
            user.save
            verified_admin_users << user
          else
            logger.debug do
              <<-MSG.strip_heredoc.gsub(/\n/, ' ')
                #{self.class.name}: User with DN `#{member_dn}` should have admin
                access but there is no user in GitLab with that identity.
                Membership will be updated once the user signs in for the first time.
              MSG
            end
          end
        end

        # Revoke the unverified admins.
        current_admin_users.each do |user|
          unless verified_admin_users.include?(user)
            user.admin = false
            user.save
          end
        end
      end

      private

      # Cache LDAP group member DNs so we don't query LDAP groups more than once.
      def dns_for_group_cn(group_cn)
        @dns_for_group_cn ||= Hash.new { |h, k| h[k] = ldap_group_member_dns(k) }
        @dns_for_group_cn[group_cn]
      end

      # Cache user DN so we don't generate excess queries to map UID to DN
      def dn_for_uid(uid)
        @dn_for_uid ||= Hash.new { |h, k| h[k] = member_uid_to_dn(k) }
        @dn_for_uid[uid]
      end

      def adapter
        @adapter ||= Gitlab::LDAP::Adapter.new(provider)
      end

      def config
        @config ||= Gitlab::LDAP::Config.new(provider)
      end

      def access_levels
        @access_levels ||= Gitlab::LDAP::AccessLevels.new
      end

      def group_base
        config.group_base
      end

      def admin_group
        config.admin_group
      end

      def ldap_group_member_dns(ldap_group_cn)
        ldap_group = Gitlab::LDAP::Group.find_by_cn(ldap_group_cn, adapter)
        unless ldap_group.present?
          logger.warn { "Cannot find LDAP group with CN '#{ldap_group_cn}'. Skipping" }
          return []
        end

        member_dns = ldap_group.member_dns
        if member_dns.empty?
          # Group must be empty
          return [] unless ldap_group.memberuid?

          members = ldap_group.member_uids
          member_dns = members.map { |uid| dn_for_uid(uid) }
        end
        ensure_full_dns!(member_dns)

        logger.debug { "Members in '#{ldap_group.name}' LDAP group: #{member_dns}" }

        # Various lookups in this method could return `nil` values.
        # Compact the array to remove those entries
        member_dns.compact
      end

      # At least one customer reported that their LDAP `member` values contain
      # only `uid=username` and not the full DN. This method allows us to
      # account for that. See gitlab-ee#442
      def ensure_full_dns!(dns)
        dns.map! do |dn|
          # If there is more than one equal sign we must have a full DN
          # Or at least the probability is higher.
          return dn if dn.count('=') > 1

          # If there is only one equal sign, we may only have a `uid`.
          # In this case, strip the first part and look up full DN by UID
          dn_for_uid(dn.split('=')[1])
        end
      end

      def member_uid_to_dn(uid)
        identity = Identity.find_by(provider: provider, secondary_extern_uid: uid)

        if identity.present?
          # Use the DN on record in GitLab when it's available
          identity.extern_uid
        else
          ldap_user = Gitlab::LDAP::Person.find_by_uid(uid, adapter)

          # Can't find a matching user for group entry
          return nil unless ldap_user.present?

          # Update user identity so we don't have to go through this again
          update_identity(ldap_user.dn, uid)

          ldap_user.dn
        end
      end

      def update_identity(dn, uid)
        identity =
          Identity.find_by(provider: provider, extern_uid: dn)

        # User may not exist in GitLab yet. Skip.
        return unless identity.present?

        identity.secondary_extern_uid = uid
        identity.save
      end

      def update_existing_group_membership(group)
        logger.debug { "Updating existing membership for '#{group.name}' group" }

        select_and_preload_group_members(group).each do |member|
          user = member.user
          identity = user.identities.select(:id, :extern_uid)
                       .with_provider(provider).first
          member_dn = identity.extern_uid

          # Skip if this is not an LDAP user with a valid `extern_uid`.
          next unless member_dn.present?

          # Prevent shifting group membership, in case where user is a member
          # of two LDAP groups from different providers linked to the same
          # GitLab group. This is not ideal, but preserves existing behavior.
          if user.ldap_identity.id != identity.id
            access_levels.delete(member_dn)
            next
          end

          desired_access = access_levels[member_dn]

          # Don't do anything if the user already has the desired access level
          if member.access_level == desired_access
            access_levels.delete(member_dn)
            next
          end

          # Check and update the access level. If `desired_access` is `nil`
          # we need to delete the user from the group.
          if desired_access.present?
            add_or_update_user_membership(user, group, desired_access)

            # Delete this entry from the hash now that we've acted on it
            access_levels.delete(member_dn)
          elsif group.last_owner?(user)
            warn_cannot_remove_last_owner(user, group)
          else
            group.users.delete(user)
          end
        end
      end

      def add_new_members(group)
        logger.debug { "Adding new members to '#{group.name}' group" }

        access_levels.each do |member_dn, access_level|
          user = Gitlab::LDAP::User.find_by_uid_and_provider(member_dn, provider)

          if user.present?
            add_or_update_user_membership(user, group, access_level)
          else
            logger.debug do
              <<-MSG.strip_heredoc.gsub(/\n/, ' ')
                #{self.class.name}: User with DN `#{member_dn}` should have access
                to '#{group.name}' group but there is no user in GitLab with that
                identity. Membership will be updated once the user signs in for
                the first time.
              MSG
            end
          end
        end
      end

      def add_or_update_user_membership(user, group, access)
        # Prevent the last owner of a group from being demoted
        if access < Gitlab::Access::OWNER && group.last_owner?(user)
          warn_cannot_remove_last_owner(user, group)
        else
          # If you pass the user object, instead of just user ID,
          # it saves an extra user database query.
          group.add_users([user], access, skip_notification: true)
        end
      end

      def warn_cannot_remove_last_owner(user, group)
        logger.warn do
          <<-MSG.strip_heredoc.gsub(/\n/, ' ')
            #{self.class.name}: LDAP group sync cannot remove #{user.name}
            (#{user.id}) from group #{group.name} (#{group.id}) as this is
            the group's last owner
          MSG
        end
      end

      def select_and_preload_group_members(group)
        group.members.select_access_level_and_user
          .with_identity_provider(provider).preload(:user)
      end

      def groups_where_group_links_with_provider_ordered
        ::Group.where_group_links_with_provider(provider)
          .preload(:ldap_group_links)
          .reorder('last_ldap_sync_at ASC, namespaces.id ASC')
          .distinct
      end

      def logger
        Rails.logger
      end
    end
  end
end