# frozen_string_literal: true

module Gitlab
  module Pagination
    module Keyset
      # This class stores information for one column (or SQL expression) which can be used in an
      # ORDER BY SQL clasue.
      # The goal of this class is to encapsulate all the metadata in one place which are needed to
      # make keyset pagination work in a generalized way.
      #
      # == Arguments
      #
      # **order expression** (Arel::Nodes::Node | String)
      #
      # The actual SQL expression for the ORDER BY clause.
      #
      # Examples:
      #   # Arel column order definition
      #   Project.arel_table[:id].asc # ORDER BY projects.id ASC
      #
      #   # Arel expression, calculated order definition
      #   Arel::Nodes::NamedFunction.new("COALESCE", [Project.arel_table[:issue_count].asc, 0]).asc # ORDER BY COALESCE(projects.issue_count, 0)
      #
      #   # Another Arel expression
      #   Arel::Nodes::Multiplication(Issue.arel_table[:weight], Issue.arel_table[:time_spent]).desc
      #
      #   # Raw string order definition
      #   'issues.type DESC NULLS LAST'
      #
      # **column_expression** (Arel::Nodes::Node | String)
      #
      # Expression for the database column or an expression. This value will be used with logical operations (>, <, =, !=)
      # when building the database query for the next page.
      #
      # Examples:
      #   # Arel column reference
      #   Issue.arel_table[:title]
      #
      #   # Calculated value
      #   Arel::Nodes::Multiplication(Issue.arel_table[:weight], Issue.arel_table[:time_spent])
      #
      # **attribute_name** (String | Symbol)
      #
      # An attribute on the loaded ActiveRecord model where the value can be obtained.
      #
      # Examples:
      #   # Simple attribute definition
      #   attribute_name = :title
      #
      #   # Later on this attribute will be used like this:
      #   my_record = Issue.find(x)
      #   value = my_record[attribute_name] # reads data from the title column
      #
      #   # Calculated value based on an Arel or raw SQL expression
      #
      #   attribute_name = :lowercase_title
      #
      #   # `lowercase_title` is not is not a table column therefore we need to make sure it's available in the `SELECT` clause
      #
      #   my_record = Issue.select(:id, 'LOWER(title) as lowercase_title').last
      #   value = my_record[:lowercase_title]
      #
      # **nullable** (:not_nullable | :nulls_last | :nulls_first)
      #
      # Tells us whether the database column is nullable or not. This information can be
      # obtained from the DB schema.
      #
      # If the column is not nullable, set this attribute to :not_nullable.
      #
      # If the column is nullable, then additional information is needed. Based on the ordering, the null values
      # will show up at the top or at the bottom of the resultset.
      #
      # Examples:
      #     # Nulls are showing up at the top (for example: ORDER BY column ASC):
      #     nullable = :nulls_first
      #
      #     # Nulls are showing up at the bottom (for example: ORDER BY column DESC):
      #     nullable = :nulls_last
      #
      # **order_direction**
      #
      # :asc or :desc
      #
      # Note: this is an optional attribute, the value will be inferred from the order_expression.
      # Sometimes it's not possible to infer the order automatically. In this case an exception will be
      # raised (when the query is executed). If the reverse order cannot be computed, it must be provided explicitly.
      #
      # **reversed_order_expression**
      #
      # The reversed version of the order_expression.
      #
      # A ColumnOrderDefinition object is able to reverse itself which is used when paginating backwards.
      # When a complex order_expression is provided (raw string), then reversing the order automatically
      # is not possible. In this case an exception will be raised.
      #
      # Example:
      #
      #   order_expression = Project.arel_table[:id].asc
      #   reversed_order_expression = Project.arel_table[:id].desc
      #
      # **add_to_projections**
      #
      # Set to true if the column is not part of the queried table. (Not part of SELECT *)
      #
      #  Example:
      #
      #  - When the order is a calculated expression or the column is in another table (JOIN-ed)
      #
      #  If the add_to_projections is true, the query builder will automatically add the column to the SELECT values
      #
      #  **sql_type**
      #
      # The SQL type of the column or SQL expression. This is an optional field which is only required when using the
      # column with the InOperatorOptimization class.
      #
      # Example: When the order expression is a calculated SQL expression.
      #
      #  {
      #    attribute_name: 'id_times_count',
      #    order_expression: Arel.sql('(id * count)').asc,
      #    sql_type: 'integer' # the SQL type here must match with the type of the produced data by the order_expression. Putting 'text' here would be incorrect.
      #  }
      #
      class ColumnOrderDefinition
        REVERSED_ORDER_DIRECTIONS = { asc: :desc, desc: :asc }.freeze
        REVERSED_NULL_POSITIONS = { nulls_first: :nulls_last, nulls_last: :nulls_first }.freeze
        AREL_ORDER_CLASSES = { Arel::Nodes::Ascending => :asc, Arel::Nodes::Descending => :desc }.freeze
        ALLOWED_NULLABLE_VALUES = [:not_nullable, :nulls_first, :nulls_last].freeze

        attr_reader :attribute_name, :column_expression, :order_expression, :add_to_projections, :order_direction

        def initialize(attribute_name:, order_expression:, column_expression: nil, reversed_order_expression: nil, nullable: :not_nullable, order_direction: nil, sql_type: nil, add_to_projections: false)
          @attribute_name = attribute_name
          @order_expression = order_expression
          @column_expression = column_expression || calculate_column_expression(order_expression)
          @reversed_order_expression = reversed_order_expression || calculate_reversed_order(order_expression)
          @nullable = parse_nullable(nullable)
          @order_direction = parse_order_direction(order_expression, order_direction)
          @sql_type = sql_type
          @add_to_projections = add_to_projections
        end

        def reverse
          self.class.new(
            attribute_name: attribute_name,
            column_expression: column_expression,
            order_expression: reversed_order_expression,
            reversed_order_expression: order_expression,
            nullable: not_nullable? ? :not_nullable : REVERSED_NULL_POSITIONS[nullable],
            order_direction: REVERSED_ORDER_DIRECTIONS[order_direction]
          )
        end

        def ascending_order?
          order_direction == :asc
        end

        def descending_order?
          order_direction == :desc
        end

        def nulls_first?
          nullable == :nulls_first
        end

        def nulls_last?
          nullable == :nulls_last
        end

        def not_nullable?
          nullable == :not_nullable
        end

        def nullable?
          !not_nullable?
        end

        def order_direction_as_sql_string
          sql_string = ascending_order? ? +'ASC' : +'DESC'

          if nulls_first?
            sql_string << ' NULLS FIRST'
          elsif nulls_last?
            sql_string << ' NULLS LAST'
          end

          sql_string
        end

        def sql_type
          raise Gitlab::Pagination::Keyset::SqlTypeMissingError.for_column(self) if @sql_type.nil?

          @sql_type
        end

        private

        attr_reader :reversed_order_expression, :nullable

        def calculate_reversed_order(order_expression)
          unless order_expression.is_a?(Arel::Nodes::Ordering)
            raise "Couldn't determine reversed order for `#{order_expression}`, please provide the `reversed_order_expression` parameter."
          end

          order_expression.reverse
        end

        def calculate_column_expression(order_expression)
          if order_expression.respond_to?(:expr)
            order_expression.expr
          else
            raise("Couldn't calculate the column expression. Please pass an ARel node as the order_expression, not a string.")
          end
        end

        def parse_order_direction(order_expression, order_direction)
          transformed_order_direction = if order_direction.present?
                                          order_direction.to_s.downcase.to_sym
                                        elsif order_expression.is_a?(Arel::Nodes::Ordering)
                                          AREL_ORDER_CLASSES[order_expression.class] || AREL_ORDER_CLASSES[order_expression.value.class]
                                        end

          unless REVERSED_ORDER_DIRECTIONS.has_key?(transformed_order_direction)
            raise "Invalid or missing `order_direction` (value: #{order_direction}) was given, the allowed values are: :asc or :desc"
          end

          transformed_order_direction
        end

        def parse_nullable(nullable)
          if ALLOWED_NULLABLE_VALUES.exclude?(nullable)
            raise "Invalid `nullable` is given (value: #{nullable}), the allowed values are: #{ALLOWED_NULLABLE_VALUES.join(', ')}"
          end

          nullable
        end
      end
    end
  end
end
