# frozen_string_literal: true
module IdentityCache
  module QueryAPI
    extend ActiveSupport::Concern

    module ClassMethods
      # Prefetches cached associations on a collection of records
      def prefetch_associations(includes, records)
        Cached::Prefetcher.prefetch(self, includes, records)
      end

      # @api private
      def cached_association(name) # :nodoc:
        cached_has_manys[name] || cached_has_ones[name] || cached_belongs_tos.fetch(name)
      end

      # @api private
      def all_cached_associations # :nodoc:
        cached_has_manys.merge(cached_has_ones).merge(cached_belongs_tos)
      end

      private

      def raise_if_scoped
        if current_scope
          IdentityCache.logger.error("#{name} has scope: #{current_scope.to_sql} (#{current_scope.values.keys})")
          raise UnsupportedScopeError, "IdentityCache doesn't support rails scopes (#{name})"
        end
      end

      def check_association_scope(association_name)
        association_reflection = reflect_on_association(association_name)
        scope = association_reflection.scope
        if scope && !association_reflection.klass.all.instance_exec(&scope).joins_values.empty?
          raise UnsupportedAssociationError, <<~MSG.squish
            caching association #{self}.#{association_name}
            scoped with a join isn't supported
          MSG
        end
      end

      def preload_id_embedded_association(records, cached_association)
        reflection = cached_association.reflection
        child_model = reflection.klass
        scope = child_model.all
        scope = scope.where(reflection.type => base_class.name) if reflection.type
        scope = scope.instance_exec(nil, &reflection.scope) if reflection.scope

        pairs = scope.where(reflection.foreign_key => records.map(&:id)).pluck(
          reflection.foreign_key, reflection.association_primary_key
        )
        ids_by_parent = Hash.new { |hash, key| hash[key] = [] }
        pairs.each do |parent_id, child_id|
          ids_by_parent[parent_id] << child_id
        end

        records.each do |parent|
          child_ids = ids_by_parent[parent.id]
          case cached_association
          when Cached::Reference::HasMany
            parent.instance_variable_set(cached_association.ids_variable_name, child_ids)
          when Cached::Reference::HasOne
            parent.instance_variable_set(cached_association.id_variable_name, child_ids.first)
          end
        end
      end

      def setup_embedded_associations_on_miss(records,
        readonly: IdentityCache.fetch_read_only_records && should_use_cache?)
        return if records.empty?
        records.each(&:readonly!) if readonly
        each_id_embedded_association do |cached_association|
          preload_id_embedded_association(records, cached_association)
        end
        recursively_embedded_associations.each_value do |cached_association|
          association_reflection = cached_association.reflection
          association_name = association_reflection.name

          # Move the loaded records to the cached association instance variable so they
          # behave the same way if they were loaded from the cache
          records.each do |record|
            association = record.association(association_name)
            target = association.target
            target = readonly_copy(target) if readonly
            cached_association.set_with_inverse(record, target)
            association.reset
            # reset inverse associations
            next unless target && association_reflection.has_inverse?
            inverse_name = association_reflection.inverse_of.name
            if target.is_a?(Array)
              target.each { |child_record| child_record.association(inverse_name).reset }
            else
              target.association(inverse_name).reset
            end
          end

          child_model = association_reflection.klass
          child_records = records.flat_map(&cached_association.cached_accessor_name).compact
          child_model.send(:setup_embedded_associations_on_miss, child_records, readonly: readonly)
        end
      end

      def readonly_record_copy(record)
        record = record.clone
        record.readonly!
        record
      end

      def readonly_copy(record_or_records)
        if record_or_records.is_a?(Array)
          record_or_records.map { |record| readonly_record_copy(record) }
        elsif record_or_records
          readonly_record_copy(record_or_records)
        end
      end

      def each_id_embedded_association
        cached_has_manys.each_value do |association|
          yield association if association.embedded_by_reference?
        end
        cached_has_ones.each_value do |association|
          yield association if association.embedded_by_reference?
        end
      end

      def recursively_embedded_associations
        all_cached_associations.select { |_name, association| association.embedded_recursively? }
      end

      def embedded_associations
        all_cached_associations.select { |_name, association| association.embedded? }
      end

      def cache_fetch_includes
        associations_for_identity_cache = recursively_embedded_associations.map do |child_association, _options|
          child_class = reflect_on_association(child_association).try(:klass)

          child_includes = child_class.send(:cache_fetch_includes)

          if child_includes.blank?
            child_association
          else
            { child_association => child_includes }
          end
        end

        associations_for_identity_cache.compact
      end
    end

    no_op_callback = proc {}
    included do |base|
      # Make sure there is at least once after_commit callback so that _run_commit_callbacks
      # is called, which is overridden to do an early after_commit callback
      base.after_commit(&no_op_callback)
    end

    # Override the method that is used to call after_commit callbacks so that we can
    # expire the caches before other after_commit callbacks. This way we can avoid stale
    # cache reads that happen from the ordering of callbacks. For example, if an after_commit
    # callback enqueues a background job, then we don't want it to be possible for the
    # background job to run and load data from the cache before it is invalidated.
    def _run_commit_callbacks
      if destroyed? || transaction_changed_attributes.present?
        expire_cache
        expire_parent_caches
      end
      super
    end

    # Invalidate the cache data associated with the record.
    def expire_cache
      expire_attribute_indexes
      true
    end

    # @api private
    def was_new_record? # :nodoc:
      pk = self.class.primary_key
      !destroyed? && transaction_changed_attributes.key?(pk) && transaction_changed_attributes[pk].nil?
    end

    private

    def expire_attribute_indexes # :nodoc:
      cache_indexes.each do |cached_attribute|
        cached_attribute.expire(self)
      end
    end
  end
end
