# frozen_string_literal: true

module ReleaseTools
  module Security
    class MergeRequestValidator
      attr_reader :errors

      # A regular expression to use for extracting all pending tasks from the
      # merge request description. This pattern will match the following:
      #
      #     - [ ] Task name here
      #     * [ ] Task name here
      PENDING_TASKS = /(\*|-)\s*\[\s+\]/

      # A regular expression to use for extracting all tasks (pending or not)
      # from the merge request description. This pattern will match the
      # following:
      #
      #     - [ ] Task name here
      #     * [ ] Task name here
      #     - [x] Task name here
      #     * [x] Task name here
      ALL_TASKS = /(\*|-)\s*\[(\s+|[xX])\]/

      # A regular expression used to determine if the target branch of a merge
      # request is valid.
      ALLOWED_TARGET_BRANCHES = /^(master|main|\d+-\d+-stable(-ee)?)$/

      # The label that must be applied to all security merge requests.
      SECURITY_LABEL = 'security'

      # The namespace that security implementation issues must reside in.
      SECURITY_NAMESPACE = 'gitlab-org/security'

      # Projects with EE stable branches
      PROJECTS_WITH_EE_BRANCHES = [Project::GitlabEe.security_id].freeze

      # @param [Gitlab::ObjectifiedHash] merge_request
      # @param [ReleaseTools::Security::Client] client
      def initialize(merge_request, client)
        @merge_request = merge_request
        @client = client
        @errors = []
      end

      def validate
        validate_pipeline_status
        validate_merge_status
        validate_work_in_progress
        validate_pending_tasks
        validate_merge_request_template
        validate_target_branch
        validate_discussions
        validate_approvals
        validate_stable_branches
      end

      def validate_pipeline_status
        pipeline = Pipeline.latest_for_merge_request(@merge_request, @client)

        if pipeline.nil?
          error('Missing pipeline', <<~ERROR)
            No pipeline could be found for this merge request. Security merge
            requests must have a pipeline that passes before they can be merged.
          ERROR
        elsif pipeline.failed?
          error('Failing pipeline', <<~ERROR)
            The latest pipeline has one or more failing builds. Merge requests
            can not be merged unless the pipeline has passed.
          ERROR
        elsif pipeline.pending? || (pipeline.running? && !default_branch?)
          # This covers pipelines that are skipped or in another unknown state.
          #
          # Running pipelines against the default branch are allowed due to
          # https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/1231
          error('Pending pipeline', <<~ERROR)
            The latest pipeline did not pass. Merge requests should not be
            assigned to me until the pipeline is successful.
          ERROR
        end
      end

      def validate_merge_status
        if @merge_request.merge_status == 'cannot_be_merged'
          error('The merge request can not be merged', <<~ERROR)
            This merge request can currently not be merged, likely due to merge
            conflicts introduced by other (security) merge requests. Please
            rebase this merge request with the target branch and resolve any
            conflicts.
          ERROR
        end
      end

      def validate_work_in_progress
        if @merge_request.title.start_with?('WIP', 'Draft')
          error('The merge request is marked as a work in progress', <<~ERROR)
            Work in progress merge requests will not be merged, so make sure to
            resolve the WIP status before assigning this merge request back to
            me.
          ERROR
        end
      end

      def validate_pending_tasks
        if @merge_request.description.match?(PENDING_TASKS)
          error('There are one or more pending tasks', <<~ERROR)
            Before a security merge request can be merged, _all_ tasks must have
            been completed. If a task is not applicable, you can either mark it
            as completed or remove it.
          ERROR
        end
      end

      def validate_merge_request_template
        unless @merge_request.description.match?(ALL_TASKS)
          error('The Security Release template is not used', <<~ERROR)
            This merge request does not contain any tasks to complete,
            suggesting that the "Security Release" merge request template was
            not used. Security merge requests must use this merge request
            template.
          ERROR
        end
      end

      def validate_target_branch
        unless @merge_request.target_branch.match?(ALLOWED_TARGET_BRANCHES)
          error('The target branch is invalid', <<~ERROR)
            Security merge requests must target the default branch, or a stable
            branch such as 11-8-stable-ee.

            Security branches are no longer in use and should not be used as
            target branches.
          ERROR
        end
      end

      def validate_discussions
        # There might be many discussions and notes. Buffering all of those in
        # an Array might require quite a bit of memory, so instead we process
        # discussions as we retrieve them.
        Retriable.with_context(:api) do
          @client
            .merge_request_discussions(@merge_request.project_id, @merge_request.iid)
            .auto_paginate do |discussion|
              if discussion.notes.any? { |n| n['resolvable'] && !n['resolved'] }
                error('There are unresolved discussions', <<~ERROR)
                  This merge request has one or more unresolved discussions,
                  preventing it from being merged. Please mark all discussions as
                  resolved, then assign this merge request back to me.
                ERROR

                break
              end
            end
        end
      end

      # Validates if a merge request has the needed approvals
      #
      # * If it's targeting the default branch, we validate at least two
      #   approvals:
      #   - From a team member (ideally a maintainer, but since there's
      #   no easy way to verify that, we're simply checking any approval)
      #   - From a member of the AppSec team
      #
      # * If it's targeting a stable branch, we validate one approval.
      def validate_approvals
        approvals =
          Retriable.with_context(:api) do
            @client.merge_request_approvals(@merge_request.project_id, @merge_request.iid)
          end

        if default_branch?
          validate_approvals_on_default_branch(approvals)
        else
          validate_approval_on_stable_branch(approvals)
        end
      end

      def validate_stable_branches
        return if default_branch?

        next_versions = next_security_versions

        # When a stable branch is created for an upcoming monthly release, but
        # the release hasn't yet been completed and there's no
        # version.gitlab.com entry, `next_security_versions` won't return the
        # correct versions for the maintenance window. Fake it by taking the
        # highest version and adding the next minor (monthly) version.
        next_versions.prepend(Version.new(next_versions.first.next_minor))

        stable_branches =
          if PROJECTS_WITH_EE_BRANCHES.include?(@merge_request.project_id)
            next_security_versions.map do |version|
              version.stable_branch(ee: true)
            end
          else
            next_security_versions.map(&:stable_branch)
          end

        return if stable_branches.any? { |branch| @merge_request.target_branch.match?(/\A#{branch}(-ee)?\z/) }

        error('The merge request is targeting a stable branch out of the maintenance policy', <<~ERROR)
          This merge request should target any of the following stable
          branches: #{stable_branches.join(', ')}
        ERROR
      end

      private

      # rubocop:disable ReleaseTools/DefaultBranchLiteral
      def default_branch?
        %w[master main].include?(@merge_request.target_branch)
      end
      # rubocop:enable ReleaseTools/DefaultBranchLiteral

      def validate_approvals_on_default_branch(approvals)
        return if approvals.approvals_left.to_i.zero? && approvals.approval_rules_left.empty?

        error('The merge request requires two approvals', <<~ERROR)
          This merge request is missing an approval. Please ensure
          its approved by a maintainer, and by an AppSec team member.
        ERROR
      end

      def validate_approval_on_stable_branch(approvals)
        return unless approvals.approved_by.empty?

        error('The merge request must be approved', <<~ERROR)
          This merge request is missing an approval. Please ensure it has maintainer approval.
        ERROR
      end

      def next_security_versions
        @next_security_versions ||= ReleaseTools::Versions
          .next_versions
      end

      # @param [String] summary
      # @param [String] details
      def error(summary, details)
        @errors << <<~HTML
          <details>
          <summary><strong>#{summary}</strong></summary>
          <br />

          #{details}

          </details>
        HTML
      end
    end
  end
end
