# frozen_string_literal: true

class Category < ActiveRecord::Base
  RESERVED_SLUGS = ["none"]

  self.ignored_columns = [
    :suppress_from_latest, # TODO(2020-11-18): remove
    :required_tag_group_id, # TODO(2023-04-01): remove
    :min_tags_from_required_group, # TODO(2023-04-01): remove
  ]

  include Searchable
  include Positionable
  include HasCustomFields
  include CategoryHashtag
  include AnonCacheInvalidator
  include HasDestroyedWebHook

  REQUIRE_TOPIC_APPROVAL = "require_topic_approval"
  REQUIRE_REPLY_APPROVAL = "require_reply_approval"
  NUM_AUTO_BUMP_DAILY = "num_auto_bump_daily"

  register_custom_field_type(REQUIRE_TOPIC_APPROVAL, :boolean)
  register_custom_field_type(REQUIRE_REPLY_APPROVAL, :boolean)
  register_custom_field_type(NUM_AUTO_BUMP_DAILY, :integer)

  belongs_to :topic
  belongs_to :topic_only_relative_url,
             -> { select "id, title, slug" },
             class_name: "Topic",
             foreign_key: "topic_id"

  belongs_to :user
  belongs_to :latest_post, class_name: "Post"
  belongs_to :uploaded_logo, class_name: "Upload"
  belongs_to :uploaded_logo_dark, class_name: "Upload"
  belongs_to :uploaded_background, class_name: "Upload"

  has_many :topics
  has_many :category_users
  has_many :category_featured_topics
  has_many :featured_topics, through: :category_featured_topics, source: :topic

  has_many :category_groups, dependent: :destroy
  has_many :groups, through: :category_groups
  has_many :topic_timers, dependent: :destroy
  has_many :upload_references, as: :target, dependent: :destroy

  has_one :category_setting, dependent: :destroy

  delegate :auto_bump_cooldown_days, to: :category_setting, allow_nil: true

  has_and_belongs_to_many :web_hooks

  accepts_nested_attributes_for :category_setting, update_only: true

  validates :user_id, presence: true

  validates :name,
            if:
              Proc.new { |c|
                c.new_record? || c.will_save_change_to_name? ||
                  c.will_save_change_to_parent_category_id?
              },
            presence: true,
            uniqueness: {
              scope: :parent_category_id,
              case_sensitive: false,
            },
            length: {
              in: 1..50,
            }

  validates :num_featured_topics, numericality: { only_integer: true, greater_than: 0 }
  validates :search_priority, inclusion: { in: Searchable::PRIORITIES.values }

  validate :parent_category_validator
  validate :email_in_validator
  validate :ensure_slug
  validate :permissions_compatibility_validator

  validates :default_slow_mode_seconds,
            numericality: {
              only_integer: true,
              greater_than: 0,
            },
            allow_nil: true
  validates :auto_close_hours,
            numericality: {
              greater_than: 0,
              less_than_or_equal_to: 87_600,
            },
            allow_nil: true
  validates :slug, exclusion: { in: RESERVED_SLUGS }

  after_create :create_category_definition
  after_destroy :trash_category_definition
  after_destroy :clear_related_site_settings

  before_save :apply_permissions
  before_save :downcase_email
  before_save :downcase_name
  before_save :ensure_category_setting

  after_save :publish_discourse_stylesheet
  after_save :publish_category
  after_save :reset_topic_ids_cache
  after_save :clear_subcategory_ids
  after_save :clear_url_cache
  after_save :update_reviewables

  after_save do
    if saved_change_to_uploaded_logo_id? || saved_change_to_uploaded_logo_dark_id? ||
         saved_change_to_uploaded_background_id?
      upload_ids = [self.uploaded_logo_id, self.uploaded_logo_dark_id, self.uploaded_background_id]
      UploadReference.ensure_exist!(upload_ids: upload_ids, target: self)
    end
  end

  after_destroy :reset_topic_ids_cache
  after_destroy :publish_category_deletion
  after_destroy :remove_site_settings

  after_create :delete_category_permalink

  after_update :rename_category_definition, if: :saved_change_to_name?
  after_update :create_category_permalink, if: :saved_change_to_slug?

  after_commit :trigger_category_created_event, on: :create
  after_commit :trigger_category_updated_event, on: :update
  after_commit :trigger_category_destroyed_event, on: :destroy
  after_commit :clear_site_cache

  after_save_commit :index_search

  belongs_to :parent_category, class_name: "Category"
  has_many :subcategories, class_name: "Category", foreign_key: "parent_category_id"

  has_many :category_tags, dependent: :destroy
  has_many :tags, through: :category_tags
  has_many :none_synonym_tags,
           -> { where(target_tag_id: nil) },
           through: :category_tags,
           source: :tag
  has_many :category_tag_groups, dependent: :destroy
  has_many :tag_groups, through: :category_tag_groups

  has_many :category_required_tag_groups, -> { order(order: :asc) }, dependent: :destroy
  has_many :sidebar_section_links, as: :linkable, dependent: :delete_all
  has_many :embeddable_hosts, dependent: :destroy

  has_many :category_form_templates, dependent: :destroy
  has_many :form_templates, through: :category_form_templates

  belongs_to :reviewable_by_group, class_name: "Group"

  scope :latest, -> { order("topic_count DESC") }

  scope :secured,
        ->(guardian = nil) {
          ids = guardian.secure_category_ids if guardian

          if ids.present?
            where(
              "NOT categories.read_restricted OR categories.id IN (:cats)",
              cats: ids,
            ).references(:categories)
          else
            where("NOT categories.read_restricted").references(:categories)
          end
        }

  TOPIC_CREATION_PERMISSIONS ||= [:full]
  POST_CREATION_PERMISSIONS ||= %i[create_post full]

  scope :topic_create_allowed,
        ->(guardian) {
          scoped = scoped_to_permissions(guardian, TOPIC_CREATION_PERMISSIONS)

          if !SiteSetting.allow_uncategorized_topics && !guardian.is_staff?
            scoped = scoped.where.not(id: SiteSetting.uncategorized_category_id)
          end

          scoped
        }

  scope :post_create_allowed,
        ->(guardian) { scoped_to_permissions(guardian, POST_CREATION_PERMISSIONS) }

  delegate :post_template, to: "self.class"

  # permission is just used by serialization
  # we may consider wrapping this in another spot
  attr_accessor :displayable_topics,
                :permission,
                :subcategory_ids,
                :subcategory_list,
                :notification_level,
                :has_children

  # Allows us to skip creating the category definition topic in tests.
  attr_accessor :skip_category_definition

  @topic_id_cache = DistributedCache.new("category_topic_ids")

  def self.topic_ids
    @topic_id_cache.defer_get_set("ids") { Set.new(Category.pluck(:topic_id).compact) }
  end

  def self.reset_topic_ids_cache
    @topic_id_cache.clear
  end

  def reset_topic_ids_cache
    Category.reset_topic_ids_cache
  end

  # Accepts an array of slugs with each item in the array
  # Returns the category ids of the last slug in the array. The slugs array has to follow the proper category
  # nesting hierarchy. If any of the slug in the array is invalid or if the slugs array does not follow the proper
  # category nesting hierarchy, nil is returned.
  #
  # When only a single slug is provided, the category id of all the categories with that slug is returned.
  def self.ids_from_slugs(slugs)
    return [] if slugs.blank?

    params = {}
    params_index = 0

    sqls =
      slugs.map do |slug|
        category_slugs =
          slug.split(":").first(SiteSetting.max_category_nesting).map { Slug.for(_1, "") }

        sql = ""

        if category_slugs.length == 1
          params[:"slug_#{params_index}"] = category_slugs.first
          sql = "SELECT id FROM categories WHERE slug = :slug_#{params_index}"
          params_index += 1
        else
          category_slugs.each_with_index do |category_slug, index|
            params[:"slug_#{params_index}"] = category_slug

            sql =
              if index == 0
                "SELECT id FROM categories WHERE slug = :slug_#{params_index} AND parent_category_id IS NULL"
              else
                "SELECT id FROM categories WHERE parent_category_id = (#{sql}) AND slug = :slug_#{params_index}"
              end

            params_index += 1
          end
        end

        sql
      end

    DB.query_single(sqls.join("\nUNION ALL\n"), params)
  end

  @@subcategory_ids = DistributedCache.new("subcategory_ids")

  def self.subcategory_ids(category_id)
    @@subcategory_ids.defer_get_set(category_id.to_s) do
      sql = <<~SQL
            WITH RECURSIVE subcategories AS (
                SELECT :category_id id, 1 depth
                UNION
                SELECT categories.id, (subcategories.depth + 1) depth
                FROM categories
                JOIN subcategories ON subcategories.id = categories.parent_category_id
                WHERE subcategories.depth < :max_category_nesting
            )
            SELECT id FROM subcategories
          SQL
      DB.query_single(
        sql,
        category_id: category_id,
        max_category_nesting: SiteSetting.max_category_nesting,
      )
    end
  end

  def self.clear_subcategory_ids
    @@subcategory_ids.clear
  end

  def clear_subcategory_ids
    Category.clear_subcategory_ids
  end

  def top_level?
    self.parent_category_id.nil?
  end

  def self.scoped_to_permissions(guardian, permission_types)
    if guardian.try(:is_admin?)
      all
    elsif !guardian || guardian.anonymous?
      if permission_types.include?(:readonly)
        where("NOT categories.read_restricted")
      else
        where("1 = 0")
      end
    else
      permissions = permission_types.map { |p| CategoryGroup.permission_types[p] }
      where(
        "(:staged AND LENGTH(COALESCE(email_in, '')) > 0 AND email_in_allow_strangers)
          OR categories.id NOT IN (SELECT category_id FROM category_groups)
          OR categories.id IN (
                SELECT category_id
                  FROM category_groups
                 WHERE permission_type IN (:permissions)
                   AND (group_id = :everyone OR group_id IN (SELECT group_id FROM group_users WHERE user_id = :user_id))
             )",
        staged: guardian.is_staged?,
        permissions: permissions,
        user_id: guardian.user.id,
        everyone: Group::AUTO_GROUPS[:everyone],
      )
    end
  end

  def self.update_stats
    topics_with_post_count =
      Topic
        .select("topics.category_id, COUNT(*) topic_count, SUM(topics.posts_count) post_count")
        .where(
          "topics.id NOT IN (select cc.topic_id from categories cc WHERE topic_id IS NOT NULL)",
        )
        .group("topics.category_id")
        .visible
        .to_sql

    DB.exec <<~SQL
      UPDATE categories c
         SET topic_count = COALESCE(x.topic_count, 0),
             post_count = COALESCE(x.post_count, 0)
        FROM (
              SELECT ccc.id as category_id, stats.topic_count, stats.post_count
              FROM categories ccc
              LEFT JOIN (#{topics_with_post_count}) stats
              ON stats.category_id = ccc.id
             ) x
       WHERE x.category_id = c.id
         AND (c.topic_count <> COALESCE(x.topic_count, 0) OR c.post_count <> COALESCE(x.post_count, 0))
    SQL

    # Yes, there are a lot of queries happening below.
    # Performing a lot of queries is actually faster than using one big update
    # statement with sub-selects on large databases with many categories,
    # topics, and posts.
    #
    # The old method with the one query is here:
    # https://github.com/discourse/discourse/blob/5f34a621b5416a53a2e79a145e927fca7d5471e8/app/models/category.rb
    #
    # If you refactor this, test performance on a large database.

    Category.all.each do |c|
      topics = c.topics.visible
      topics = topics.where(["topics.id <> ?", c.topic_id]) if c.topic_id
      c.topics_year = topics.created_since(1.year.ago).count
      c.topics_month = topics.created_since(1.month.ago).count
      c.topics_week = topics.created_since(1.week.ago).count
      c.topics_day = topics.created_since(1.day.ago).count

      posts = c.visible_posts
      c.posts_year = posts.created_since(1.year.ago).count
      c.posts_month = posts.created_since(1.month.ago).count
      c.posts_week = posts.created_since(1.week.ago).count
      c.posts_day = posts.created_since(1.day.ago).count

      c.save if c.changed?
    end
  end

  def visible_posts
    query =
      Post
        .joins(:topic)
        .where(["topics.category_id = ?", self.id])
        .where("topics.visible = true")
        .where("posts.deleted_at IS NULL")
        .where("posts.user_deleted = false")
    self.topic_id ? query.where(["topics.id <> ?", self.topic_id]) : query
  end

  # Internal: Generate the text of post prompting to enter category description.
  def self.post_template
    I18n.t("category.post_template", replace_paragraph: I18n.t("category.replace_paragraph"))
  end

  def create_category_definition
    return if skip_category_definition

    Topic.transaction do
      t =
        Topic.new(
          title: I18n.t("category.topic_prefix", category: name),
          user: user,
          pinned_at: Time.now,
          category_id: id,
        )
      t.skip_callbacks = true
      t.ignore_category_auto_close = true
      t.delete_topic_timer(TopicTimer.types[:close])
      t.save!(validate: false)
      update_column(:topic_id, t.id)
      post = t.posts.build(raw: description || post_template, user: user)
      post.save!(validate: false)
      update_column(:description, post.cooked) if description.present?

      t
    end
  end

  def trash_category_definition
    self.topic&.trash!
  end

  def clear_related_site_settings
    SiteSetting.general_category_id = -1 if self.id == SiteSetting.general_category_id
  end

  def topic_url
    if has_attribute?("topic_slug")
      Topic.relative_url(topic_id, read_attribute(:topic_slug))
    else
      topic_only_relative_url.try(:relative_url)
    end
  end

  def description_text
    return nil unless self.description

    @@cache_text ||= LruRedux::ThreadSafeCache.new(1000)
    @@cache_text.getset(self.description) do
      text = Nokogiri::HTML5.fragment(self.description).text.strip
      ERB::Util.html_escape(text).html_safe
    end
  end

  def description_excerpt
    return nil unless self.description

    @@cache_excerpt ||= LruRedux::ThreadSafeCache.new(1000)
    @@cache_excerpt.getset(self.description) { PrettyText.excerpt(description, 300) }
  end

  def access_category_via_group
    Group
      .joins(:category_groups)
      .where("category_groups.category_id = ?", self.id)
      .where("groups.public_admission OR groups.allow_membership_requests")
      .order(:allow_membership_requests)
      .first
  end

  def duplicate_slug?
    Category.where(slug: self.slug, parent_category_id: parent_category_id).where.not(id: id).any?
  end

  def ensure_slug
    return unless name.present?

    self.name.strip!

    if slug.present?
      # if we don't unescape it first we strip the % from the encoded version
      slug = SiteSetting.slug_generation_method == "encoded" ? CGI.unescape(self.slug) : self.slug
      self.slug = Slug.for(slug, "", method: :encoded)

      if self.slug.blank?
        errors.add(:slug, :invalid)
      elsif SiteSetting.slug_generation_method == "ascii" && !CGI.unescape(self.slug).ascii_only?
        errors.add(:slug, I18n.t("category.errors.slug_contains_non_ascii_chars"))
      elsif duplicate_slug?
        errors.add(:slug, I18n.t("category.errors.is_already_in_use"))
      end
    else
      # auto slug
      self.slug = Slug.for(name, "")
      self.slug = "" if duplicate_slug?
    end

    # only allow to use category itself id.
    match_id = /\A(\d+)-category/.match(self.slug)
    if match_id.present?
      errors.add(:slug, :invalid) if new_record? || (match_id[1] != self.id.to_s)
    end
  end

  def slug_for_url
    slug.present? ? self.slug : "#{self.id}-category"
  end

  def publish_category
    if self.read_restricted
      group_ids = self.groups.pluck(:id)

      if group_ids.present?
        MessageBus.publish(
          "/categories",
          { categories: ActiveModel::ArraySerializer.new([self]).as_json },
          group_ids: group_ids,
        )
      end
    else
      MessageBus.publish(
        "/categories",
        { categories: ActiveModel::ArraySerializer.new([self]).as_json },
      )
    end
  end

  def remove_site_settings
    SiteSetting.all_settings.each do |s|
      SiteSetting.set(s[:setting], "") if s[:type] == "category" && s[:value].to_i == self.id
    end
  end

  def publish_category_deletion
    MessageBus.publish("/categories", deleted_categories: [self.id])
  end

  # This is used in a validation so has to produce accurate results before the
  # record has been saved
  def height_of_ancestors(max_height = SiteSetting.max_category_nesting)
    parent_id = self.parent_category_id

    return max_height if parent_id == id

    DB.query(<<~SQL, id: id, parent_id: parent_id, max_height: max_height)[0].max
      WITH RECURSIVE ancestors(parent_category_id, height) AS (
        SELECT :parent_id :: integer, 0

        UNION ALL

        SELECT
          categories.parent_category_id,
          CASE
            WHEN categories.parent_category_id = :id THEN :max_height
            ELSE ancestors.height + 1
          END
        FROM categories, ancestors
        WHERE categories.id = ancestors.parent_category_id
        AND ancestors.height < :max_height
      )

      SELECT max(height) FROM ancestors
    SQL
  end

  # This is used in a validation so has to produce accurate results before the
  # record has been saved
  def depth_of_descendants(max_depth = SiteSetting.max_category_nesting)
    parent_id = self.parent_category_id

    return max_depth if parent_id == id

    DB.query(<<~SQL, id: id, parent_id: parent_id, max_depth: max_depth)[0].max
      WITH RECURSIVE descendants(id, depth) AS (
        SELECT :id :: integer, 0

        UNION ALL

        SELECT
          categories.id,
          CASE
            WHEN categories.id = :parent_id THEN :max_depth
            ELSE descendants.depth + 1
          END
        FROM categories, descendants
        WHERE categories.parent_category_id = descendants.id
        AND descendants.depth < :max_depth
      )

      SELECT max(depth) FROM descendants
    SQL
  end

  def parent_category_validator
    if parent_category_id
      errors.add(:base, I18n.t("category.errors.uncategorized_parent")) if uncategorized?

      errors.add(:base, I18n.t("category.errors.self_parent")) if parent_category_id == id

      total_depth = height_of_ancestors + 1 + depth_of_descendants
      if total_depth > SiteSetting.max_category_nesting
        errors.add(:base, I18n.t("category.errors.depth"))
      end
    end
  end

  def group_names=(names)
    # this line bothers me, destroying in AR can not seem to be queued, thinking of extending it
    category_groups.destroy_all unless new_record?
    ids = Group.where(name: names.split(",")).pluck(:id)
    ids.each { |id| category_groups.build(group_id: id) }
  end

  # will reset permission on a topic to a particular
  # set.
  #
  # Available permissions are, :full, :create_post, :readonly
  #   hash can be:
  #
  # :everyone => :full - everyone has everything
  # :everyone => :readonly, :staff => :full
  # 7 => 1  # you can pass a group_id and permission id
  def set_permissions(permissions)
    self.read_restricted, @permissions = Category.resolve_permissions(permissions)

    # Ideally we can just call .clear here, but it runs SQL, we only want to run it
    # on save.
  end

  def permissions=(permissions)
    set_permissions(permissions)
  end

  def permissions_params
    hash = {}
    category_groups
      .includes(:group)
      .each do |category_group|
        if category_group.group.present?
          hash[category_group.group_name] = category_group.permission_type
        end
      end
    hash
  end

  def apply_permissions
    if @permissions
      category_groups.destroy_all
      @permissions.each do |group_id, permission_type|
        category_groups.build(group_id: group_id, permission_type: permission_type)
      end
      @permissions = nil
    end
  end

  def self.resolve_permissions(permissions)
    read_restricted = true

    everyone = Group::AUTO_GROUPS[:everyone]
    full = CategoryGroup.permission_types[:full]

    mapped =
      permissions.map do |group, permission|
        group_id = Group.group_id_from_param(group)
        permission = CategoryGroup.permission_types[permission] unless permission.is_a?(Integer)

        [group_id, permission]
      end

    mapped.each do |group, permission|
      return false, [] if group == everyone && permission == full

      read_restricted = false if group == everyone
    end

    [read_restricted, mapped]
  end

  def require_topic_approval?
    custom_fields[REQUIRE_TOPIC_APPROVAL]
  end

  def require_reply_approval?
    custom_fields[REQUIRE_REPLY_APPROVAL]
  end

  def num_auto_bump_daily
    custom_fields[NUM_AUTO_BUMP_DAILY]
  end

  def num_auto_bump_daily=(v)
    custom_fields[NUM_AUTO_BUMP_DAILY] = v
  end

  def auto_bump_limiter
    return nil if num_auto_bump_daily.to_i == 0
    RateLimiter.new(nil, "auto_bump_limit_#{self.id}", 1, 86_400 / num_auto_bump_daily.to_i)
  end

  def clear_auto_bump_cache!
    auto_bump_limiter&.clear!
  end

  def self.auto_bump_topic!
    bumped = false

    auto_bumps =
      CategoryCustomField
        .where(name: Category::NUM_AUTO_BUMP_DAILY)
        .where('NULLIF(value, \'\')::int > 0')
        .pluck(:category_id)

    if (auto_bumps.length > 0)
      auto_bumps.shuffle.each do |category_id|
        bumped = Category.find_by(id: category_id)&.auto_bump_topic!
        break if bumped
      end
    end

    bumped
  end

  # will automatically bump a single topic
  # if number of automatically bumped topics is smaller than threshold
  def auto_bump_topic!
    return false if num_auto_bump_daily.to_i == 0

    limiter = auto_bump_limiter
    return false if !limiter.can_perform?

    filters = []
    DiscourseEvent.trigger(:filter_auto_bump_topics, self, filters)

    relation = Topic

    filters.each { |filter| relation = filter.call(relation) } if filters.length > 0

    topic =
      relation
        .visible
        .listable_topics
        .exclude_scheduled_bump_topics
        .where(category_id: self.id)
        .where("id <> ?", self.topic_id)
        .where("bumped_at < ?", (self.auto_bump_cooldown_days || 1).days.ago)
        .where("pinned_at IS NULL AND NOT closed AND NOT archived")
        .order("bumped_at ASC")
        .limit(1)
        .first

    if topic
      topic.add_small_action(Discourse.system_user, "autobumped", nil, bump: true)
      limiter.performed!
      true
    else
      false
    end
  end

  def allowed_tags=(tag_names_arg)
    DiscourseTagging.add_or_create_tags_by_name(self, tag_names_arg, unlimited: true)
  end

  def allowed_tag_groups=(group_names)
    self.tag_groups = TagGroup.where(name: group_names).all.to_a
  end

  def required_tag_groups=(required_groups)
    map =
      Array(required_groups)
        .map
        .with_index { |rg, i| [rg["name"], { min_count: rg["min_count"].to_i, order: i }] }
        .to_h
    tag_groups = TagGroup.where(name: map.keys)

    self.category_required_tag_groups =
      tag_groups
        .map do |tag_group|
          attrs = map[tag_group.name]
          CategoryRequiredTagGroup.new(tag_group: tag_group, **attrs)
        end
        .sort_by(&:order)
  end

  def downcase_email
    self.email_in = (email_in || "").strip.downcase.presence
  end

  def email_in_validator
    return if self.email_in.blank?
    email_in
      .split("|")
      .each do |email|
        escaped = Rack::Utils.escape_html(email)
        if !Email.is_valid?(email)
          self.errors.add(:base, I18n.t("category.errors.invalid_email_in", email: escaped))
        elsif group = Group.find_by_email(email)
          self.errors.add(
            :base,
            I18n.t(
              "category.errors.email_already_used_in_group",
              email: escaped,
              group_name: Rack::Utils.escape_html(group.name),
            ),
          )
        elsif category = Category.where.not(id: self.id).find_by_email(email)
          self.errors.add(
            :base,
            I18n.t(
              "category.errors.email_already_used_in_category",
              email: escaped,
              category_name: Rack::Utils.escape_html(category.name),
            ),
          )
        end
      end
  end

  def downcase_name
    self.name_lower = name.downcase if self.name
  end

  def visible_group_names(user)
    self.groups.visible_groups(user)
  end

  def secure_group_ids
    groups.pluck("groups.id") if self.read_restricted?
  end

  def update_latest
    latest_post_id =
      Post
        .order("posts.created_at desc")
        .where("NOT hidden")
        .joins("join topics on topics.id = topic_id")
        .where("topics.category_id = :id", id: self.id)
        .limit(1)
        .pluck("posts.id")
        .first

    latest_topic_id =
      Topic
        .order("topics.created_at desc")
        .where("visible")
        .where("topics.category_id = :id", id: self.id)
        .limit(1)
        .pluck("topics.id")
        .first

    self.update(latest_topic_id: latest_topic_id, latest_post_id: latest_post_id)
  end

  def self.query_parent_category(parent_slug)
    encoded_parent_slug = CGI.escape(parent_slug) if SiteSetting.slug_generation_method == "encoded"
    self.where(slug: (encoded_parent_slug || parent_slug), parent_category_id: nil).pick(:id) ||
      self.where(id: parent_slug.to_i).pick(:id)
  end

  def self.query_category(slug_or_id, parent_category_id)
    encoded_slug_or_id = CGI.escape(slug_or_id) if SiteSetting.slug_generation_method == "encoded"
    self.where(
      slug: (encoded_slug_or_id || slug_or_id),
      parent_category_id: parent_category_id,
    ).first || self.where(id: slug_or_id.to_i, parent_category_id: parent_category_id).first
  end

  def self.find_by_email(email)
    self.where("string_to_array(email_in, '|') @> ARRAY[?]", Email.downcase(email)).first
  end

  def has_children?
    @has_children ||= (id && Category.where(parent_category_id: id).exists?) ? :true : :false
    @has_children == :true
  end

  def uncategorized?
    id == SiteSetting.uncategorized_category_id
  end

  def seeded?
    [
      SiteSetting.general_category_id,
      SiteSetting.meta_category_id,
      SiteSetting.staff_category_id,
      SiteSetting.uncategorized_category_id,
    ].include? id
  end

  def full_slug(separator = "-")
    start_idx = "#{Discourse.base_path}/c/".size
    url[start_idx..-1].gsub("/", separator)
  end

  @@url_cache = DistributedCache.new("category_url")

  def clear_url_cache
    @@url_cache.clear
  end

  def url
    @@url_cache.defer_get_set(self.id) do
      "#{Discourse.base_path}/c/#{slug_path.join("/")}/#{self.id}"
    end
  end

  def url_with_id
    Discourse.deprecate(
      "Category#url_with_id is deprecated. Use `Category#url` instead.",
      output_in_test: true,
      drop_from: "2.9.0",
    )

    url
  end

  # If the name changes, try and update the category definition topic too if it's an exact match
  def rename_category_definition
    return unless topic.present?
    old_name = saved_changes.transform_values(&:first)["name"]
    if topic.title == I18n.t("category.topic_prefix", category: old_name)
      topic.update_attribute(:title, I18n.t("category.topic_prefix", category: name))
    end
  end

  def create_category_permalink
    old_slug = saved_changes.transform_values(&:first)["slug"]

    url = +"#{Discourse.base_path}/c"
    url << "/#{parent_category.slug_path.join("/")}" if parent_category_id
    url << "/#{old_slug}/#{id}"
    url = Permalink.normalize_url(url)

    if Permalink.where(url: url).exists?
      Permalink.where(url: url).update_all(category_id: id)
    else
      Permalink.create(url: url, category_id: id)
    end
  end

  def delete_category_permalink
    permalink = Permalink.find_by_url("c/#{slug_path.join("/")}")
    permalink.destroy if permalink
  end

  def publish_discourse_stylesheet
    Stylesheet::Manager.cache.clear
  end

  def index_search
    Jobs.enqueue(
      :index_category_for_search,
      category_id: self.id,
      force: saved_change_to_attribute?(:name),
    )
  end

  def update_reviewables
    if should_update_reviewables?
      Reviewable.where(category_id: id).update_all(reviewable_by_group_id: reviewable_by_group_id)
    end
  end

  def self.find_by_slug_path(slug_path)
    return nil if slug_path.empty?
    return nil if slug_path.size > SiteSetting.max_category_nesting

    slug_path.map! { |slug| CGI.escape(slug.downcase) }

    query =
      slug_path.inject(nil) do |parent_id, slug|
        category = Category.where(slug: slug, parent_category_id: parent_id)

        if match_id = /\A(\d+)-category/.match(slug).presence
          category = category.or(Category.where(id: match_id[1], parent_category_id: parent_id))
        end

        category.select(:id)
      end

    Category.find_by_id(query)
  end

  def self.find_by_slug_path_with_id(slug_path_with_id)
    slug_path = slug_path_with_id.split("/")

    if slug_path.last =~ /\A\d+\Z/
      id = slug_path.pop.to_i
      Category.find_by_id(id)
    else
      Category.find_by_slug_path(slug_path)
    end
  end

  def self.find_by_slug(category_slug, parent_category_slug = nil)
    return nil if category_slug.nil?

    find_by_slug_path([parent_category_slug, category_slug].compact)
  end

  def subcategory_list_includes_topics?
    subcategory_list_style.end_with?("with_featured_topics")
  end

  %i[category_created category_updated category_destroyed].each do |event|
    define_method("trigger_#{event}_event") do
      DiscourseEvent.trigger(event, self)
      true
    end
  end

  def permissions_compatibility_validator
    # when saving subcategories
    if @permissions && parent_category_id.present?
      return if parent_category.category_groups.empty?

      parent_permissions = parent_category.category_groups.pluck(:group_id, :permission_type)
      child_permissions =
        (
          if @permissions.empty?
            [[Group[:everyone].id, CategoryGroup.permission_types[:full]]]
          else
            @permissions
          end
        )
      check_permissions_compatibility(parent_permissions, child_permissions)

      # when saving parent category
    elsif @permissions && subcategories.present?
      return if @permissions.empty?

      parent_permissions = @permissions
      child_permissions = subcategories_permissions.uniq

      check_permissions_compatibility(parent_permissions, child_permissions)
    end
  end

  def self.ensure_consistency!
    sql = <<~SQL
      SELECT t.id FROM topics t
      JOIN categories c ON c.topic_id = t.id
      LEFT JOIN posts p ON p.topic_id = t.id AND p.post_number = 1
      WHERE p.id IS NULL
    SQL

    DB.query_single(sql).each { |id| Topic.with_deleted.find_by(id: id).destroy! }

    sql = <<~SQL
      UPDATE categories c
      SET topic_id = NULL
      WHERE c.id IN (
        SELECT c2.id FROM categories c2
        LEFT JOIN topics t ON t.id = c2.topic_id AND t.deleted_at IS NULL
        WHERE t.id IS NULL AND c2.topic_id IS NOT NULL
      )
    SQL

    DB.exec(sql)

    Category
      .joins("LEFT JOIN topics ON categories.topic_id = topics.id AND topics.deleted_at IS NULL")
      .where("categories.id <> ?", SiteSetting.uncategorized_category_id)
      .where(topics: { id: nil })
      .find_each { |category| category.create_category_definition }
  end

  def slug_path
    if self.parent_category_id.present?
      slug_path = self.parent_category.slug_path
      slug_path.push(self.slug_for_url)
      slug_path
    else
      [self.slug_for_url]
    end
  end

  def cannot_delete_reason
    return I18n.t("category.cannot_delete.uncategorized") if self.uncategorized?
    return I18n.t("category.cannot_delete.has_subcategories") if self.has_children?

    if self.topic_count != 0
      oldest_topic = self.topics.where.not(id: self.topic_id).order("created_at ASC").limit(1).first
      if oldest_topic
        I18n.t(
          "category.cannot_delete.topic_exists",
          count: self.topic_count,
          topic_link: "<a href=\"#{oldest_topic.url}\">#{CGI.escapeHTML(oldest_topic.title)}</a>",
        )
      else
        # This is a weird case, probably indicating a bug.
        I18n.t("category.cannot_delete.topic_exists_no_oldest", count: self.topic_count)
      end
    end
  end

  def has_restricted_tags?
    tags.count > 0 || tag_groups.count > 0
  end

  private

  def ensure_category_setting
    self.build_category_setting if self.category_setting.blank?
  end

  def should_update_reviewables?
    SiteSetting.enable_category_group_moderation? && saved_change_to_reviewable_by_group_id?
  end

  def check_permissions_compatibility(parent_permissions, child_permissions)
    parent_groups = parent_permissions.map(&:first)

    return if parent_groups.include?(Group[:everyone].id)

    child_groups = child_permissions.map(&:first)
    only_subcategory_groups = child_groups - parent_groups

    if only_subcategory_groups.present?
      group_names = Group.where(id: only_subcategory_groups).pluck(:name).join(", ")
      errors.add(:base, I18n.t("category.errors.permission_conflict", group_names: group_names))
    end
  end

  def subcategories_permissions
    everyone = Group[:everyone].id
    full = CategoryGroup.permission_types[:full]

    result = DB.query(<<-SQL, id: id, everyone: everyone, full: full)
        SELECT category_groups.group_id, category_groups.permission_type
        FROM categories, category_groups
        WHERE categories.parent_category_id = :id
        AND categories.id = category_groups.category_id
        UNION
        SELECT :everyone, :full
        FROM categories
        WHERE categories.parent_category_id = :id
        AND (
          SELECT DISTINCT 1
          FROM category_groups
          WHERE category_groups.category_id = categories.id
        ) IS NULL
      SQL

    result.map { |row| [row.group_id, row.permission_type] }
  end

  def clear_site_cache
    Site.clear_cache
  end

  def on_custom_fields_change
    clear_site_cache
  end
end

# == Schema Information
#
# Table name: categories
#
#  id                                        :integer          not null, primary key
#  name                                      :string(50)       not null
#  color                                     :string(6)        default("0088CC"), not null
#  topic_id                                  :integer
#  topic_count                               :integer          default(0), not null
#  created_at                                :datetime         not null
#  updated_at                                :datetime         not null
#  user_id                                   :integer          not null
#  topics_year                               :integer          default(0)
#  topics_month                              :integer          default(0)
#  topics_week                               :integer          default(0)
#  slug                                      :string           not null
#  description                               :text
#  text_color                                :string(6)        default("FFFFFF"), not null
#  read_restricted                           :boolean          default(FALSE), not null
#  auto_close_hours                          :float
#  post_count                                :integer          default(0), not null
#  latest_post_id                            :integer
#  latest_topic_id                           :integer
#  position                                  :integer
#  parent_category_id                        :integer
#  posts_year                                :integer          default(0)
#  posts_month                               :integer          default(0)
#  posts_week                                :integer          default(0)
#  email_in                                  :string
#  email_in_allow_strangers                  :boolean          default(FALSE)
#  topics_day                                :integer          default(0)
#  posts_day                                 :integer          default(0)
#  allow_badges                              :boolean          default(TRUE), not null
#  name_lower                                :string(50)       not null
#  auto_close_based_on_last_post             :boolean          default(FALSE)
#  topic_template                            :text
#  contains_messages                         :boolean
#  sort_order                                :string
#  sort_ascending                            :boolean
#  uploaded_logo_id                          :integer
#  uploaded_background_id                    :integer
#  topic_featured_link_allowed               :boolean          default(TRUE)
#  all_topics_wiki                           :boolean          default(FALSE), not null
#  show_subcategory_list                     :boolean          default(FALSE)
#  num_featured_topics                       :integer          default(3)
#  default_view                              :string(50)
#  subcategory_list_style                    :string(50)       default("rows_with_featured_topics")
#  default_top_period                        :string(20)       default("all")
#  mailinglist_mirror                        :boolean          default(FALSE), not null
#  minimum_required_tags                     :integer          default(0), not null
#  navigate_to_first_post_after_read         :boolean          default(FALSE), not null
#  search_priority                           :integer          default(0)
#  allow_global_tags                         :boolean          default(FALSE), not null
#  reviewable_by_group_id                    :integer
#  read_only_banner                          :string
#  default_list_filter                       :string(20)       default("all")
#  allow_unlimited_owner_edits_on_first_post :boolean          default(FALSE), not null
#  default_slow_mode_seconds                 :integer
#  uploaded_logo_dark_id                     :integer
#
# Indexes
#
#  index_categories_on_email_in                (email_in) UNIQUE
#  index_categories_on_reviewable_by_group_id  (reviewable_by_group_id)
#  index_categories_on_search_priority         (search_priority)
#  index_categories_on_topic_count             (topic_count)
#  unique_index_categories_on_name             (COALESCE(parent_category_id, '-1'::integer), name) UNIQUE
#  unique_index_categories_on_slug             (COALESCE(parent_category_id, '-1'::integer), lower((slug)::text)) UNIQUE WHERE ((slug)::text <> ''::text)
#