# frozen_string_literal: true

class PostQuery
  extend Memoist

  class Error < StandardError; end
  class TagLimitError < Error; end

  # Metatags that don't count against the user's tag limit.
  UNLIMITED_METATAGS = %w[
    status rating limit is id date age filesize filetype parent child md5 width
    height duration mpixels ratio score upvote downvotes favcount embedded
    tagcount pixiv_id pixiv
  ]

  # Metatags that define the order of search results. These metatags can't be used more than once per query.
  ORDER_METATAGS = %w[order ordfav ordfavgroup ordpool]

  # Metatags that can't be used more than once per query, and that can't be used with OR or NOT operators.
  SINGLETON_METATAGS = ORDER_METATAGS + %w[limit random]

  attr_reader :current_user
  private attr_reader :tag_limit, :safe_mode, :builder

  delegate :tag?, :metatag?, :wildcard?, :metatags, :wildcards, :tag_names, :to_infix, :to_pretty_string, to: :ast
  alias_method :safe_mode?, :safe_mode
  alias_method :to_s, :to_infix

  # Return a new PostQuery with aliases replaced.
  def self.normalize(search, ...)
    search = search.to_s.strip

    # Optimize zero tag and single tag searches
    if search.blank?
      PostQuery.new(AST.all, ...)
    elsif search.match?(%r{\A[a-zA-Z0-9][a-zA-Z0-9();/+!?&'._~-]*\z}) && !search.downcase.in?(["and", "or"])
      PostQuery.new(AST.tag(search), ...).replace_aliases
    else
      PostQuery.new(search, ...).replace_aliases.rewrite_opts.trim
    end
  end

  # Perform a search and return the resulting posts
  def self.search(search, ...)
    post_query = PostQuery.normalize(search, ...)
    post_query.validate_tag_limit!
    post_query.with_implicit_metatags.posts
  end

  def initialize(search_or_ast, current_user: User.anonymous, tag_limit: nil, safe_mode: false)
    if search_or_ast.is_a?(AST)
      @ast = search_or_ast
    else
      @search = search_or_ast.to_s
    end

    @current_user = current_user
    @tag_limit = tag_limit
    @safe_mode = safe_mode
  end

  # Build a new PostQuery from the given AST and the current settings.
  def build(ast)
    PostQuery.new(ast, current_user: current_user, tag_limit: tag_limit, safe_mode: safe_mode)
  end

  def builder
    @builder ||= PostQueryBuilder.new(self, current_user, tag_limit: tag_limit, safe_mode: safe_mode)
  end

  def search
    @search ||= ast.to_infix
  end

  def ast
    @ast ||= Parser.parse(search, metatags: PostQueryBuilder::METATAGS)
  end

  def posts
    validate_metatags!
    builder.posts(to_cnf)
  end

  def paginated_posts(...)
    validate_metatags!
    builder.paginated_posts(to_cnf, ...)
  end

  # Perform a search and return N posts, or none if the search times out.
  #
  # @param n [Integer] The number of posts to return.
  # @param timeout [Integer] The search timeout, in milliseconds.
  # @param count [Integer] The number of posts matched by the search. An optional optimization if the search count is known ahead of time.
  # @return [Array<Post>]
  def posts_with_timeout(n, timeout: current_user.statement_timeout, count: post_count, **options)
    Post.with_timeout(timeout, []) do
      paginated_posts(1, limit: n, count: count, **options).to_a
    end
  end

  # The name of the only tag in the query, if the query contains a single tag. The tag may not exist. The query may contain other metatags or wildcards, and the tag may be negated.
  def tag_name
    tag_names.first if has_single_tag?
  end

  # The only tag in the query, if the query contains a single tag. The query may contain other metatags or wildcards, and the tag may be negated.
  def tag
    tags.to_a.first if has_single_tag?
  end

  # The list of all tags contained in the query.
  def tags
    Tag.where(name: tag_names)
  end

  # True if this search would return all posts (normally because the search is the empty string).
  def is_empty_search?
    ast.all?
  end

  # True if this search would return nothing (normally because there was a syntax error).
  def is_null_search?
    ast.none?
  end

  # True if the search is a single, non-negated metatag search for the given metatag. Assumes the query has been normalized.
  def is_metatag?(name, value = nil)
    if value.nil?
      metatag? && has_metatag?(name)
    else
      metatag? && find_metatag(name) == value.to_s
    end
  end

  # True if the search consists of a single tag, metatag, or wildcard.
  def is_single_term?
    tag_names.size + metatags.size + wildcards.size == 1
  end

  # True if this search consists only of a single non-negated tag, with no other metatags or operators.
  def is_simple_tag?
    ast.tag?
  end

  # True if the search contains a single tag. It may have other metatags or wildcards, and the tag may be negated.
  def has_single_tag?
    tag_names.one?
  end

  # True if the search depends on the current user because of permissions or privacy settings.
  def is_user_dependent_search?
    metatags.any? do |metatag|
      # XXX date: is user dependent because it depends on the current user's time zone
      metatag.name.in?(%w[date upvoter upvote downvoter downvote commenter comm search flagger fav ordfav favgroup ordfavgroup]) ||
      metatag.name == "status" && metatag.value == "unmoderated" ||
      metatag.name == "disapproved" && !metatag.value.downcase.in?(PostDisapproval::REASONS)
    end
  end

  def select_metatags(*names)
    metatags.select { |metatag| metatag.name.in?(names.map(&:to_s).map(&:downcase)) }
  end

  def has_metatag?(*names)
    select_metatags(*names).present?
  end

  def find_metatag(*names)
    select_metatags(*names).first&.value
  end

  # Return a new PostQuery with unnecessary AND and OR clauses eliminated.
  def trim
    build(ast.trim)
  end

  # Return a new PostQuery with the '~' operator replaced with OR clauses.
  def rewrite_opts
    build(ast.rewrite_opts)
  end

  # Return a new PostQuery with aliases replaced.
  def replace_aliases
    return self if aliases.empty?
    build(ast.replace_tags(aliases))
  end

  # Return a new PostQuery with implicit metatags (rating:safe and -status:deleted) added.
  def with_implicit_metatags
    return self if implicit_metatags.empty?
    build(AST.new(:and, [ast, *implicit_metatags]))
  end

  # Return a new PostQuery with terms sorted into alphabetical order.
  def sort
    build(ast.sort)
  end

  # Return a new PostQuery converted to conjunctive normal form.
  def to_cnf
    build(ast.to_cnf)
  end

  # Return a hash mapping aliased tag names to real tag names.
  def aliases
    TagAlias.aliases_for(tag_names)
  end

  # Implicit metatags are metatags added by the user's account settings. rating:g,s is implicit under safe mode.
  def implicit_metatags
    return [] unless safe_mode?

    tags = Danbooru.config.safe_mode_restricted_tags.map { |tag| -AST.tag(tag) }
    [AST.metatag("rating", "g"), *tags]
  end

  concerning :CountMethods do
    extend Memoist

    # @return [Integer, nil] The number of posts returned by the search, or nil on timeout.
    def post_count
      @post_count ||= fast_count
    end

    # Return an estimate of the number of posts returned by the search. By default, we try to use an
    # estimated or cached count before doing an exact count.
    #
    # @param timeout [Integer] The database timeout in milliseconds
    # @param estimate_count [Boolean] If true, estimate the count with inexact methods.
    # @param skip_cache [Boolean] If true, don't use the cached count.
    # @return [Integer, nil] The number of posts, or nil on timeout.
    def fast_count(timeout: 1_000, estimate_count: true, skip_cache: false)
      count = nil
      count = estimated_count(timeout) if estimate_count
      count = cached_count(timeout) if count.nil? && !skip_cache
      count = exact_count(timeout) if count.nil? && skip_cache
      count
    end

    # @param timeout [Integer] The maximum amount of time to spend calculating the count.
    # @param exact_count_threshold [Integer] If the estimate is below this threshold, return an exact count instead.
    # @return [Integer, nil] The estimated post count, or nil if we can't compute a good estimate.
    def estimated_count(timeout = 1_000, exact_count_threshold: 20_000)
      if is_empty_search? || is_metatag?(:rating)
        # If there's a small number of posts, then the estimate may be noticeably off if the table hasn't been vacuumed
        # recently. Return the exact number of posts because calculating it is fast enough anyway.
        if estimated_row_count < exact_count_threshold
          exact_count(timeout)
        else
          estimated_row_count
        end
      elsif is_simple_tag?
        tag.try(:post_count)
      elsif (is_metatag?(:status) || is_metatag?(:is)) && metatags.sole.value.in?(%w[pending flagged appealed modqueue unmoderated])
        exact_count(timeout)
      elsif is_metatag?(:pool) || is_metatag?(:ordpool)
        name = find_metatag(:pool, :ordpool)

        if name.downcase.in?(Pool::RESERVED_NAMES)
          nil
        else
          Pool.find_by_name(name)&.post_count || 0
        end
      elsif is_metatag?(:fav) || is_metatag?(:ordfav)
        name = find_metatag(:fav, :ordfav)
        user = User.find_by_name(name)

        if user.nil?
          0
        elsif Pundit.policy!(current_user, user).can_see_favorites?
          user.favorite_count
        else
          nil
        end
      end
    end

    # Estimate the count by parsing the Postgres EXPLAIN output.
    memoize def estimated_row_count
      ExplainParser.new(posts).row_count
    end

    def cached_count(timeout, duration: 5.minutes)
      Cache.get(count_cache_key, duration) do
        exact_count(timeout)
      end
    end

    def exact_count(timeout)
      Post.with_timeout(timeout) do
        posts.count
      end
    end

    def count_cache_key
      if is_user_dependent_search?
        "post-count-for-user:#{current_user.id.to_i}:#{to_s}"
      else
        "post-count:#{to_s}"
      end
    end
  end

  concerning :ValidationMethods do
    def validate_tag_limit!
      return if is_empty_search? || is_simple_tag?
      raise TagLimitError if tag_limit.present? && term_count > tag_limit
    end

    def validate_metatags!
      return if is_empty_search? || is_simple_tag?
      return if metatags.empty?

      order_metatags = select_metatags(*ORDER_METATAGS).map(&:to_s).map(&:downcase).uniq
      raise Error, "#{order_metatags.to_sentence} can't be used together." if order_metatags.size > 1

      SINGLETON_METATAGS.each do |name|
        metatag = select_metatags(name).first
        raise Error, "'#{name}:' can't be used more than once." if select_metatags(name).map(&:to_s).map(&:downcase).uniq.size > 1
        raise Error, "'#{metatag}' can't be negated." if metatag&.parents&.any?(&:not?)
        raise Error, "'#{metatag}' can't be used with the 'or' operator." if metatag&.parents&.any?(&:or?)
      end
    end

    # The number of unique tags, wildcards, and metatags in the search, excluding metatags that don't count against the user's tag limit.
    def term_count
      tag_names.size + wildcards.size + metatags.count { !_1.name.in?(UNLIMITED_METATAGS) }
    end

    # @return [Integer] The total number of terms in the query, including free metatags.
    def total_term_count
      tag_names.size + wildcards.size + metatags.size
    end
  end

  memoize :tags, :replace_aliases, :with_implicit_metatags, :to_cnf, :aliases, :implicit_metatags, :term_count
end
