# frozen_string_literal: true

# Params:
#   iids: integer[]
#   state: 'open' or 'closed' or 'all'
#   group_id: integer
#   parent_id: integer
#   author_id: integer
#   author_username: string
#   label_name: string
#   milestone_title: string
#   search: string
#   sort: string
#   start_date: datetime
#   end_date: datetime
#   created_after: datetime
#   created_before: datetime
#   updated_after: datetime
#   updated_before: datetime
#   include_ancestor_groups: boolean
#   include_descendant_groups: boolean
#   starts_with_iid: string (containing a number)
#   confidential: boolean

class EpicsFinder < IssuableFinder
  include TimeFrameFilter
  include Gitlab::Utils::StrongMemoize
  extend ::Gitlab::Utils::Override

  IID_STARTS_WITH_PATTERN = %r{\A(\d)+\z}.freeze

  def self.scalar_params
    @scalar_params ||= %i[
      parent_id
      author_id
      author_username
      label_name
      milestone_title
      start_date
      end_date
      search
      my_reaction_emoji
    ]
  end

  def self.array_params
    @array_params ||= { issues: [], label_name: [] }
  end

  def self.valid_iid_query?(query)
    query.match?(IID_STARTS_WITH_PATTERN)
  end

  def klass
    Epic
  end

  def execute(skip_visibility_check: false)
    @skip_visibility_check = skip_visibility_check

    raise ArgumentError, 'group_id argument is missing' unless params[:group_id]
    return Epic.none unless Ability.allowed?(current_user, :read_epic, group)

    items = init_collection
    items = filter_items(items)
    items = filter_negated_items(items)

    # This has to be last as we use a CTE as an optimization fence
    # for counts by passing the force_cte param
    # https://www.postgresql.org/docs/current/static/queries-with.html
    items = by_search(items)

    sort(items)
  end

  def init_collection
    groups = if params[:iids].present?
               # If we are querying for specific iids, then we should only be looking at
               # those in the group, not any sub-groups (which can have identical iids).
               # The `group` method takes care of checking permissions
               [group]
             else
               permissioned_related_groups
             end

    epics = Epic.in_selected_groups(groups)
    with_confidentiality_access_check(epics, groups)
  end

  private

  def permissioned_related_groups
    strong_memoize(:permissioned_related_groups) do
      groups = related_groups

      # if user is member of top-level related group, he can automatically read
      # all epics in all subgroups
      next groups if can_read_all_epics_in_related_groups?(groups, include_confidential: false)

      groups_user_can_read_epics(groups)
    end
  end

  def groups_user_can_read_epics(groups)
    # `same_root` should be set only if we are sure that all groups
    # in related_groups have the same ancestor root group
    ::Group.groups_user_can_read_epics(groups, current_user, same_root: true)
  end

  def filter_items(items)
    items = by_created_at(items)
    items = by_updated_at(items)
    items = by_author(items)
    items = by_timeframe(items)
    items = by_state(items)
    items = by_label(items)
    items = by_parent(items)
    items = by_iids(items)
    items = by_my_reaction_emoji(items)
    items = by_confidential(items)
    items = by_milestone(items)

    starts_with_iid(items)
  end

  def filter_negated_items(items)
    return items unless not_filters_enabled?

    # API endpoints send in `nil` values so we test if there are any non-nil
    return items unless not_params&.values&.any?

    by_negated_label(items)
  end

  def group
    strong_memoize(:group) do
      next unless params[:group_id]

      if params[:group_id].is_a?(Group)
        params[:group_id]
      else
        Group.find(params[:group_id])
      end
    end
  end

  def starts_with_iid(items)
    return items unless params[:iid_starts_with].present?

    query = params[:iid_starts_with]
    raise ArgumentError unless self.class.valid_iid_query?(query)

    items.iid_starts_with(query)
  end

  def related_groups
    include_ancestors = params.fetch(:include_ancestor_groups, false)
    include_descendants = params.fetch(:include_descendant_groups, true)

    if include_ancestors && include_descendants
      group.self_and_hierarchy
    elsif include_ancestors
      group.self_and_ancestors
    elsif include_descendants
      group.self_and_descendants
    else
      Group.id_in(group.id)
    end
  end

  def count_key(value)
    last_value = Array(value).last

    if last_value.is_a?(Integer)
      Epic.states.invert[last_value].to_sym
    else
      last_value.to_sym
    end
  end

  def parent_id?
    params[:parent_id].present?
  end

  # rubocop: disable CodeReuse/ActiveRecord
  def by_parent(items)
    return items unless parent_id?

    items.where(parent_id: params[:parent_id])
  end
  # rubocop: enable CodeReuse/ActiveRecord

  def with_confidentiality_access_check(epics, groups)
    return epics if can_read_all_epics_in_related_groups?(groups)

    epics.not_confidential_or_in_groups(groups_with_confidential_access(groups))
  end

  def groups_with_confidential_access(groups)
    return ::Group.none unless current_user

    # groups is an array, not a relation here so we have to use `map`
    group_ids = groups.map(&:id)
    GroupMember.by_group_ids(group_ids).by_user_id(current_user).non_guests.select(:source_id)
  end

  # @param include_confidential [Boolean] if this method should factor in
  # confidential issues. Setting this to `false` will mean that it only checks
  # the user can view all non-confidential epics within all of these groups. It
  # does not check that they can view confidential epics and as such may return
  # `true` even if `groups` contains a group where the user cannot view
  # confidential epics. As such you should only call this with `false` if you
  # are planning on filtering out confidential epics separately.
  def can_read_all_epics_in_related_groups?(groups, include_confidential: true)
    return true if @skip_visibility_check
    return false unless current_user

    # If a user is a member of a group, he also inherits access to all subgroups,
    # so here we check if user is member of the top-level group (from the
    # list of groups being requested) - this is checked by
    # `read_confidential_epic` policy. If that's the case we don't need to
    # check membership on subgroups.
    #
    # `groups` is a list of groups in the same group hierarchy, by default
    # these should be ordered by nested level in the group hierarchy in
    # descending order (so top-level first), except if we fetch ancestors
    # - in that case top-level group is group's root parent
    parent = params.fetch(:include_ancestor_groups, false) ? groups.first.root_ancestor : group

    # If they can view confidential epics in this parent group they can
    # definitely view confidential epics in subgroups.
    return true if Ability.allowed?(current_user, :read_confidential_epic, parent)

    # If we don't account for confidential (assume it will be filtered later by
    # with_confidentiality_access_check) then as long as the user can see all
    # epics in this group they can see in all subgroups. This is only true for
    # private top level groups because it's possible that a top level public
    # group has private subgroups and therefore they would not necessarily be
    # able to read epics in the private subgroup even though they can in the
    # parent group.
    !include_confidential && parent.private? && Ability.allowed?(current_user, :read_epic, parent)
  end

  def by_confidential(items)
    return items if params[:confidential].nil?

    params[:confidential] ? items.confidential : items.public_only
  end

  # rubocop: disable CodeReuse/ActiveRecord
  def by_milestone(items)
    return items unless params[:milestone_title].present?

    milestones = Milestone.for_projects_and_groups(group_projects, permissioned_related_groups)
                          .where(title: params[:milestone_title])

    items.in_milestone(milestones)
  end
  # rubocop: enable CodeReuse/ActiveRecord

  def group_projects
    Project.in_namespace(permissioned_related_groups).with_issues_available_for_user(current_user)
  end

  override :feature_flag_scope
  def feature_flag_scope
    group
  end
end