# frozen_string_literal: true

class CategoryList
  CATEGORIES_PER_PAGE = 20
  SUBCATEGORIES_PER_CATEGORY = 5

  # Maximum number of categories before the optimized category page style is enforced
  MAX_UNOPTIMIZED_CATEGORIES = 1000

  include ActiveModel::Serialization

  cattr_accessor :preloaded_topic_custom_fields
  self.preloaded_topic_custom_fields = Set.new

  attr_accessor :categories, :uncategorized

  def self.register_included_association(association)
    @included_associations ||= []
    @included_associations << association if !@included_associations.include?(association)
  end

  def self.included_associations
    [
      :uploaded_background,
      :uploaded_background_dark,
      :uploaded_logo,
      :uploaded_logo_dark,
      :topic_only_relative_url,
      subcategories: [:topic_only_relative_url],
    ].concat(@included_associations || [])
  end

  def initialize(guardian = nil, options = {})
    @guardian = guardian || Guardian.new
    @options = options

    find_categories
    find_relevant_topics if options[:include_topics]

    prune_empty
    find_user_data
    sort_unpinned
    trim_results
    demote_muted

    if preloaded_topic_custom_fields.present?
      displayable_topics = @categories.map(&:displayable_topics)
      displayable_topics.flatten!
      displayable_topics.compact!

      if displayable_topics.present?
        Topic.preload_custom_fields(displayable_topics, preloaded_topic_custom_fields)
      end
    end
  end

  def preload_key
    "categories_list"
  end

  def self.order_categories(categories)
    if SiteSetting.fixed_category_positions
      categories.order(:position, :id)
    else
      categories
        .left_outer_joins(:featured_topics)
        .where("topics.category_id IS NULL OR topics.category_id IN (?)", categories.select(:id))
        .group("categories.id")
        .order("max(topics.bumped_at) DESC NULLS LAST")
        .order("categories.id ASC")
    end
  end

  private

  def relevant_topics_query
    @all_topics =
      Topic
        .secured(
          @guardian,
          include_uncategorized: false,
        ) # perf optimization since category featured topics can't have `category_id` set to null
        .listable_topics
        .visible
        .joins(
          "INNER JOIN category_featured_topics ON topics.id = category_featured_topics.topic_id",
        )
        .where("category_featured_topics.category_id IN (?)", categories_with_descendants.map(&:id))
        .select(
          "topics.*, category_featured_topics.category_id AS category_featured_topic_category_id",
        )
        .includes(:shared_draft, :category, { topic_thumbnails: %i[optimized_image upload] })
        .order("category_featured_topics.rank")

    @all_topics = @all_topics.joins(:tags).where(tags: { name: @options[:tag] }) if @options[
      :tag
    ].present?

    if @guardian.authenticated?
      @all_topics =
        @all_topics
          .joins(
            "LEFT JOIN topic_users tu ON topics.id = tu.topic_id AND tu.user_id = #{@guardian.user.id.to_i}",
          )
          .joins(
            "LEFT JOIN category_users ON category_users.category_id = topics.category_id AND category_users.user_id = #{@guardian.user.id}",
          )
          .where(
            "COALESCE(tu.notification_level,1) > :muted",
            muted: TopicUser.notification_levels[:muted],
          )
    end

    inclusions = [:last_poster]
    preload = DiscoursePluginRegistry.category_list_topics_preloader_associations
    inclusions.concat(preload) if preload.present?
    @all_topics = TopicQuery.remove_muted_tags(@all_topics, @guardian.user).includes(inclusions)
  end

  def find_relevant_topics
    featured_topics_by_category_id = Hash.new { |h, k| h[k] = [] }

    relevant_topics_query.each do |t|
      # hint for the serializer
      t.include_last_poster = true
      t.dismissed = dismissed_topic?(t)
      featured_topics_by_category_id[t.category_featured_topic_category_id] << t
    end

    categories_with_descendants.each do |category|
      category.displayable_topics = featured_topics_by_category_id[category.id]
    end
  end

  def dismissed_topic?(topic)
    if @guardian.current_user
      @dismissed_topic_users_lookup ||=
        DismissedTopicUser.lookup_for(@guardian.current_user, @all_topics)
      @dismissed_topic_users_lookup.include?(topic.id)
    else
      false
    end
  end

  def find_categories
    query = Category.includes(CategoryList.included_associations).secured(@guardian)
    query = query.includes(:localizations) if SiteSetting.content_localization_enabled
    query = self.class.order_categories(query)

    paginate = paginate_results?

    if @options[:parent_category_id].present? || paginate
      query = query.where(parent_category_id: @options[:parent_category_id])
    end

    page = [1, @options[:page].to_i].max
    if paginate
      query = query.limit(CATEGORIES_PER_PAGE).offset((page - 1) * CATEGORIES_PER_PAGE)
    elsif page > 1
      # Pagination is supported only when lazy load is enabled. If it is not,
      # everything is returned on page 1.
      query = query.none
    end

    query =
      DiscoursePluginRegistry.apply_modifier(:category_list_find_categories_query, query, self)

    @categories = query.to_a

    if paginate && @options[:parent_category_id].blank?
      categories_with_rownum =
        Category
          .secured(@guardian)
          .select(:id, "ROW_NUMBER() OVER (PARTITION BY parent_category_id) rownum")
          .where(parent_category_id: @categories.map { |c| c.id })

      subcategories_query =
        Category.includes(CategoryList.included_associations).where(
          "id IN (WITH cte AS (#{categories_with_rownum.to_sql}) SELECT id FROM cte WHERE rownum <= ?)",
          SUBCATEGORIES_PER_CATEGORY,
        )
      if SiteSetting.content_localization_enabled
        subcategories_query = subcategories_query.includes(:localizations)
      end
      @categories += subcategories_query
    end

    if SiteSetting.content_localization_enabled
      @categories.each { |c| LocalizationAttributesReplacer.localize_category(c, I18n.locale) }
    end

    if Site.preloaded_category_custom_fields.any?
      Category.preload_custom_fields(@categories, Site.preloaded_category_custom_fields)
    end

    include_subcategories = @options[:include_subcategories] == true

    if paginate
      subcategory_ids = {}
      Category
        .secured(@guardian)
        .where(parent_category_id: @categories.map(&:id))
        .pluck(:id, :parent_category_id)
        .each { |id, parent_id| (subcategory_ids[parent_id] ||= []) << id }
      @categories.each { |c| c.subcategory_ids = subcategory_ids[c.id] || [] }
    elsif @options[:parent_category_id].blank?
      subcategory_ids = {}
      subcategory_list = {}
      to_delete = Set.new
      @categories.each do |c|
        if c.parent_category_id.present?
          subcategory_ids[c.parent_category_id] ||= []
          subcategory_ids[c.parent_category_id] << c.id
          if include_subcategories
            subcategory_list[c.parent_category_id] ||= []
            subcategory_list[c.parent_category_id] << c
          end
          to_delete << c
        end
      end
      @categories.each do |c|
        c.subcategory_ids = subcategory_ids[c.id] || []
        c.subcategory_list = subcategory_list[c.id] || [] if include_subcategories
      end
      @categories.delete_if { |c| to_delete.include?(c) }
    end

    Category.preload_user_fields!(@guardian, categories_with_descendants)
  end

  def prune_empty
    return if SiteSetting.allow_uncategorized_topics
    @categories.delete_if { |c| c.uncategorized? }
  end

  # Attach some data for serialization to each topic
  def find_user_data
    if @guardian.current_user && @all_topics.present?
      topic_lookup = TopicUser.lookup_for(@guardian.current_user, @all_topics)
      @all_topics.each { |ft| ft.user_data = topic_lookup[ft.id] }
    end
  end

  # Put unpinned topics at the end of the list
  def sort_unpinned
    if @guardian.current_user && @all_topics.present?
      categories_with_descendants.each do |c|
        next if c.displayable_topics.blank? || c.displayable_topics.size <= c.num_featured_topics
        unpinned = []
        c.displayable_topics.each do |t|
          unpinned << t if t.pinned_at && PinnedCheck.unpinned?(t, t.user_data)
        end
        c.displayable_topics = (c.displayable_topics - unpinned) + unpinned unless unpinned.empty?
      end
    end
  end

  def demote_muted
    muted_categories = @categories.select { |category| category.notification_level == 0 }
    @categories = @categories.reject { |category| category.notification_level == 0 }
    @categories.concat muted_categories
  end

  def trim_results
    categories_with_descendants.each do |c|
      next if c.displayable_topics.blank?
      c.displayable_topics = c.displayable_topics[0, c.num_featured_topics]
    end
  end

  def categories_with_descendants(categories = @categories)
    return @categories_with_children if @categories_with_children && (categories == @categories)
    return nil if categories.nil?

    result = categories.flat_map { |c| [c, *categories_with_descendants(c.subcategory_list)] }

    @categories_with_children = result if categories == @categories

    result
  end

  def paginate_results?
    return true if @guardian.can_lazy_load_categories?

    query = Category.secured(@guardian)

    if @options[:parent_category_id].present?
      query = query.where(parent_category_id: @options[:parent_category_id])
    end

    # Enforce pagination for users who can see a large number of categories to
    # smooth out the performance of the category list page.
    query.count > MAX_UNOPTIMIZED_CATEGORIES
  end
end
