# frozen_string_literal: true

# Reassigns contributions from placeholder users to real users without creating
# placeholder references.
#
# This service processes all eligible models with proper database indexes,
# updating user ID associations in batches for optimal performance.
#
# == Batch Processing Strategy
#
# 1. *Primary approach*: Uses `update_all` for efficient batch updates
# 2. *Fallback approach*: When unique constraints cause failures, falls back to
#    individual record updates with duplicate record cleanup
#
# == Handling Unique Constraint Violations
#
# Unique constraint errors can occur when a contribution already exists for the
# reassignee user on a has_many association before reassignment begins.
#
# *Example scenario*:
# - A merge request approval is imported and assigned to a placeholder user
# - The reassignee user later approves the same merge request
# - During reassignment, the placeholder user's approval cannot be transferred
#   to the reassignee as it would create a duplicate approval
# - In this case, the placeholder user's approval is deleted
#
# == Updating the MODEL_LIST
#
# The MODEL_LIST should be updated when a new relation that references a user
# is added to the import_export.yml configuration.
#
# After updating the MODEL_LIST, ensure the reassignment process continue
# to work as expected for the new model and attributes.
#
# See https://docs.gitlab.com/user/import/mapping/reassignment/ for more
# information about the reassignment process.

module Import
  class DirectReassignService
    REASSIGN_BATCH_LIMIT = 100
    BATCH_SLEEP = 3
    MAX_RUNTIME = 5.minutes

    MODEL_LIST = {
      "Approval" => ["user_id"],
      "AwardEmoji" => ["user_id"],
      "Ci::Pipeline" => ["user_id"],
      "Ci::PipelineSchedule" => ["owner_id"],
      "CommitStatus" => ["user_id"],
      "DesignManagement::Version" => ["author_id"],
      "Epic" => %w[author_id assignee_id last_edited_by_id closed_by_id],
      "Event" => ["author_id"],
      "Issue" => %w[author_id updated_by_id closed_by_id],
      "IssueAssignee" => ["user_id"],
      "List" => ['user_id'],
      "MergeRequest::Metrics" => %w[merged_by_id latest_closed_by_id],
      "MergeRequest" => %w[author_id updated_by_id merge_user_id],
      "MergeRequestAssignee" => ["user_id"],
      "MergeRequestReviewer" => ["user_id"],
      "Note" => %w[author_id],
      "ProtectedBranch::MergeAccessLevel" => ["user_id"],
      "ProtectedBranch::PushAccessLevel" => ["user_id"],
      "ProtectedTag::CreateAccessLevel" => ["user_id"],
      "Release" => ["author_id"],
      "ResourceLabelEvent" => ["user_id"],
      "ResourceMilestoneEvent" => ["user_id"],
      "ResourceStateEvent" => ["user_id"],
      "Snippet" => ["author_id"],
      "Timelog" => ["user_id"],
      "Vulnerability" => %w[author_id resolved_by_id dismissed_by_id confirmed_by_id]
    }.freeze

    # Lists all models and attributes that are imported, reference users, and have an
    # index for the attribute
    #
    # Overridden in EE
    def self.model_list
      MODEL_LIST
    end

    # Determines if reassignment without placeholder references can be used for the given model, attribute and
    # source user.
    #
    # @param model_class [Class] The model class (e.g., Issue, MergeRequest)
    # @param attribute [String] The model attribute/database column name
    # @param source_user [Import::SourceUser] The source user being reassigned
    #
    # @return [Boolean] true if direct reassignment is supported, false otherwise
    #
    # == Decision Logic
    #
    # Direct reassignment is *not supported* when:
    # 1. The placeholder user is an import_user type
    # 2. The model class and attribute are excluded from the model list
    def self.supported?(model_class, attribute, source_user)
      return false if source_user.placeholder_user.import_user?

      model_attributes = model_list[model_class.base_class.name]
      !!model_attributes&.include?(attribute.to_s)
    end

    def initialize(import_source_user, reassignment_throttling:, sleep_time: BATCH_SLEEP)
      @import_source_user = import_source_user
      @reassigned_by_user = import_source_user.reassigned_by_user
      @reassignment_throttling = reassignment_throttling
      @execution_tracker = Gitlab::Utils::ExecutionTracker.new(MAX_RUNTIME)
      @sleep_time = sleep_time
    end

    def execute
      return unless import_source_user.placeholder_user.placeholder?

      self.class.model_list.each do |model, columns|
        reassignment_throttling.db_health_check!

        next if reassignment_throttling.db_table_unavailable?(model.constantize)

        columns.each do |column|
          direct_reassign_model_user_references(model, column)
        end
      end
    end

    private

    attr_accessor :import_source_user, :reassigned_by_user, :sleep_time, :execution_tracker, :reassignment_throttling

    def transaction(model_class, _contributions)
      model_class.transaction do
        yield
      end
    end

    # @param model [String] - Model name as string
    # @param column [String] - Model attribute/db column
    def direct_reassign_model_user_references(model, column)
      model_class = model.constantize

      loop do
        contributions = find_batch_of_contributions(model_class, column)

        begin
          # First, try to update user references in batches
          update_count = transaction(model_class, contributions) do
            contributions.update_all(column => import_source_user.reassign_to_user_id)
          end

          break if update_count == 0
        rescue ActiveRecord::RecordNotUnique
          # Fallback to individual reassignment
          contributions.each do |contribution|
            reassign_single_contribution(model_class, contribution, column)
          end
        end

        if execution_tracker.over_limit?
          raise Gitlab::Utils::ExecutionTracker::ExecutionTimeOutError, 'Execution timeout'
        end

        Kernel.sleep sleep_time
      end
    end

    # @param model_class [Class] - Model class, for example Issue
    # @param column [String] - Model attribute/db column
    # rubocop:disable CodeReuse/ActiveRecord -- this query is performed in several distinct model
    def find_batch_of_contributions(model_class, column)
      model_class.where(column => import_source_user.placeholder_user_id)
                .limit(REASSIGN_BATCH_LIMIT)
    end
    # rubocop:enable CodeReuse/ActiveRecord

    def reassign_single_contribution(model_class, contribution, column)
      transaction(model_class, contribution) do
        contribution.update_column(column, import_source_user.reassign_to_user_id)
      end
    rescue ActiveRecord::RecordNotUnique
      log_warn("Destroying contribution due to uniqueness constraint",
        to_param: contribution.to_param,
        model: contribution.class.name)

      contribution.destroy!
    end

    def logger
      Framework::Logger
    end

    def log_warn(...)
      logger.warn(logger_params(...))
    end

    def logger_params(message, **params)
      params.merge(
        message: message,
        source_user_id: import_source_user.id
      )
    end
  end
end

Import::DirectReassignService.prepend_mod
