# frozen_string_literal: true

require_relative "../../gitlab/dangerfiles/teammate"
require_relative "../../gitlab/dangerfiles/weightage/maintainers"
require_relative "../../gitlab/dangerfiles/weightage/reviewers"

module Danger
  # Common helper functions for our danger scripts. See Danger::Helper
  # for more details
  class Roulette < Danger::Plugin
    ROULETTE_DATA_URL = "https://gitlab-org.gitlab.io/gitlab-roulette/roulette.json"
    HOURS_WHEN_PERSON_CAN_BE_PICKED = (6..14).freeze

    Spin = Struct.new(:category, :reviewer, :maintainer, :optional_role)
    HTTPError = Class.new(StandardError)

    Approval = Struct.new(:category, :spin) do
      def self.from_approval_rule(rule, maintainer)
        category =
          if rule["section"] == "codeowners"
            "`#{rule["name"]}`"
          else
            rule["section"]
          end.to_sym

        spin = Spin.new(category, nil, maintainer, :reviewer)

        new(category, spin)
      end
    end

    def prepare_categories(changes_keys)
      categories = Set.new(changes_keys)

      # Ensure to spin for database reviewer/maintainer when ~database is applied (e.g. to review SQL queries)
      categories << :database if labels.include?("database")

      # Ensure to spin for Analytics Instrumentation reviewer when ~"analytics instrumentation::review pending" is applied
      categories << :analytics_instrumentation if labels.include?("analytics instrumentation::review pending")

      # Skip Analytics Instrumentation reviews for growth experiment MRs
      categories.delete(:analytics_instrumentation) if labels.include?("growth experiment")

      prepare_ux_category!(categories) if labels.include?("UX")

      # Remove disabled categories
      categories.subtract(helper.config.disabled_roulette_categories)

      categories
    end

    # Finds the +Gitlab::Dangerfiles::Teammate+ object whose username matches the MR author username.
    #
    # @return [Gitlab::Dangerfiles::Teammate]
    def team_mr_author
      @team_mr_author ||= find_member(helper.mr_author)
    end

    # Assigns GitLab team members to be reviewer and maintainer
    # for the given +categories+.
    #
    # @param project [String] A project path.
    # @param categories [Array<Symbol>] An array of categories symbols.
    #
    # @return [Array<Spin>]
    # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
    def spin(project = nil, categories = [:none], ux_fallback_wider_community_reviewer: teammate_pedroms)
      project = (project || config_project_name).downcase
      categories = categories.map { |category| category&.downcase || :none }
      categories.reject! { |category| import_and_integrate_reject_category?(category, project) }

      spins = categories.sort_by(&:to_s).map do |category|
        spin_for_category(project, category)
      end

      backend_spin = spins.find { |spin| spin.category == :backend }
      frontend_spin = spins.find { |spin| spin.category == :frontend }

      spins.each do |spin|
        case spin.category
        when :qa
          # MR includes QA changes, but also other changes, and author isn't an SET
          if categories.size > 1 &&
             !(team_mr_author && team_mr_author.capabilities(project).any? { |capability| capability.end_with?("qa") })
            spin.optional_role = :maintainer
          end
        when :test
          spin.optional_role = :maintainer

          if spin.reviewer.nil?
            # Fetch an already picked backend reviewer, or pick one otherwise
            spin.reviewer = backend_spin&.reviewer || spin_for_category(project, :backend).reviewer
          end
        when :tooling, :engineering_productivity # Deprecated as of 2.3.0 in favor of tooling
          if spin.maintainer.nil?
            # Fetch an already picked backend maintainer, or pick one otherwise
            spin.maintainer = backend_spin&.maintainer || spin_for_category(project, :backend).maintainer
          end
        when :ci_template
          if spin.maintainer.nil?
            # Fetch an already picked backend maintainer, or pick one otherwise
            spin.maintainer = backend_spin&.maintainer || spin_for_category(project, :backend).maintainer
          end
        when :analytics_instrumentation
          spin.optional_role = :maintainer

          if spin.maintainer.nil?
            # Fetch an already picked maintainer, or pick one otherwise
            spin.maintainer = backend_spin&.maintainer || frontend_spin&.maintainer || spin_for_category(project, :backend).maintainer
          end
        when :import_integrate_be, :import_integrate_fe
          spin.optional_role = :maintainer
        when :ux
          spin.optional_role = :maintainer

          # We want at least a UX reviewer who can review any wider community
          # contribution even without a team designer. We assign this to Pedro.
          spin.reviewer = ux_fallback_wider_community_reviewer if
            labels.include?("Community contribution") &&
            spin.reviewer.nil? &&
            spin.maintainer.nil?
        end
      end

      spins
    end

    # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity

    def codeowners_approvals
      approval_rules = helper.mr_approval_state["rules"]

      return [] unless approval_rules

      required_approval_rules = unique_approval_rules(approval_rules)
      required_approval_rules.filter_map do |rule|
        spin_for_approval_rule?(rule) &&
          Approval.from_approval_rule(rule, spin_for_approver(rule))
      end
    end

    def warnings
      @warnings ||= []
    end

    def teammate_pedroms
      @teammate_pedroms ||= find_member("pedroms")
    end

    alias_method :required_approvals, :codeowners_approvals

    private

    def spin_for_approval_rule?(rule)
      rule["rule_type"] == "code_owner" &&
        should_include_codeowners_rule?(rule) &&
        # Exclude generic codeowners rule, which should be covered by others already
        !generic_codeowners_rule?(rule) &&
        !excluded_required_codeowners_rule?(rule)
    end

    def should_include_codeowners_rule?(rule)
      rule["approvals_required"] > 0 ||
        helper.config.included_optional_codeowners_sections_for_roulette.include?(rule["section"])
    end

    def excluded_required_codeowners_rule?(rule)
      helper.config.excluded_required_codeowners_sections_for_roulette.include?(rule["section"])
    end

    def generic_codeowners_rule?(rule)
      rule["section"] == "codeowners" && rule["name"] == "*"
    end

    # Returns an array containing all unique approval rules, based on on the section and eligible_approvers of the rules
    #
    # @param [Array<Hash>] approval rules
    # @return [Array<Hash>]
    def unique_approval_rules(approval_rules)
      approval_rules.uniq do |rule|
        section = rule["section"]

        approvers = rule["eligible_approvers"].map do |approver|
          approver["username"]
        end

        [section, approvers]
      end
    end

    # @param [Gitlab::Dangerfiles::Teammate] person
    # @return [Boolean]
    def valid_person?(person)
      !mr_author?(person) && person.available
    end

    # @param [Gitlab::Dangerfiles::Teammate] person
    # @return [Boolean]
    def mr_author?(person)
      person.username == helper.mr_author
    end

    # @param [String] category name
    # @return [Boolean]
    def import_and_integrate_reject_category?(category, project)
      # Reject Import and Integrate categories if the MR author has reviewing abilities for the category.
      team_mr_author&.import_integrate_be?(project, category, labels) ||
        team_mr_author&.import_integrate_fe?(project, category, labels)
    end

    def random
      @random ||= Random.new(Digest::MD5.hexdigest(helper.mr_source_branch).to_i(16))
    end

    def spin_role_for_category(team, role, project, category)
      team.select do |member|
        member.public_send("#{role}?", project, category, labels)
      end
    end

    # Known issue: If someone is rejected due to OOO, and then becomes not OOO, the
    # selection will change on next spin.
    #
    # @param [Array<Gitlab::Dangerfiles::Teammate>] people
    #
    # @return [Gitlab::Dangerfiles::Teammate]
    def spin_for_person(people)
      shuffled_people = people.shuffle(random: random)

      shuffled_people.find(&method(:valid_person?))
    end

    # Spin a reviewer for a particular approval rule
    #
    # @param [Hash] rule of approval
    #
    # @return [Gitlab::Dangerfiles::Teammate]
    def spin_for_approver(rule)
      approvers = rule["eligible_approvers"].filter_map do |approver|
        find_member(approver["username"], project: config_project_name.downcase)
      end

      spin_for_person(approvers) || spin_for_approver_fallback(rule)
    end

    # It can be possible that we don't have a valid reviewer for approval.
    # In this case, we sample again without considering:
    #
    # * If they're available
    # * If they're an actual reviewer from roulette data
    #
    # We do this because we strictly require an approval from the approvers.
    #
    # @param [Hash] rule of approval
    #
    # @return [Gitlab::Dangerfiles::Teammate]
    def spin_for_approver_fallback(rule)
      fallback_approvers = rule["eligible_approvers"].map do |approver|
        find_member(approver["username"]) ||
          Gitlab::Dangerfiles::Teammate.new(approver)
      end

      # Intentionally not using `spin_for_person` to skip `valid_person?`.
      # This should strictly return someone so we don't filter anything,
      # and it's a fallback mechanism which should not happen often that
      # deserves a complex algorithm.
      fallback_approvers.sample(random: random)
    end

    def spin_for_category(project, category)
      team = project_team(project)
      reviewers, traintainers, maintainers =
        %i[reviewer traintainer maintainer].map do |role|
          spin_role_for_category(team, role, project, category)
        end

      weighted_reviewers = Gitlab::Dangerfiles::Weightage::Reviewers.new(reviewers, traintainers).execute
      weighted_maintainers = Gitlab::Dangerfiles::Weightage::Maintainers.new(maintainers).execute

      reviewer = spin_for_person(weighted_reviewers)
      maintainer = spin_for_person(weighted_maintainers)

      # allow projects with small number of reviewers to take from maintainers if possible
      if reviewer.nil? && weighted_maintainers.uniq.size > 1
        weighted_maintainers.delete(maintainer)
        reviewer = spin_for_person(weighted_maintainers)
      end

      Spin.new(category, reviewer, maintainer, false)
    end

    def prepare_ux_category!(categories)
      if labels.include?("Community contribution")
        categories << :ux
      else
        begin
          # We only want to spin a reviewer for merge requests which has a
          # designer for the team. There's no easy way to tell this, so we
          # pretend this is a community contribution, in which case we only
          # pick the team designer. If there's no one got picked, it means
          # there's no designer for this team.
          labels << "Community contribution"

          # Don't use a fallback reviewer so when a group doesn't have
          # available reviewers, it'll not give us any reviewers.
          ux_spin = spin(nil, [:ux], ux_fallback_wider_community_reviewer: nil).first

          categories << :ux if ux_spin.reviewer || ux_spin.maintainer
        ensure
          # Make sure we delete the label afterward
          labels.delete("Community contribution")
        end
      end
    end

    # Fetches the given +url+ and parse its response as JSON.
    #
    # @param [String] url
    #
    # @return [Hash, Array, NilClass]
    def http_get_json(url)
      rsp = Net::HTTP.get_response(URI.parse(url))

      if rsp.is_a?(Net::HTTPRedirection)
        if (uri = URI.parse(rsp.header["location"]))
          uri.query = nil
        end

        warnings << "Redirection detected: #{uri}."
        return nil
      end

      unless rsp.is_a?(Net::HTTPOK)
        message = rsp.message[0, 30]
        warnings << "HTTPError: Failed to read #{url}: #{rsp.code} #{message}."
        return nil
      end

      JSON.parse(rsp.body)
    end

    # Looks up the current list of GitLab team members and parses it into a
    # useful form.
    #
    # @return [Array<Gitlab::Dangerfiles::Teammate>]
    def company_members
      @company_members ||= begin
          data = http_get_json(ROULETTE_DATA_URL) || []
          data.map { |hash| Gitlab::Dangerfiles::Teammate.new(hash) }
        rescue JSON::ParserError
          warnings << "Failed to parse JSON response from #{ROULETTE_DATA_URL}"
          []
        end
    end

    def find_member(username, project: nil)
      company_members.find do |member|
        member.username == username &&
          if project
            member.in_project?(project)
          else
            true
          end
      end
    end

    # Return the configured project name
    #
    # @return [String]
    def config_project_name
      helper.config.project_name
    end

    # Return the labels from the merge requests. This is cached.
    #
    # @return [String]
    def labels
      @labels ||= helper.mr_labels
    end

    # Like +team+, but only returns teammates in the current project, based on
    # project_name.
    #
    # @return [Array<Gitlab::Dangerfiles::Teammate>]
    def project_team(project_name)
      company_members.select do |member|
        member.in_project?(project_name)
      end
    rescue => err
      warn("Reviewer roulette failed to load team data: #{err.message}")
      []
    end
  end
end