class PotentialMatch < ApplicationRecord
  # We use "-1" to represent all the requested items matching
  ALL = -1

  CACHE_PROGRESS_KEY = "potential_match_status_for_".freeze
  CACHE_SIGNUP_KEY = "potential_match_signups_for_".freeze
  CACHE_INTERRUPT_KEY = "potential_match_interrupt_for_".freeze
  CACHE_INVALID_SIGNUP_KEY = "potential_match_invalid_signup_for_".freeze

  belongs_to :collection
  belongs_to :offer_signup, class_name: "ChallengeSignup"
  belongs_to :request_signup, class_name: "ChallengeSignup"

  
  def self.progress_key(collection)
    CACHE_PROGRESS_KEY + collection.id.to_s
  end

  def self.signup_key(collection)
    CACHE_SIGNUP_KEY + collection.id.to_s
  end

  def self.interrupt_key(collection)
    CACHE_INTERRUPT_KEY + collection.id.to_s
  end

  def self.invalid_signup_key(collection)
    CACHE_INVALID_SIGNUP_KEY + collection.id.to_s
  end

  def self.clear!(collection)
    # rapidly delete all potential prompt matches and potential matches
    # WITHOUT CALLBACKS
    pmids = collection.potential_matches.pluck(:id)
    PotentialMatch.where(id: pmids).delete_all
  end

  def self.set_up_generating(collection)
    REDIS_GENERAL.set progress_key(collection), "0.0"
  end

  def self.cancel_generation(collection)
    REDIS_GENERAL.set interrupt_key(collection), "1"
  end

  def self.canceled?(collection)
    REDIS_GENERAL.get(interrupt_key(collection)) == "1"
  end

  @queue = :collection

  # This only works on class methods
  def self.perform(method, *args)
    self.send(method, *args)
  end

  def self.generate(collection)
    Resque.enqueue(self, :generate_in_background, collection.id)
  end

  # Regenerate the potential matches for a given signup
  def self.regenerate_for_signup(signup)
    Resque.enqueue(self, :regenerate_for_signup_in_background, signup.id)
  end

  def self.invalid_signups_for(collection)
    REDIS_GENERAL.smembers(invalid_signup_key(collection))
  end

  def self.clear_invalid_signups(collection)
    REDIS_GENERAL.del invalid_signup_key(collection)
  end

  # The actual method that generates the potential matches for an entire collection
  def self.generate_in_background(collection_id)
    collection = Collection.find(collection_id)

    if collection.challenge.assignments_sent_at.present?
      # If assignments have been sent, we don't want to delete everything and
      # regenerate. (If the challenge moderator wants to recalculate potential
      # matches after sending assignments, they can use the Purge Assignments
      # button.)
      return
    end

    # check for invalid signups
    PotentialMatch.clear_invalid_signups(collection)
    invalid_signup_ids = collection.signups.reject(&:valid?)
      .collect(&:id)
    if invalid_signup_ids.present?
      invalid_signup_ids.each { |sid| REDIS_GENERAL.sadd invalid_signup_key(collection), sid }
      
      if collection.collection_email.present?
        UserMailer.invalid_signup_notification(collection.id, invalid_signup_ids, collection.collection_email).deliver_later
      else
        collection.maintainers_list.each do |user|
          I18n.with_locale(user.preference.locale_for_mails) do
            UserMailer.invalid_signup_notification(collection.id, invalid_signup_ids, user.email).deliver_later
          end
        end
      end
      PotentialMatch.cancel_generation(collection)
    else

      PotentialMatch.clear!(collection)
      settings = collection.challenge.potential_match_settings

      if settings.no_match_required?
        matcher = PotentialMatcherUnconstrained.new(collection)
      else
        index_type = PromptTagTypeInfo.new(collection).good_index_types.first
        matcher = PotentialMatcherConstrained.new(collection, index_type)
      end

      matcher.generate
    end
    # TODO: for any signups with no potential matches try regenerating?
    PotentialMatch.finish_generation(collection)
  end

  # Generate potential matches for a single signup.
  def self.generate_for_signup(collection, signup, settings, collection_tag_sets, required_types, prompt_type = "request")
    # only check the signups that have any overlap
    match_signup_ids = PotentialMatch.matching_signup_ids(collection, signup, collection_tag_sets, required_types, prompt_type)

    # We randomize the signup ids to make sure potential matches are distributed across all the participants
    match_signup_ids.shuffle.each do |other_signup_id|
      next if signup.id == other_signup_id

      # The "match" method of ChallengeSignup creates and returns a new
      # (unsaved) potential match object. It assumes the signup that is calling
      # is the requesting signup, so if this is meant to be an offering signup
      # instead, we call it from the other signup.
      if prompt_type == "request"
        other_signup = ChallengeSignup.with_offer_tags.find(other_signup_id)
        potential_match = signup.match(other_signup, settings)
      else
        other_signup = ChallengeSignup.with_request_tags.find(other_signup_id)
        potential_match = other_signup.match(signup, settings)
      end

      potential_match.save if potential_match&.valid?
    end
  end

  # Get the ids of all signups that have some overlap in the tag types required for matching
  def self.matching_signup_ids(collection, signup, collection_tag_sets, required_types, prompt_type = "request")
    matching_signup_ids = []

    if required_types.empty?
      # nothing is required, so any signup can match -- check all of them
      return collection.signups.pluck(:id)
    end

    # get the tagsets used in the signup we are trying to match
    signup_tagsets = signup.send(prompt_type.pluralize).pluck(:tag_set_id, :optional_tag_set_id).flatten.compact

    # get the ids of all the tags of the required types in the signup's tagsets
    signup_tags = SetTagging.where(tag_set_id: signup_tagsets).joins(:tag).where(tags: { type: required_types }).pluck(:tag_id)

    if signup_tags.empty?
      # a match is required by the settings but the user hasn't put any of the required tags in, meaning they are open to anything
      return collection.signups.pluck(:id)
    else
      # now find all the tagsets in the collection that share the original signup's tags
      match_tagsets = SetTagging.where(tag_id: signup_tags, tag_set_id: collection_tag_sets).pluck(:tag_set_id).uniq

      # and now we look up any signups that have one of those tagsets in the opposite position -- ie,
      # if this signup is a request, we are looking for offers with the same tag; if it's an offer, we're
      # looking for requests with the same tag.
      matching_signup_ids = (prompt_type == "request" ? Offer : Request)
        .where("tag_set_id IN (?) OR optional_tag_set_id IN (?)", match_tagsets, match_tagsets)
        .pluck(:challenge_signup_id).compact

      # now add on "any" matches for the required types
      condition = case required_types.first.underscore
                  when "fandom"
                    "any_fandom = 1"
                  when "character"
                    "any_character = 1"
                  when "rating"
                    "any_rating = 1"
                  when "relationship"
                    "any_relationship = 1"
                  when "category"
                    "any_category = 1"
                  when "archive_warning"
                    "any_archive_warning = 1"
                  when "freeform"
                    "any_freeform = 1"
                  else
                    " 1 = 0"
                  end
      matching_signup_ids += collection.prompts.where(condition).pluck(:challenge_signup_id)
    end

    matching_signup_ids.uniq
  end

  # Regenerate potential matches for a single signup within a challenge where (presumably)
  # the other signups already have matches generated.
  # To do this, we have to regenerate its potential matches both as a request and as an offer
  # (instead of just generating them as a request as we do when generating ALL potential matches)
  def self.regenerate_for_signup_in_background(signup_id)
    # The signup will be acting as both offer and request, so we want to load
    # both request tags and offer tags.
    signup = ChallengeSignup.with_request_tags.with_offer_tags.find(signup_id)
    collection = signup.collection

    # Get all the data
    settings = collection.challenge.potential_match_settings
    collection_tag_sets = Prompt.where(collection_id: collection.id).pluck(:tag_set_id, :optional_tag_set_id).flatten.compact
    required_types = settings.required_types.map(&:classify)

    # clear the existing potential matches for this signup in each direction
    signup.offer_potential_matches.destroy_all
    signup.request_potential_matches.destroy_all

    # We check the signup in both directions -- as a request signup and as an offer signup
    %w[request offer].each do |prompt_type|
      PotentialMatch.generate_for_signup(collection, signup, settings, collection_tag_sets, required_types, prompt_type)
    end
  end

  # Finish off the potential match generation
  def self.finish_generation(collection)
    REDIS_GENERAL.del progress_key(collection)
    REDIS_GENERAL.del signup_key(collection)
    if PotentialMatch.canceled?(collection)
      REDIS_GENERAL.del interrupt_key(collection)
      # eventually we'll want to be able to pick up where we left off,
      # but not there yet
      PotentialMatch.clear!(collection)
    else
      ChallengeAssignment.delayed_generate(collection.id)
    end
  end

  def self.in_progress?(collection)
    if REDIS_GENERAL.get(progress_key(collection))
      if PotentialMatch.canceled?(collection)
        self.finish_generation(collection)
        return false
      end
      return true
    end
    false
  end

  # The PotentialMatcherProgress class calculates the percent, so we just need
  # to retrieve it from redis.
  def self.progress(collection)
    REDIS_GENERAL.get(progress_key(collection))
  end

  # sorting routine -- this gets used to rank the relative goodness of potential matches
  include Comparable
  def <=>(other)
    return 0 if self.id == other.id

    # start with seeing how many offers/requests match
    cmp = compare_all(self.num_prompts_matched, other.num_prompts_matched)
    return cmp unless cmp.zero?

    # compare the "quality" of the best prompt match
    # (i.e. the number of matching tags between the most closely-matching
    # request prompt/offer prompt pair)
    cmp = compare_all(max_tags_matched, other.max_tags_matched)
    return cmp unless cmp.zero?

    # if we're a match down to here just match on id
    self.id <=> other.id
  end

  protected

  def compare_all(self_value, other_value)
    if self_value == ALL
      other_value == ALL ? 0 : 1
    else
      (other_value == ALL ? -1 : self_value <=> other_value)
    end
  end
end
