# frozen_string_literal: true
require 'active_support/core_ext/enumerable' # For Enumerable#index_by

class GlobalID
  module Locator
    class InvalidModelIdError < StandardError; end
    class Error < StandardError; end

    # Raised by GlobalID::Locator.fetch when the GlobalID is valid but the
    # record it references no longer exists. The record is gone for good, so
    # retrying won't help.
    class RecordNotFound < Error; end

    # Raised by GlobalID::Locator.fetch when the record couldn't be located
    # due to any other error in the backend, such as a database connection
    # error. The record may still exist, so retrying may succeed.
    class RecordUnavailable < Error; end

    class << self
      # The default locator used when no app-specific locator is found.
      attr_accessor :default_locator

      # Takes either a GlobalID or a string that can be turned into a GlobalID
      #
      # Options:
      # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them.
      #   The same structure you would pass into a +includes+ method of Active Record.
      #   If present, locate will load all the relationships specified here.
      #   See https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations.
      # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
      #   allowed to be located.  Passing one or more classes limits instances of returned
      #   classes to those classes or their subclasses.  Passing one or more modules in limits
      #   instances of returned classes to those including that module.  If no classes or
      #   modules match, +nil+ is returned.
      def locate(gid, options = {})
        gid = GlobalID.parse(gid)

        return unless gid && find_allowed?(gid, options[:only])

        locator = locator_for(gid)

        if locator.method(:locate).arity == 1
          GlobalID.deprecator.warn "It seems your locator is defining the `locate` method only with one argument. Please make sure your locator is receiving the options argument as well, like `locate(gid, options = {})`."
          locator.locate(gid)
        else
          locator.locate(gid, options.except(:only))
        end
      end

      # Like .locate, but instead of returning +nil+ or leaking the backend's
      # own exceptions when the record can't be returned, it raises one of two
      # GlobalID-specific errors so callers can tell the cases apart:
      #
      # * GlobalID::Locator::RecordNotFound when the record no longer exists.
      #   Retrying won't help.
      # * GlobalID::Locator::RecordUnavailable when the record couldn't be
      #   located due to any other failure in the backend, like a database
      #   connection error. The record may still exist, so retrying may succeed.
      #
      # The distinction is drawn without knowing the backend's exception
      # classes: the record is looked up through a query that doesn't raise on
      # missing records (like .locate_many's +:ignore_missing+), so an empty
      # result means the record is gone, while an error from the query itself
      # means the backend is unavailable.
      #
      # Returns +nil+ for a blank or unparseable GlobalID, or one disallowed by
      # the +:only+ option, just like .locate, and accepts the same options.
      #
      # Note: custom locators registered with .use need to implement locate_many
      # with support for the +:ignore_missing+ option for this method to work.
      def fetch(gid, options = {})
        gid = GlobalID.parse(gid)

        return unless gid && find_allowed?(gid, options[:only])

        fetch_record(gid, options.except(:only)) or raise RecordNotFound, "Couldn't find record for #{gid}"
      end

      # Takes an array of GlobalIDs or strings that can be turned into a GlobalIDs.
      # All GlobalIDs must belong to the same app, as they will be located using
      # the same locator using its locate_many method.
      #
      # By default the GlobalIDs will be located using Model.find(array_of_ids), so the
      # models must respond to that finder signature.
      #
      # This approach will efficiently call only one #find (or #where(id: id), when using ignore_missing)
      # per model class, but still interpolate the results to match the order in which the gids were passed.
      #
      # Options:
      # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them
      #   The same structure you would pass into a includes method of Active Record.
      #   @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations
      #   If present, locate_many will load all the relationships specified here.
      #   Note: It only works if all the gids models have that relationships.
      # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
      #   allowed to be located.  Passing one or more classes limits instances of returned
      #   classes to those classes or their subclasses.  Passing one or more modules in limits
      #   instances of returned classes to those including that module.  If no classes or
      #   modules match, +nil+ is returned.
      # * <tt>:ignore_missing</tt> - By default, locate_many will call #find on the model to locate the
      #   ids extracted from the GIDs. In Active Record (and other data stores following the same pattern),
      #   #find will raise an exception if a named ID can't be found. When you set this option to true,
      #   we will use #where(id: ids) instead, which does not raise on missing records.
      def locate_many(gids, options = {})
        if (allowed_gids = parse_allowed(gids, options[:only])).any?
          locator = locator_for(allowed_gids.first)
          locator.locate_many(allowed_gids, options)
        else
          []
        end
      end

      # Takes either a SignedGlobalID or a string that can be turned into a SignedGlobalID
      #
      # Options:
      # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them
      #   The same structure you would pass into a includes method of Active Record.
      #   @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations
      #   If present, locate_signed will load all the relationships specified here.
      # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
      #   allowed to be located.  Passing one or more classes limits instances of returned
      #   classes to those classes or their subclasses.  Passing one or more modules in limits
      #   instances of returned classes to those including that module.  If no classes or
      #   modules match, +nil+ is returned.
      def locate_signed(sgid, options = {})
        SignedGlobalID.find sgid, options
      end

      # Takes an array of SignedGlobalIDs or strings that can be turned into a SignedGlobalIDs.
      # The SignedGlobalIDs are located using Model.find(array_of_ids), so the models must respond to
      # that finder signature.
      #
      # This approach will efficiently call only one #find per model class, but still interpolate
      # the results to match the order in which the gids were passed.
      #
      # Options:
      # * <tt>:includes</tt> - A Symbol, Array, Hash or combination of them
      #   The same structure you would pass into a includes method of Active Record.
      #   @see https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations
      #   If present, locate_many_signed will load all the relationships specified here.
      #   Note: It only works if all the gids models have that relationships.
      # * <tt>:only</tt> - A class, module or Array of classes and/or modules that are
      #   allowed to be located.  Passing one or more classes limits instances of returned
      #   classes to those classes or their subclasses.  Passing one or more modules in limits
      #   instances of returned classes to those including that module.  If no classes or
      #   modules match, +nil+ is returned.
      def locate_many_signed(sgids, options = {})
        locate_many sgids.collect { |sgid| SignedGlobalID.parse(sgid, options.slice(:for)) }.compact, options
      end

      # Tie a locator to an app.
      # Useful when different apps collaborate and reference each others' Global IDs.
      #
      # The locator can be either a block or a class.
      #
      # Using a block:
      #
      #   GlobalID::Locator.use :foo do |gid, options|
      #     FooRemote.const_get(gid.model_name).find(gid.model_id)
      #   end
      #
      # Using a class:
      #
      #   GlobalID::Locator.use :bar, BarLocator.new
      #
      #   class BarLocator
      #     def locate(gid, options = {})
      #       @search_client.search name: gid.model_name, id: gid.model_id
      #     end
      #   end
      def use(app, locator = nil, &locator_block)
        raise ArgumentError, 'No locator provided. Pass a block or an object that responds to #locate.' unless locator || block_given?

        URI::GID.validate_app(app)

        @locators[normalize_app(app)] = locator || BlockLocator.new(locator_block)
      end

      def locator_for(gid)
        @locators.fetch(normalize_app(gid.app)) { default_locator }
      end

      private
        def find_allowed?(gid, only = nil)
          only ? Array(only).any? { |c| gid.model_class <= c } : true
        end

        def fetch_record(gid, options)
          locator_for(gid).locate_many([gid], options.merge(ignore_missing: true)).first
        rescue => error
          raise RecordUnavailable, "Couldn't fetch record for #{gid}: #{error.message}"
        end

        def parse_allowed(gids, only = nil)
          gids.collect { |gid| GlobalID.parse(gid) }.compact.select { |gid| find_allowed?(gid, only) }
        end

        def normalize_app(app)
          app.to_s.downcase
        end
    end

    private
      @locators = {}

      class BaseLocator
        def model_class(gid)
          gid.model_name.constantize
        end

        def locate(gid, options = {})
          return unless model_id_is_valid?(gid)
          model_class = gid.model_class
          model_class = model_class.includes(options[:includes]) if options[:includes]

          model_class.find gid.model_id
        end

        def locate_many(gids, options = {})
          ids_by_model = Hash.new { |hash, key| hash[key] = [] }

          gids.each do |gid|
            next unless model_id_is_valid?(gid)
            ids_by_model[gid.model_class] << gid.model_id
          end

          records_by_model_name_and_id = {}

          ids_by_model.each do |model, ids|
            records = find_records(model, ids, ignore_missing: options[:ignore_missing], includes: options[:includes])

            records_by_id = records.index_by do |record|
              record.id.is_a?(Array) ? record.id.map(&:to_s) : record.id.to_s
            end

            records_by_model_name_and_id[model.name] = records_by_id
          end

          gids.filter_map { |gid| records_by_model_name_and_id[gid.model_name][gid.model_id] }
        end

        private
          def find_records(model_class, ids, options)
            model_class = model_class.includes(options[:includes]) if options[:includes]

            if options[:ignore_missing]
              model_class.where(primary_key(model_class) => ids)
            else
              model_class.find(ids)
            end
          end

          def model_id_is_valid?(gid)
            Array(gid.model_id).size == Array(primary_key(gid.model_class)).size
          end

          def primary_key(model_class)
            model_class.respond_to?(:primary_key) ? model_class.primary_key : :id
          end
      end

      class UnscopedLocator < BaseLocator
        def locate(gid, options = {})
          unscoped(gid.model_class) { super }
        end

        private
          def find_records(model_class, ids, options)
            unscoped(model_class) { super }
          end

          def unscoped(model_class)
            if model_class.respond_to?(:unscoped)
              model_class.unscoped { yield }
            else
              yield
            end
          end
      end

      self.default_locator = UnscopedLocator.new

      class BlockLocator
        def initialize(block)
          @locator = block
        end

        def model_class(gid)
          gid.model_name.constantize
        end

        def locate(gid, options = {})
          @locator.call(gid, options)
        end

        def locate_many(gids, options = {})
          gids.map { |gid| locate(gid, options) }
        end
      end
  end
end
