# frozen_string_literal: true

module Members
  # This class serves as more of an app-wide way we add/create members
  # All roads to add members should take this path.
  class CreatorService
    class << self
      def parsed_access_level(access_level)
        access_levels.fetch(access_level) { access_level.to_i }
      end

      def access_levels
        Gitlab::Access.sym_options_with_owner
      end

      # Add members to sources with passed access option
      #
      # access can be an integer representing a access code
      # or symbol like :maintainer representing role
      #
      # Ex.
      #   add_members(
      #     sources,
      #     user_ids,
      #     Member::MAINTAINER
      #   )
      #
      #   add_members(
      #     sources,
      #     user_ids,
      #     :maintainer
      #   )
      #
      # @param sources [Group, Project, Array<Group>, Array<Project>, Group::ActiveRecord_Relation,
      # Project::ActiveRecord_Relation] - Can't be an array of source ids because we don't know the type of source.
      # @return Array<Member>
      def add_members(
        sources,
        invitees,
        access_level,
        current_user: nil,
        expires_at: nil,
        tasks_to_be_done: [],
        tasks_project_id: nil,
        ldap: nil
      ) # rubocop:disable Metrics/ParameterLists
        return [] unless invitees.present?

        sources = Array.wrap(sources) if sources.is_a?(ApplicationRecord) # For single source

        Member.transaction do
          sources.flat_map do |source|
            # If this user is attempting to manage Owner members and doesn't have permission, do not allow
            next [] if managing_owners?(current_user, access_level) && cannot_manage_owners?(source, current_user)

            emails, users, existing_members = parse_users_list(source, invitees)

            common_arguments = {
              source: source,
              access_level: access_level,
              existing_members: existing_members,
              current_user: current_user,
              expires_at: expires_at,
              tasks_to_be_done: tasks_to_be_done,
              tasks_project_id: tasks_project_id,
              ldap: ldap
            }

            members = emails.map do |email|
              new(invitee: email, builder: InviteMemberBuilder, **common_arguments).execute
            end

            members += users.map do |user|
              new(invitee: user, **common_arguments).execute
            end

            members
          end
        end
      end

      def add_member(
        source,
        invitee,
        access_level,
        current_user: nil,
        expires_at: nil,
        ldap: nil
      ) # rubocop:disable Metrics/ParameterLists
        add_members(
          source,
          [invitee],
          access_level,
          current_user: current_user,
          expires_at: expires_at,
          ldap: ldap
        ).first
      end

      private

      def managing_owners?(current_user, access_level)
        current_user && Gitlab::Access.sym_options_with_owner[access_level] == Gitlab::Access::OWNER
      end

      def parse_users_list(source, list)
        emails = []
        user_ids = []
        users = []
        existing_members = {}

        list.each do |item|
          case item
          when User
            users << item
          when Integer
            user_ids << item
          when /\A\d+\Z/
            user_ids << item.to_i
          when Devise.email_regexp
            emails << item
          end
        end

        # the below will automatically discard invalid user_ids
        users.concat(User.id_in(user_ids)) if user_ids.present?
        # de-duplicate just in case as there is no controlling if user records and ids are sent multiple times
        users.uniq!

        users_by_emails = source.users_by_emails(emails) # preloads our request store for all emails
        # in case emails belong to a user that is being invited by user or user_id, remove them from
        # emails and let users/user_ids handle it.
        parsed_emails = emails.select do |email|
          user = users_by_emails[email]
          !user || (users.exclude?(user) && user_ids.exclude?(user.id))
        end

        if users.present? || users_by_emails.present?
          # helps not have to perform another query per user id to see if the member exists later on when fetching
          existing_members = source.members_and_requesters.with_user(users + users_by_emails.values).index_by(&:user_id)
        end

        [parsed_emails, users, existing_members]
      end
    end

    def initialize(invitee:, builder: StandardMemberBuilder, **args)
      @invitee = invitee
      @builder = builder
      @args = args
      @access_level = self.class.parsed_access_level(args[:access_level])
    end

    private_class_method :new

    def execute
      find_or_build_member
      commit_member
      after_commit_tasks

      member
    end

    private

    delegate :new_record?, to: :member
    attr_reader :invitee, :access_level, :member, :args, :builder

    def assign_member_attributes
      member.attributes = member_attributes
    end

    def commit_member
      if can_commit_member?
        assign_member_attributes
        commit_changes
      else
        add_commit_error
      end
    end

    def can_commit_member?
      # There is no current user for bulk actions, in which case anything is allowed
      return true if skip_authorization?

      if new_record?
        can_create_new_member?
      else
        can_update_existing_member?
      end
    end

    def can_create_new_member?
      raise NotImplementedError
    end

    def can_update_existing_member?
      raise NotImplementedError
    end

    # Populates the attributes of a member.
    #
    # This logic resides in a separate method so that EE can extend this logic,
    # without having to patch the `add_members` method directly.
    def member_attributes
      {
        created_by: member.created_by || current_user,
        access_level: access_level,
        expires_at: args[:expires_at]
      }
    end

    def commit_changes
      if member.request?
        approve_request
      elsif member.changed?
        # Calling #save triggers callbacks even if there is no change on object.
        # This previously caused an incident due to the hard to predict
        # behaviour caused by the large number of callbacks.
        # See https://gitlab.com/gitlab-com/gl-infra/production/-/issues/6351
        # and https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80920#note_911569038
        # for details.
        member.save
      end
    end

    def after_commit_tasks
      create_member_task
    end

    def create_member_task
      return unless member.persisted?
      return if member_task_attributes.value?(nil)
      return if member.member_task.present?

      member.create_member_task(member_task_attributes)
    end

    def member_task_attributes
      {
        tasks_to_be_done: args[:tasks_to_be_done],
        project_id: args[:tasks_project_id]
      }
    end

    def approve_request
      ::Members::ApproveAccessRequestService.new(current_user, access_level: access_level)
                                            .execute(
                                              member,
                                              skip_authorization: ldap || skip_authorization?,
                                              skip_log_audit_event: ldap
                                            )
    end

    def current_user
      args[:current_user]
    end

    def skip_authorization?
      !current_user
    end

    def add_commit_error
      msg = if new_record?
              _('not authorized to create member')
            else
              _('not authorized to update member')
            end

      member.errors.add(:base, msg)
    end

    def find_or_build_member
      @member = builder.new(source, invitee, existing_members).execute
    end

    def ldap
      args[:ldap] || false
    end

    def source
      args[:source]
    end

    def existing_members
      args[:existing_members] || {}
    end
  end
end

Members::CreatorService.prepend_mod_with('Members::CreatorService')
