# frozen_string_literal: true

module EE
  module Gitlab
    module Auth
      module LDAP
        module Sync
          class Group
            attr_reader :provider, :group, :proxy

            class << self
              # Sync members across all providers for the given group.
              def execute_all_providers(group)
                return unless ldap_sync_ready?(group)

                begin
                  group.start_ldap_sync
                  Rails.logger.debug { "Started syncing all providers for '#{group.name}' group" } # rubocop:disable Gitlab/RailsLogger

                  # 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::Auth::LDAP::Config.providers.shuffle.each do |provider|
                    Sync::Proxy.open(provider) do |proxy|
                      new(group, proxy).update_permissions
                    end
                  end

                  group.finish_ldap_sync
                  Rails.logger.debug { "Finished syncing all providers for '#{group.name}' group" } # rubocop:disable Gitlab/RailsLogger
                rescue ::Gitlab::Auth::LDAP::LDAPConnectionError
                  Rails.logger.warn("Error syncing all providers for '#{group.name}' group") # rubocop:disable Gitlab/RailsLogger
                  group.fail_ldap_sync
                end
              end

              # Sync members across a single provider for the given group.
              def execute(group, proxy)
                return unless ldap_sync_ready?(group)

                begin
                  group.start_ldap_sync
                  Rails.logger.debug { "Started syncing '#{proxy.provider}' provider for '#{group.name}' group" } # rubocop:disable Gitlab/RailsLogger

                  sync_group = new(group, proxy)
                  sync_group.update_permissions

                  group.finish_ldap_sync
                  Rails.logger.debug { "Finished syncing '#{proxy.provider}' provider for '#{group.name}' group" } # rubocop:disable Gitlab/RailsLogger
                rescue ::Gitlab::Auth::LDAP::LDAPConnectionError
                  Rails.logger.warn("Error syncing '#{proxy.provider}' provider for '#{group.name}' group") # rubocop:disable Gitlab/RailsLogger
                  group.fail_ldap_sync
                end
              end

              def ldap_sync_ready?(group)
                fail_stuck_group(group)

                return true unless group.ldap_sync_started?

                Rails.logger.warn "Group '#{group.name}' is not ready for LDAP sync. Skipping" # rubocop:disable Gitlab/RailsLogger
                false
              end

              def fail_stuck_group(group)
                return unless group.ldap_sync_started?

                if group.ldap_sync_last_sync_at.nil?
                  fail_due_to_no_sync_time(group)
                elsif group.ldap_sync_last_sync_at < 1.hour.ago
                  group.mark_ldap_sync_as_failed('The sync took too long to complete.')
                end
              end

              def fail_due_to_no_sync_time(group)
                # If the group sync is in the started state but no sync
                # time was available, then something may be invalid with
                # this group. Do some validation and bubble up the error.
                details = group.errors.full_messages.join(', ') unless group.valid?

                message = +'The sync failed because the group is an inconsistent state'
                message += ": #{details}" if details

                group.mark_ldap_sync_as_failed(message, skip_validation: true)
              end
            end

            def initialize(group, proxy)
              @provider = proxy.provider
              @group = group
              @proxy = proxy
            end

            def update_permissions
              unless group.ldap_sync_started?
                logger.warn "Group '#{group.name}' LDAP sync status must be 'started' before updating permissions"
                return
              end

              access_levels = AccessLevels.new
              # Only iterate over group links for the current provider
              group.ldap_group_links.with_provider(provider).each do |group_link|
                next unless group_link.active?

                update_access_levels(access_levels, group_link)
              end

              # Users in this LDAP group may already have a higher access level in a parent group.
              # Currently demoting a user in a subgroup is forbidden by (Group)Member validation
              # so we must propagate any higher inherited permissions unconditionally.
              inherit_higher_access_levels(group, access_levels)

              logger.debug do
                <<-MSG.strip_heredoc.tr("\n", ' ')
                  Resolved '#{group.name}' group member access,
                  propagating any higher access inherited from a parent group:
                  #{access_levels.to_hash}
                MSG
              end

              update_existing_group_membership(group, access_levels)
              add_new_members(group, access_levels)
            end

            private

            def update_access_levels(access_levels, group_link)
              if member_dns = get_member_dns(group_link)
                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

            def get_member_dns(group_link)
              group_link.cn ? dns_for_group_cn(group_link.cn) : proxy.dns_for_filter(group_link.filter)
            end

            def dns_for_group_cn(group_cn)
              if config.group_base.blank?
                logger.debug { "No `group_base` configured for '#{provider}' provider and group link CN #{group_cn}. Skipping" }

                return
              end

              proxy.dns_for_group_cn(group_cn)
            end

            # for all LDAP Distinguished Names in access_levels, merge access level
            # with any higher permission inherited from a parent group
            # rubocop: disable CodeReuse/ActiveRecord
            def inherit_higher_access_levels(group, access_levels)
              return unless group.parent

              # for any permission granted by an ancestor group to any DN in access_levels,
              # retrieve user DN, access_level and ID of the group providing it.
              # Ignore unapproved access requests.
              permissions_in_ancestry = ::GroupMember.of_groups(group.ancestors)
                .non_request
                .with_identity_provider(provider)
                .where(users: { identities: ::Identity.iwhere(extern_uid: access_levels.keys) })
                .select(:id, 'identities.extern_uid AS distinguished_name', :access_level, :source_id)
                .references(:identities)

              permissions_in_ancestry.each do |member|
                access_levels.set([member.distinguished_name], to: member.access_level)
              end
            end
            # rubocop: enable CodeReuse/ActiveRecord

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

              multiple_ldap_providers = ::Gitlab::Auth::LDAP::Config.providers.count > 1
              existing_members = select_and_preload_group_members(group)
              # For each existing group member, we'll need to look up its LDAP identity in the current LDAP provider.
              # It is much faster to resolve these at once than later for each member one by one.
              ldap_identity_by_user_id = resolve_ldap_identities(for_users: existing_members.map(&:user))

              existing_members.each do |member|
                user = member.user
                identity = ldap_identity_by_user_id[user.id]

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

                member_dn = identity.extern_uid

                # 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 multiple_ldap_providers && user.ldap_identity.id != identity.id
                  access_levels.delete(member_dn)
                  next
                end

                # Skip validations and callbacks. We have a limited set of attrs
                # due to the `select` lookup, and we need to be efficient.
                # Low risk, because the member should already be valid.
                member.update_column(:ldap, true) unless member.ldap?

                desired_access = access_levels[member_dn]

                # Check and update the access level. If `desired_access` is `nil`
                # we need to delete the user from the group.
                if desired_access.present?
                  # Delete this entry from the hash now that we're acting on it
                  access_levels.delete(member_dn)

                  # Don't do anything if the user already has the desired access level
                  # and respect existing overrides
                  next if member.access_level == desired_access || member.override?

                  add_or_update_user_membership(
                    user,
                    group,
                    desired_access
                  )
                elsif group.last_owner?(user)
                  warn_cannot_remove_last_owner(user, group)
                else
                  group.users.destroy(user)
                end
              end
            end

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

              return unless access_levels.present?

              # Even in the absence of new members, the list of DNs to add can be consistently large
              # when LDAP groups contain members who do not have a gitlab account.
              # Thus we can be a lot more efficient by pre-resolving all candidate DNs into gitlab users.
              gitlab_users_by_dn = resolve_users_from_normalized_dn(for_normalized_dns: access_levels.keys)

              access_levels.each do |member_dn, access_level|
                user = gitlab_users_by_dn[member_dn]

                if user.present?
                  add_or_update_user_membership(
                    user,
                    group,
                    access_level
                  )
                else
                  logger.debug do
                    <<-MSG.strip_heredoc.tr("\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, current_user: nil)
              # 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_user(
                  user,
                  access,
                  current_user: current_user,
                  ldap: true
                )
              end
            end

            def warn_cannot_remove_last_owner(user, group)
              logger.warn do
                <<-MSG.strip_heredoc.tr("\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

            # rubocop: disable CodeReuse/ActiveRecord
            def select_and_preload_group_members(group)
              group.members.select(:id, :access_level, :user_id, :ldap, :override)
                .with_identity_provider(provider).preload(:user)
            end
            # rubocop: enable CodeReuse/ActiveRecord

            # returns a hash user_id -> LDAP identity in current LDAP provider
            def resolve_ldap_identities(for_users:)
              ::Identity.for_user(for_users).with_provider(provider)
                .map {|identity| [identity.user_id, identity] }
                .to_h
            end

            # returns a hash of normalized DN -> user for the current LDAP provider
            # rubocop: disable CodeReuse/ActiveRecord
            def resolve_users_from_normalized_dn(for_normalized_dns:)
              ::Identity.with_provider(provider).iwhere(extern_uid: for_normalized_dns)
                .preload(:user)
                .map {|identity| [identity.extern_uid, identity.user] }
                .to_h
            end
            # rubocop: enable CodeReuse/ActiveRecord

            def logger
              Rails.logger # rubocop:disable Gitlab/RailsLogger
            end

            def config
              @proxy.adapter.config
            end
          end
        end
      end
    end
  end
end
