# frozen_string_literal: true

module Gitlab
  module SidekiqConfig
    class WorkerRouter
      InvalidRoutingRuleError = Class.new(StandardError)
      RuleEvaluator = Struct.new(:matcher, :queue_name, :store_name)

      def self.queue_name_from_worker_name(worker_klass)
        base_queue_name =
          worker_klass.name
            .delete_prefix('Gitlab::')
            .delete_suffix('Worker')
            .underscore
            .tr('/', '_')
        [worker_klass.queue_namespace, base_queue_name].compact.join(':')
      end

      def self.global
        @global_worker_router ||= new(::Gitlab.config.sidekiq.routing_rules)
      rescue InvalidRoutingRuleError, ::Gitlab::SidekiqConfig::WorkerMatcher::UnknownPredicate => e
        ::Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)

        @global_worker_router = new([])
      end

      # call-seq:
      #   router = WorkerRouter.new([
      #     ["resource_boundary=cpu", 'cpu_boundary'],
      #     ["feature_category=pages", nil],
      #     ["feature_category=source_code_management", ''],
      #     ["*", "default"]
      #   ])
      #   router.route(ACpuBoundaryWorker) # Return "cpu_boundary"
      #   router.route(JustAPagesWorker)   # Return "just_a_pages_worker"
      #   router.route(PostReceive)        # Return "post_receive"
      #   router.route(RandomWorker)       # Return "default"
      #
      # This class is responsible for routing a Sidekiq worker to a certain
      # queue defined in the input routing rules. The input routing rules, as
      # described above, is an order-matter array of tuples [query, queue_name].
      #
      # - The query syntax follows "worker matching query" detailedly
      # denoted in doc/administration/operations/extra_sidekiq_processes.md.
      #
      # - The queue_name must be a valid Sidekiq queue name. If the queue name
      # is nil, or an empty string, the worker is routed to the queue generated
      # by the name of the worker instead.
      #
      # Rules are evaluated from first to last, and as soon as we find a match
      # for a given worker we stop processing for that worker (first match
      # wins). If the worker doesn't match any rule, it falls back the queue
      # name generated from the worker name
      #
      # For further information, please visit:
      #   https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1016
      #
      def initialize(routing_rules)
        @rule_evaluators = parse_routing_rules(routing_rules)
      end

      def route(worker_klass)
        # A medium representation to ensure the backward-compatibility of
        # WorkerMatcher
        worker_metadata = generate_worker_metadata(worker_klass)
        @rule_evaluators.each do |evaluator|
          if evaluator.matcher.match?(worker_metadata)
            return evaluator.queue_name.presence || queue_name_from_worker_name(worker_klass)
          end
        end

        queue_name_from_worker_name(worker_klass)
      end

      def store(worker_klass)
        worker_metadata = generate_worker_metadata(worker_klass)
        @rule_evaluators.each do |evaluator|
          if evaluator.matcher.match?(worker_metadata)
            return evaluator.store_name.presence
          end
        end

        nil
      end

      def stores_with_queue(queue_name)
        @rule_evaluators.filter_map do |evaluator|
          evaluator.store_name if evaluator.queue_name == queue_name
        end
      end

      private

      def parse_routing_rules(routing_rules)
        raise InvalidRoutingRuleError, 'The set of routing rule must be an array' unless routing_rules.is_a?(Array)

        unless routing_rules.last&.first == WorkerMatcher::WILDCARD_MATCH
          Gitlab::AppLogger.warn "sidekiq.routing_rules config is missing a catch-all `*` entry as the last rule. " \
                                 "Consider adding `[['*', 'default']]` at the end of routing_rules."
        end

        routing_rules.map do |rule_tuple|
          raise InvalidRoutingRuleError, "Routing rule `#{rule_tuple.inspect}` is invalid" unless valid_routing_rule?(rule_tuple)

          selector, destination_queue, destination_store = rule_tuple
          RuleEvaluator.new(
            ::Gitlab::SidekiqConfig::WorkerMatcher.new(selector),
            destination_queue,
            destination_store
          )
        end
      end

      def valid_routing_rule?(rule_tuple)
        rule_tuple.is_a?(Array) && (rule_tuple.length == 2 || rule_tuple.length == 3)
      end

      def generate_worker_metadata(worker_klass)
        # The ee indicator here is insignificant and irrelevant to the matcher.
        # Plus, it's not easy to determine whether a worker is **only**
        # available in EE.
        ::Gitlab::SidekiqConfig::Worker.new(worker_klass, ee: false).to_yaml
      end

      def queue_name_from_worker_name(worker_klass)
        self.class.queue_name_from_worker_name(worker_klass)
      end
    end
  end
end
