module Articles
  module Feeds
    # This object is responsible for building the relevancy feed based on the given user and the
    # variant they are currently assigned.
    #
    # This class uses the Articles::Feeds::VariantQuery::Config to create a SQL statement.  The SQL
    # statement calculates a `relevancy_score` for each article based on the configuration of the
    # chosen relevancy levers.
    #
    # @see config/feed-variants/README.md
    # @see app/models/articles/feeds/README.md
    class VariantQuery
      Config = Struct.new(
        :variant,
        :description,
        :levers, # Array <Articles::Feeds::RelevancyLever::Configured>
        :order_by, # Articles::Feeds::OrderByLever
        :max_days_since_published,
        # when true, each time you call the query you will get different randomized numbers; when
        # false, the resulting randomized numbers will be the same within a window of time.
        :reseed_randomizer_on_each_request,
        keyword_init: true,
      ) do
        alias_method :reseed_randomizer_on_each_request?, :reseed_randomizer_on_each_request
      end
      # @api public
      #
      # @param variant [Symbol, #to_sym] the name of the variant query we're building.
      # @param assembler [Articles::Feeds::VariantAssembler, #call] responsible for converting the
      #        given variant to a config suitable for building a VariantQuery.
      # @param kwargs [Hash] named parameters to pass along to the #initialize method.
      #
      # @return [Articles::Feeds::VariantQuery]
      #
      # @see #initialize
      def self.build_for(variant:, assembler: VariantAssembler, **kwargs)
        config = assembler.call(variant: variant)
        new(config: config, **kwargs)
      end

      # @param config [Articles::Feeds::VariantQuery::Config]
      # @param user [User,NilClass]
      # @param number_of_articles [Integer, #to_i]
      # @param page [Integer, #to_i]
      # @param tag [NilClass] not used
      #
      # @param seed [Number] used in the `setseed` Postgresql function to set the randomization
      #        seed.  This parameter allows the caller (and debugger) to use the same randomization
      #        order in the queries; the hope being that this might help in any debugging.
      def initialize(config:, user: nil, number_of_articles: 50, page: 1, tag: nil, seed: nil, type_of: "discover")
        @user = user
        @number_of_articles = number_of_articles
        @page = page
        @tag = tag
        @config = config
        @type_of = type_of
        @seed = randomizer_seed_for(seed: seed, user: user)
        oldest_published_at = Articles::Feeds.oldest_published_at_to_consider_for(
          user: @user,
          days_since_published: config.max_days_since_published,
        )
        @query_parameters = {
          oldest_published_at: oldest_published_at,
          conditional_lookback: oldest_published_at - 12.hours,
          conditional_comment_timeframe: 6.hours.ago
        }
        configure!
      end

      attr_reader :config, :query_parameters, :seed

      # Query for articles relevant to the user's interest.
      #
      # @param only_featured [Boolean] select only articles that are "featured"
      # @param must_have_main_image [Boolean] select only articles that have a main image.
      # @param limit [Integer] the number of records to return
      # @param offset [Integer] start the paging window at the given offset
      # @param omit_article_ids [Array] don't include these articles in the search results
      #
      # @return ActiveRecord::Relation for Article
      #
      # @note This creates a complicated SQL query; well actually an ActiveRecord::Relation object
      #       on which you can call `to_sql`.  Which you might find helpful to see what's really
      #       going on.  A great place to do this is in the corresponding spec file.  See the
      #       example below:
      #
      # @example
      #
      #    user = User.first
      #    strategy = Articles::Feed::VariantQuery.build_for(variant: :original, user: user)
      #    puts strategy.call.to_sql
      #
      # rubocop:disable Layout/LineLength
      def call(only_featured: false, must_have_main_image: false, limit: default_limit, offset: default_offset, omit_article_ids: [], comments_variant: default_comments_variant)
        # rubocop:enable Layout/LineLength

        # These are the variables we'll pass to the SQL statement.
        repeated_query_variables = query_parameters.merge(
          omit_article_ids: omit_article_ids,
          now: Time.current,
          user_id: @user&.id,
        )

        # This needs to be an Array for Article.sanitize_sql.
        unsanitized_sql_sub_query = [
          sql_sub_query(
            only_featured: only_featured,
            must_have_main_image: must_have_main_image,
            limit: limit,
            offset: offset,
            omit_article_ids: omit_article_ids,
          ),
          repeated_query_variables,
        ]

        # Following this blog post: https://pganalyze.com/blog/active-record-subqueries-rails#the-from-subquery
        #
        # We're using the above `unsanitized_sql_sub_query` to create a result set so we can join to
        # articles and then sort on the attributes on either the article or the result set
        # (e.g. sort on the relevancy score).
        join_fragment = Arel.sql(
          "INNER JOIN (" \
          "\n--- The setseed needs to be called independently; later we reference seeder \n" \
          "WITH seeder AS (SELECT setseed(#{Float(@seed)})) " \
          "\n--- We are using the inner logic to build relevancy score" \
          "\n--- The outer part, with seeder, is to create a stable randomized number \n" \
          "SELECT inner_article_relevancies.id, " \
          "inner_article_relevancies.relevancy_score, " \
          "RANDOM() AS randomized_value " \
          "FROM seeder, " \
          "(#{Article.sanitize_sql(unsanitized_sql_sub_query)}) AS inner_article_relevancies" \
          ") AS article_relevancies ON articles.id = article_relevancies.id",
        )

        # This sub-query allows us to take the hard work of the hand-coded unsanitized sql and
        # create a sub-query that we can use to help ensure that we can use all of the ActiveRecord
        # goodness of scopes (e.g., limited_column_select) and eager includes.
        scope = Article.joins(join_fragment)
          .from_subforem
          .limited_column_select
          .includes(:distinct_reaction_categories, :context_notes)
          .includes(:subforem)
          .order(config.order_by.to_sql)

        scope = case comments_variant
                when "top_comments"
                  scope.includes(top_comments: :user)
                when "more_inclusive_top_comments"
                  scope.includes(more_inclusive_top_comments: :user)
                when "recent_good_comments"
                  scope.includes(recent_good_comments: :user)
                when "more_inclusive_recent_good_comments"
                  scope.includes(more_inclusive_recent_good_comments: :user)
                when "most_inclusive_recent_good_comments"
                  scope.includes(most_inclusive_recent_good_comments: :user)
                else
                  scope.includes(top_comments: :user) # fallback default
                end

        if @user.present? && (hidden_tags = @user.cached_antifollowed_tag_names).any?
          scope = scope.not_cached_tagged_with_any(hidden_tags)
        end

        scope
      end

      alias more_comments_minimal_weight_randomized call

      # Provided as a means to align interfaces with existing feeds.
      #
      # @note I really dislike this method name as it is opaque on
      #       it's purpose.
      # @note We're specifically In the LargeForemExperimental implementation, the
      #       default home feed omits the featured story.  In this
      #       case, I don't want to do that.  Instead, I want to see
      #       how this behaves.
      def default_home_feed(**)
        call
      end

      # The featured story should be the article that:
      #
      # - has the highest relevance score for the nil_user version
      # - has a main image (see note below).
      #
      # The other articles should use the nil_user version and require the `featured = true`
      # attribute.  In my envisioned implementation, the pagination would omit the featured story.
      #
      # @return [Array<Article, Array<Article>] a featured story
      #         Article and an array of Article objects.
      #
      # @note Per prior work, a featured story is the article that has a main image, is marked as
      #       featured (e.g., featured = true), and has the highest relevance score.  In the
      #       Articles::Feeds::LargeForemExperimental object we used the hotness_score to determine
      #       which to use.  The hotness score is most analogue to how this class calculates the
      #       relevance score when we don't have a user.
      #
      # @note There are requests to allow for the featured article to NOT require a main image.
      #       We're still talking through what that means.  This work relates to PR #15333.
      #
      # @note including the ** operator to mirror the method interface of the other feed strategies.
      #
      # @todo In other implementations, when user's aren't signed in we favor featured stories.  But
      #       not so much that they're in the featured story.  For non-signed in users, we may want
      #       to use a completely different set of scoring methods.
      #
      # @note The logic of Articles::Feeds::FindFeaturedStory does not (at present) filter apply an
      #       `Article.featured` scope.  [@jeremyf] I have reported this in
      #       https://github.com/forem/forem/issues/15613 to get clarity from product.
      def featured_story_and_default_home_feed(**)
        # the below implementation creates additional downstream complexities.
        articles = call
        featured_story = Articles::Feeds::FindFeaturedStory.call(articles)
        [featured_story, articles]
      end

      private

      def configure!
        @relevance_score_components = []

        # By default we always need to group by the `articles.id` column.  And as we apply relevancy
        # levers to the query, we need to add additional group_by clauses based on the chosen
        # relevancy levers.
        @group_by_fields = Set.new
        @group_by_fields << "articles.id"

        @joins = Set.new

        # Ensure that we honor a user's block requests.
        unless @user.nil?
          @joins << "LEFT OUTER JOIN user_blocks
            ON user_blocks.blocked_id = articles.user_id
              AND user_blocks.blocked_id IS NULL
              AND user_blocks.blocker_id = :user_id"
        end

        config.levers.each do |lever|
          # Don't attempt to use this factor if we don't have user.
          next if lever.user_required? && @user.nil?

          # This scoring method requires a group by clause.
          @group_by_fields << lever.group_by_fragment if lever.group_by_fragment.present?

          @joins += lever.joins_fragments if lever.joins_fragments.present?

          @relevance_score_components << build_score_element_from(
            select_fragment: lever.select_fragment,
            cases: lever.cases,
            fallback: lever.fallback,
          )

          # As implemented, this is not looking for collisions of named parameters.
          @query_parameters.merge!(lever.query_parameters)
        end
      end

      # Concatenate the required group by clauses.
      #
      # @return [String]
      def group_by_fields_as_sql
        @group_by_fields.join(", ")
      end

      # The sql statement for selecting based on relevance scores
      def sql_sub_query(limit:, offset:, omit_article_ids:, only_featured: false, must_have_main_image: false)
        where_clause = build_sql_with_where_clauses(
          only_featured: only_featured,
          must_have_main_image: must_have_main_image,
          omit_article_ids: omit_article_ids,
        )
        <<~THE_SQL_STATEMENT
          SELECT articles.id, (#{relevance_score_components_as_sql}) as relevancy_score
          FROM articles
          #{joins_clauses_as_sql}
          WHERE #{where_clause}
          GROUP BY #{group_by_fields_as_sql}
          ORDER BY relevancy_score DESC,
            articles.published_at DESC
          #{offset_and_limit_clause(offset: offset, limit: limit)}
        THE_SQL_STATEMENT
      end

      def build_sql_with_where_clauses(only_featured:, must_have_main_image:, omit_article_ids:)
        # Hardcode the values for lookback_hours and comment_hours.
        where_clauses = "articles.published = true"
        where_clauses += " AND articles.published_at < :now"
        where_clauses += " AND articles.score >= 0"
        if @type_of == "discover"
          where_clauses += " AND ((articles.published_at > :oldest_published_at)
            OR (articles.published_at > :conditional_lookback
            AND articles.last_comment_at > :conditional_comment_timeframe))"
        elsif @user
          user_ids = @user.cached_following_users_ids + @user.cached_following_organizations_ids + [0] # Adding one that will never be reached so we can have a valid SQL statement
          where_clauses += " AND articles.user_id IN (#{user_ids.join(',')})"
        end
        where_clauses += " AND articles.id NOT IN (:omit_article_ids)" unless omit_article_ids.compact.empty?
        where_clauses += " AND articles.featured = true" if only_featured
        where_clauses += " AND articles.main_image IS NOT NULL" if must_have_main_image
        where_clauses
      end

      def offset_and_limit_clause(offset:, limit:)
        if offset.to_i.positive?
          Article.sanitize_sql_array(["OFFSET ? LIMIT ?", offset, limit])
        else
          Article.sanitize_sql_array(["LIMIT ?", limit])
        end
      end

      def joins_clauses_as_sql
        # This is to just make things legible if we do `to_sql` on the ActiveRecord::Relation object
        @joins.join("\n")
      end

      # We multiply the relevance score components together.
      def relevance_score_components_as_sql
        @relevance_score_components.join(" * \n")
      end

      def default_limit
        @number_of_articles.to_i
      end

      def default_offset
        return 0 if @page.zero?

        (@page.to_i - 1) * default_limit
      end

      def default_comments_variant
        "top_comments"
      end

      # We want to ensure that we're not randomizing someone's feed all the time; and instead aiming
      # for somewhat repeatable experiences (e.g. I refresh the page it is likely I will have the
      # same order of pages even though we've randomized things a bit).
      def randomizer_seed_for(seed:, user:)
        return Float(seed) if seed

        return rand if config.reseed_randomizer_on_each_request?

        # This is added as a short-circuit in-case the caching proves to be non-performant.  Once
        # this has been merged, given that it's part of the main loop, we can remove the FeatureFlag
        # a week or so after we merge.
        return rand if FeatureFlag.enabled?(:halt_caching_for_feeds_random_seed)

        # From https://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html#method-i-fetch
        #
        # Setting :race_condition_ttl is very useful in situations where a cache entry is used very
        # frequently and is under heavy load. If a cache expires and due to heavy load several
        # different processes will try to read data natively and then they all will try to write to
        # cache. To avoid that case the first process to find an expired cache entry will bump the
        # cache expiration time by the value set in :race_condition_ttl.
        Rails.cache.fetch(
          "variant_query-for-#{user&.id || 'no-one'}",
          race_condition_ttl: 10.seconds,
          expires_in: 10.minutes,
        ) { rand }
      end

      # Responsible for transforming the :select_fragment, :cases, and :fallback into a SQL fragment
      # that we can use to multiply with the other SQL fragments.
      #
      # @param select_fragment [String]
      # @param cases [Array<Array<#to_i, #to_f>>]
      # @param fallback [#to_f]
      def build_score_element_from(select_fragment:, cases:, fallback:)
        values = []
        # I would love to sanitize this, but alas, we must trust this clause.
        text = "(CASE #{select_fragment}"
        cases.each do |value, factor|
          text += "\nWHEN ? THEN ?"
          values << value.to_i
          values << factor.to_f
        end
        text += "\nELSE ? END)"
        values << fallback.to_f
        values.unshift(text)

        Article.sanitize_sql_array(values)
      end
    end
  end
end
