# frozen_string_literal: true

module ReleaseTools
  module CherryPick
    # Performs automated cherry-picking into an auto-deploy branch
    #
    # Given a `project`, gathers merged MRs labeled `Pick into auto-deploy` and
    # attempts to cherry-pick their merge commit into the auto-deploy branch
    # specified by `target`.
    #
    # Each MR will receive a comment indicating the result of the attempt.
    class AutoDeployService
      # Raised when a target merge commit isn't available on Security
      MirrorError = Class.new(StandardError)

      include ::SemanticLogger::Loggable

      attr_reader :project
      attr_reader :target

      # project - ReleaseTools::Project object
      # target  - Auto-deploy branch name
      def initialize(project, target)
        @project = project
        @target = target

        @results = []
      end

      def execute
        return [] if no_pickable_mrs?

        canonical_pickable_mrs.auto_paginate do |merge_request|
          handle_merge_request(merge_request)
        end

        return @results unless Feature.enabled?(:cherry_pick_security_merge_requests)

        security_pickable_mrs.auto_paginate do |merge_request|
          handle_merge_request(merge_request)
        end

        @results
      end

      private

      def client
        ReleaseTools::GitlabClient
      end

      def notifier
        @notifier ||= AutoDeployNotifier.new(target, project)
      end

      def no_pickable_mrs?
        return canonical_pickable_mrs.none? unless Feature.enabled?(:cherry_pick_security_merge_requests)

        canonical_pickable_mrs.none? && security_pickable_mrs.none?
      end

      def handle_merge_request(merge_request)
        result =
          if allowed?(merge_request)
            assert_mirrored!(merge_request)

            if target_includes_merge_commit?(merge_request)
              Result.new(
                merge_request: merge_request,
                status: :completed,
                reason: "Auto deploy branch #{target} already contains MR merge commit"
              )

            elsif target_includes_cherry_picked_commit?(merge_request)
              Result.new(
                merge_request: merge_request,
                status: :not_required,
                reason: "Auto deploy branch #{target} already contains a cherry-picked commit"
              )

            else
              cherry_pick(merge_request)
            end

          else
            Result.new(merge_request: merge_request, status: :denied, reason: 'Missing ~severity::1 or ~severity::2 label')
          end

        record_result(result)
      end

      def canonical_pickable_mrs
        @canonical_pickable_mrs ||=
          Retriable.with_context(:api) do
            client.merge_requests(
              project,
              state: 'merged',
              labels: PickIntoLabel.for(:auto_deploy),
              order_by: 'created_at',
              sort: 'asc'
            )
          end
      end

      def security_pickable_mrs
        @security_pickable_mrs ||=
          Retriable.with_context(:api) do
            client.merge_requests(
              project.security_path,
              state: 'merged',
              labels: PickIntoLabel.for(:auto_deploy),
              order_by: 'created_at',
              sort: 'asc'
            )
          end
      end

      # Attempt to cherry-pick a merge request into the auto-deploy branch
      #
      # Returns a Result object
      def cherry_pick(merge_request)
        cherry_pick_result =
          unless SharedStatus.dry_run?
            client.cherry_pick(
              project.auto_deploy_path,
              ref: merge_request.merge_commit_sha,
              target: @target
            )
          end

        Result.new(merge_request: merge_request, status: :success, new_sha: cherry_pick_result&.short_id)
      rescue Gitlab::Error::Error => ex
        Result.new(merge_request: merge_request, status: :failure, reason: ex.message)
      end

      def allowed?(merge_request)
        merge_request.labels.include?('severity::1') ||
          merge_request.labels.include?('severity::2')
      end

      def target_includes_merge_commit?(merge_request)
        client.commit_refs(project.auto_deploy_path, merge_request.merge_commit_sha, type: 'branch').each_page do |refs|
          result = refs.detect { |ref| ref.name == target }

          logger.info(
            'Searching for merge commit in auto deploy branch',
            project: project.auto_deploy_path,
            sha: merge_request.merge_commit_sha,
            branch: target,
            present_in_branch: result
          )

          return true if result
        end

        false
      end

      def target_includes_cherry_picked_commit?(merge_request)
        sha = merge_request.merge_commit_sha

        results =
          client.search_in_project(
            project.auto_deploy_path,
            'commits',
            "cherry picked from commit #{sha}",
            target
          )
          .flatten
          .uniq(&:id)

        logger.info(
          'Searching for cherry-picked commits in auto deploy branch',
          project: project.auto_deploy_path,
          merge_commit: merge_request.merge_commit_sha,
          cherry_picked_commit: results.map(&:web_url),
          branch: target
        )

        results.present?
      end

      # Verify the merge commit has been mirrored to the Security repository
      def assert_mirrored!(merge_request)
        # Skip security mirror check for security MRs
        return if merge_request.web_url.include?('gitlab-org/security')

        Retriable.with_context(:api, tries: 10) do
          client.commit(project.security_path, ref: merge_request.merge_commit_sha)
        end
      rescue ::Gitlab::Error::NotFound
        # If the commit still isn't available after retries, something may be
        # wrong with our mirroring or merge-train, and we should fail loudly
        message = <<~MSG
          Unable to find #{merge_request.merge_commit_sha} on #{project.security_path}.
          Check that canonical is successfully mirroring to security.
          If it is not (for example during a patch release), manually trigger the `gitlab-org/gitlab@master -> gitlab-org/security/gitlab@master` merge-train pipeline schedule:
          https://ops.gitlab.net/gitlab-org/merge-train/-/pipeline_schedules
        MSG

        raise MirrorError, message
      end

      def record_result(result)
        @results << result
        track_result(result)
        log_result(result)
        notifier.comment(result)
      end

      def log_result(result)
        payload = {
          project: project,
          target: @target,
          merge_request: result.url
        }

        payload[:reason] = result.reason if result.reason

        if result.success?
          logger.info('Cherry-pick merged', payload)
        elsif result.denied?
          logger.info('Cherry-pick denied', payload)
        elsif result.failure?
          logger.warn('Cherry-pick failed', payload)
        elsif result.not_required?
          logger.info('Cherry-pick not required', payload)
        elsif result.completed?
          logger.info('Merge commit included', payload)
        else
          logger.warn('Unknown status for cherry-pick', payload)
        end
      end

      def track_result(result)
        label = if result.success?
                  'success'
                elsif result.failure? || result.denied?
                  'failed'
                else
                  # Don't count not_required and completed statuses since no action was taken
                  return
                end

        Metrics::Client.new.inc('auto_deploy_picks_total', labels: label)
      rescue StandardError => ex
        logger.error('pushing auto_deploy_picks_total metrics failed', error: ex)
      end
    end
  end
end
