# frozen_string_literal: true

#
# Copyright (C) 2012 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#

# on-demand in-memory model caching for those times where the rails query
# cache can't help you, like in this contrived example:
#
# # given:
#
# class Foo < ActiveRecord::Base
#   has_many :bars
# end
#
# class Bar < ActiveRecord::Base
#   belongs_to :foo
#   def something!
#     update_attribute :stuff, "my foo is: #{foo.name}"
#   end
# end
#
# # if you do:
# foo.bars.each(&:something!)
#
# # then by default, AR loads each bar's foo separately and won't use the
# # query cache, since each update blows it away.
#
# granted, the example is a bit contrived, as preloading would be one possible
# solution. ModelCache is useful for those times when preloading is just not
# feasible. See Conversation and ConversatonParticipant for real-world usage.

module ModelCache
  module ClassMethods
    # e.g. use this to cache calls to ConversationParticipant#conversation,
    # no matter how those conversation_participants were loaded
    def cacheable_method(method, options = {})
      options[:cache_name] ||= method.to_s.pluralize.to_sym
      options[:key_method] ||= "#{method}_id"
      options[:key_lookup] ||= :id
      options[:type] ||= :instance

      # ensure the target class is ModelCache-aware, and set up the :id lookup
      target_klass = reflections[method.to_s].klass
      raise "`#{target_klass}` needs to `include ModelCache` before you can make `#{self}##{method}` cacheable" unless target_klass.included_modules.include?(ModelCache)

      unless ModelCache.keys[target_klass.name].include?(options[:key_lookup])
        ModelCache.keys[target_klass.name] << options[:key_lookup]
      end

      ModelCache.make_cacheable self, method, options
    end
  end

  module InstanceMethods
    def add_to_caches
      return unless (cache = ModelCache[self.class.name.underscore.pluralize.to_sym])

      cache.each_key do |key|
        cache[key][send(key)] = self
      end
    end

    def update_in_caches
      return unless (cache = ModelCache[self.class.name.underscore.pluralize.to_sym])

      cache.each_key do |key|
        if saved_change_to_attribute?(key)
          cache[key][send(key)] = self
          cache[key].delete(attribute_before_last_save(key))
        end
      end
    end
  end

  def self.with_cache(lookups)
    @cache = lookups.transform_values do |v|
      prepare_lookups(v)
    end
    yield
  ensure
    @cache = nil
  end

  def self.[](cache_name)
    return nil unless @cache

    @cache[cache_name] || {}
  end

  def self.keys
    @keys ||= Hash.new { |h, k| h[k] = [] }
  end

  def self.prepare_lookups(records)
    return records if records.is_a?(Hash)
    return {} if records.empty?

    keys[records.first.class.name].index_with do |k|
      records.index_by(&k)
    end
  end

  def self.make_cacheable(klass, method, options = {})
    options[:type] ||= :class
    options[:cache_name] ||= klass.name.underscore.pluralize.to_sym
    options[:key_lookup] ||= method

    orig_method = "super"
    alias_method = nil

    key_value = options[:key_method] || "args.first"
    # if extra args are provided, we should clear out the current value
    # (e.g. if you call c.user(:lock => true) )
    expected_args = options[:key_method] ? 0 : 1
    maybe_reset = "cache[#{key_value}] = #{orig_method} if args.size > #{expected_args}"

    klass.send((options[:type] == :instance) ? :class_eval : :instance_eval, <<~RUBY, __FILE__, __LINE__ + 1)
      def #{method}(*args)
        if cache = ModelCache[#{options[:cache_name].inspect}] and cache = cache[#{options[:key_lookup].inspect}]
          #{maybe_reset}
          cache[#{key_value}] ||= #{orig_method}
        else
          #{orig_method}
        end
      end
      #{alias_method}
    RUBY
  end

  def self.included(klass)
    klass.include InstanceMethods
    klass.extend ClassMethods
    klass.after_create :add_to_caches
    klass.after_update :update_in_caches
  end
end
