# 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

    INCLUDE_TIMEZONE_FOR_CATEGORY = {
      database: false,
    }.freeze

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

    Approval = Struct.new(:category, :spin) do
      def self.from_approval_rule(rule, maintainer)
        category = rule["section"].to_sym
        spin = Spin.new(category, nil, maintainer, :reviewer)

        new(category, spin)
      end
    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.
    # @param timezone_experiment [Boolean] Whether to select reviewers based in timezone or not.
    #
    # @return [Array<Spin>]
    def spin(project = nil, categories = [nil], timezone_experiment: false)
      project = (project || helper.config.project_name).downcase
      categories = categories.map { |category| category&.downcase }
      categories.reject! { |category| integrations_reject_category?(category, project) }

      spins = categories.sort_by(&:to_s).map do |category|
        including_timezone = INCLUDE_TIMEZONE_FOR_CATEGORY.fetch(category, timezone_experiment)

        spin_for_category(project, category, timezone_experiment: including_timezone)
      end

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

      spins.each do |spin|
        including_timezone = INCLUDE_TIMEZONE_FOR_CATEGORY.fetch(spin.category, timezone_experiment)
        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&.any_capability?(project, spin.category)
            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, timezone_experiment: including_timezone).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, timezone_experiment: including_timezone).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, timezone_experiment: including_timezone).maintainer
          end
        when :product_intelligence
          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, timezone_experiment: including_timezone).maintainer
          end
        when :integrations_be, :integrations_fe
          spin.optional_role = :maintainer
        when :ux
          spin.optional_role = :maintainer
        end
      end

      spins
    end

    def required_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|
        rule["rule_type"] == "code_owner" &&
          rule["approvals_required"] > 0 &&
          Approval.from_approval_rule(rule, spin_for_approver(rule))
      end
    end

    private

    # 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 valid_person_with_timezone?(person)
      valid_person?(person) && HOURS_WHEN_PERSON_CAN_BE_PICKED.cover?(person.local_hour)
    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 integrations_reject_category?(category, project)
      # Reject integrations categories if the MR author has reviewing abilities for the category.
      team_mr_author&.integrations_be?(project, category, helper.mr_labels) ||
        team_mr_author&.integrations_fe?(project, category, helper.mr_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, helper.mr_labels) # rubocop:disable GitlabSecurity/PublicSend
      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, timezone_experiment: false)
      shuffled_people = people.shuffle(random: random)

      if timezone_experiment
        shuffled_people.find(&method(:valid_person_with_timezone?))
      else
        shuffled_people.find(&method(:valid_person?))
      end
    end

    # Spin a reviewer for a particular approval rule
    #
    # @param [Hash] rule of approval
    #
    # @return [Gitlab::Dangerfiles::Teammate]
    def spin_for_approver(rule)
      # This will filter out approvers who are not even reviewers who
      # don't show up in roulette data we're relying on.
      # That's why `filter_map` is used.
      approvers = rule["eligible_approvers"].filter_map do |approver|
        find_member(approver["username"])
      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, timezone_experiment: false)
      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, timezone_experiment: timezone_experiment)
      maintainer = spin_for_person(weighted_maintainers, timezone_experiment: timezone_experiment)

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

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

      if rsp.is_a?(Net::HTTPRedirection)
        raise "Redirection detected: #{rsp.header["location"]}"
      end

      unless rsp.is_a?(Net::HTTPOK)
        raise HTTPError, "Failed to read #{url}: #{rsp.code} #{rsp.message}"
      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
          raise "Failed to parse JSON response from #{ROULETTE_DATA_URL}"
        end
    end

    def find_member(username)
      company_members.find { |person| person.username == username }
    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) ||
          member.in_project?("gitlab") # Used for universal reviewer
      end
    rescue => err
      warn("Reviewer roulette failed to load team data: #{err.message}")
      []
    end
  end
end