class WorkQuery < Query
  include TaggableQuery

  def klass
    'Work'
  end

  def index_name
    WorkIndexer.index_name
  end

  def document_type
    WorkIndexer.document_type
  end

  # Combine the available filters
  def filters
    add_owner

    @filters ||= (
      visibility_filters +
      work_filters +
      creator_filters +
      collection_filters +
      tag_filters +
      range_filters
    ).flatten.compact
  end

  def exclusion_filters
    @exclusion_filters ||= [
      tag_exclusion_filter,
      named_tag_exclusion_filter
    ].flatten.compact
  end

  # Combine the available queries
  # In this case, name is the only text field
  def queries
    @queries = [
      general_query
    ].flatten.compact
  end

  def add_owner
    owner = options[:works_parent]

    if owner.is_a?(Language)
      options[:language_id] = owner.short
      return
    end

    field = case owner
            when Tag
              :filter_ids
            when Pseud
              :pseud_ids
            when User
              :user_ids
            when Collection
              :collection_ids
            end
    return unless field.present?
    options[field] ||= []
    options[field] << owner.id
  end

  ####################
  # GROUPS OF FILTERS
  ####################

  def visibility_filters
    [
      posted_filter,
      hidden_filter,
      restricted_filter,
      unrevealed_filter,
      anon_filter
    ]
  end

  def work_filters
    [
      complete_filter,
      single_chapter_filter,
      language_filter,
      crossover_filter,
      type_filter
    ]
  end

  def creator_filters
    [user_filter, pseud_filter]
  end

  def collection_filters
    [collection_filter]
  end

  def tag_filters
    [
      filter_id_filter,
      named_tag_inclusion_filter
    ].flatten.compact
  end

  def range_filters
    ranges = []
    [:word_count, :hits, :kudos_count, :comments_count, :bookmarks_count, :revised_at].each do |countable|
      if options[countable].present?
        ranges << { range: { countable => SearchRange.parsed(options[countable]) } }
      end
    end
    ranges += [date_range_filter, word_count_filter].compact
    ranges
  end

  ####################
  # FILTERS
  ####################

  def posted_filter
    term_filter(:posted, 'true')
  end

  def hidden_filter
    term_filter(:hidden_by_admin, 'false')
  end

  def restricted_filter
    term_filter(:restricted, 'false') unless include_restricted?
  end

  def unrevealed_filter
    term_filter(:in_unrevealed_collection, 'false') unless include_unrevealed?
  end

  def anon_filter
    term_filter(:in_anon_collection, 'false') unless include_anon?
  end

  def complete_filter
    term_filter(:complete, bool_value(options[:complete])) if options[:complete].present?
  end

  def single_chapter_filter
    term_filter(:expected_number_of_chapters, 1) if options[:single_chapter].present?
  end

  def language_filter
    term_filter(:"language_id.keyword", options[:language_id]) if options[:language_id].present?
  end

  def crossover_filter
    term_filter(:crossover, bool_value(options[:crossover])) if options[:crossover].present?
  end

  def type_filter
    terms_filter(:work_type, options[:work_types]) if options[:work_types]
  end

  def user_filter
    return if user_ids.blank?

    if viewing_own_collected_works_page?
      {
        has_child: {
          type: "creator",
          query: terms_filter(:private_user_ids, user_ids)
        }
      }
    else
      terms_filter(:user_ids, user_ids)
    end
  end

  def pseud_filter
    terms_filter(:pseud_ids, pseud_ids) if pseud_ids.present?
  end

  def collection_filter
    terms_filter(:collection_ids, options[:collection_ids]) if options[:collection_ids].present?
  end

  def filter_id_filter
    if filter_ids.present?
      filter_ids.map { |filter_id| term_filter(:filter_ids, filter_id) }
    end
  end

  def tag_exclusion_filter
    if exclusion_ids.present?
      exclusion_ids.map { |exclusion_id| term_filter(:filter_ids, exclusion_id) }
    end
  end

  # This filter is used to restrict our results to only include works
  # whose "tag" text matches all of the tag names in included_tag_names. This
  # is useful when the user enters a non-existent tag, which would be discarded
  # by the TaggableQuery.filter_ids function.
  def named_tag_inclusion_filter
    return if included_tag_names.blank?
    match_filter(:tag, included_tag_names.join(" "))
  end

  # This set of filters is used to prevent us from matching any works whose
  # "tag" text matches one of the passed-in tag names. This is useful when the
  # user enters a non-existent tag, which would be discarded by the
  # TaggableQuery.exclusion_ids function.
  #
  # Unlike the inclusion filter, we must separate these into different match
  # filters to get the results that we want (that is, excluding "A B" and "C D"
  # is the same as "not(A and B) and not(C and D)").
  def named_tag_exclusion_filter
    excluded_tag_names.map do |tag_name|
      match_filter(:tag, tag_name)
    end
  end

  def date_range_filter
    return unless options[:date_from].present? || options[:date_to].present?
    begin
      range = {}
      range[:gte] = clamp_search_date(options[:date_from].to_date) if options[:date_from].present?
      range[:lte] = clamp_search_date(options[:date_to].to_date) if options[:date_to].present?
      { range: { revised_at: range } }
    rescue ArgumentError
      nil
    end
  end

  def word_count_filter
    return unless options[:words_from].present? || options[:words_to].present?
    range = {}
    range[:gte] = options[:words_from].delete(",._").to_i if options[:words_from].present?
    range[:lte] = options[:words_to].delete(",._").to_i if options[:words_to].present?
    { range: { word_count: range } }
  end

  ####################
  # QUERIES
  ####################

  # Search for a tag by name
  # Note that fields don't need to be explicitly included in the
  # field list to be searchable directly (ie, "complete:true" will still work)
  def general_query
    input = (options[:q] || options[:query] || "").dup
    query = generate_search_text(input)

    return {
      query_string: {
        query: query,
        fields: ["creators^5", "title^7", "endnotes", "notes", "summary", "tag", "series.title"],
        default_operator: "AND"
      }
    } unless query.blank?
  end

  def generate_search_text(query = '')
    search_text = query
    %i[title creators].each do |field|
      search_text << split_query_text_words(field, options[field])
    end

    if options[:series_titles].present?
      search_text << split_query_text_words("series.title", options[:series_titles])
    end

    if options[:collection_ids].blank? && collected?
      search_text << " collection_ids:*"
    end
    escape_slashes(search_text.strip)
  end

  def sort
    column = options[:sort_column].present? ? options[:sort_column] : default_sort
    direction = options[:sort_direction].present? ? options[:sort_direction] : 'desc'
    sort_hash = { column => { order: direction } }

    if column == 'revised_at'
      sort_hash[column][:unmapped_type] = 'date'
    end

    [sort_hash, { id: { order: direction } }]
  end

  # When searching outside of filters, use relevance instead of date
  def default_sort
    facet_tags? || collected? ? 'revised_at' : '_score'
  end

  def aggregations
    aggs = {}
    if collected?
      aggs[:collections] = { terms: { field: 'collection_ids' } }
    end

    if facet_tags?
      %w(rating archive_warning category fandom character relationship freeform).each do |facet_type|
        aggs[facet_type] = { terms: { field: "#{facet_type}_ids" } }
      end
    end

    { aggs: aggs }
  end

  def works_per_language(languages_count)
    response = $elasticsearch.search(index: index_name, body: {
                                       size: 0,
                                       query: filtered_query,
                                       aggregations: {
                                         languages: {
                                           terms: { field: "language_id.keyword", size: languages_count }
                                         }
                                       }
                                     })
    language_counts = response.dig("aggregations", "languages", "buckets") || []
    language_counts.map(&:values).to_h
  end

  ####################
  # HELPERS
  ####################

  def facet_tags?
    options[:faceted]
  end

  def collected?
    options[:collected]
  end

  def viewing_own_collected_works_page?
    collected? && options[:works_parent].present? &&
      options[:works_parent] == User.current_user
  end

  def include_restricted?
    User.current_user.present? || options[:show_restricted]
  end

  # Include unrevealed works only if we're on a collection page
  # OR the collected works page of a user
  def include_unrevealed?
    options[:collection_ids].present? || collected?
  end

  # Include anonymous works if we're not on a user/pseud page
  # OR if the user is viewing their own collected works
  def include_anon?
    (user_ids.blank? && pseud_ids.blank?) ||
      viewing_own_collected_works_page?
  end

  def user_ids
    options[:user_ids]
  end

  def pseud_ids
    options[:pseud_ids]
  end

  # By default, ES6 expects yyyy-MM-dd and can't parse years with 4+ digits.
  def clamp_search_date(date)
    return date.change(year: 0) if date.year.negative?
    return date.change(year: 9999) if date.year > 9999
    date
  end
end
