# frozen_string_literal: true

module ReleaseTools
  module PublicRelease
    # A release of GitLab CE and EE using the API.
    class GitlabRelease
      include Release

      attr_reader :version, :client, :release_metadata

      # The sleep intervals to use when waiting for the EE to CE sync.
      #
      # At 15 intervals of 60 seconds, we wait at most 15 minutes.
      #
      # As of June 2020, almost all sync pipelines finish in less than 6
      # minutes. As such, 15 minutes should be more than enough.
      WAIT_SYNC_INTERVALS = Array.new(15, 60)

      # The file to check when waiting for the EE to CE sync.
      SYNC_CHECK_FILE = 'CHANGELOG.md'

      # The name of the default branch for CE and EE.
      DEFAULT_BRANCH = Project::GitlabEe.default_branch

      # The name of the version file storing the CE/EE version.
      VERSION_FILE = 'VERSION'

      # Error raised when a EE to CE sync pipeline is still running.
      PipelineTooSlow = Class.new(StandardError) do
        def message
          'The EE to CE pipeline did not finish in a timely manner'
        end
      end

      # Error raised when a EE to CE sync pipeline failed.
      PipelineFailed = Class.new(StandardError) do
        def message
          'The EE to CE pipeline failed'
        end
      end

      def initialize(
        version,
        client: GitlabClient,
        release_metadata: ReleaseMetadata.new,
        commit: nil
      )
        @version = version.to_ee
        @client = client
        @release_metadata = release_metadata
        @commit = commit
      end

      def execute
        logger.info(
          'Starting release of GitLab CE and EE',
          ee_version: version,
          ce_version: version.to_ce
        )

        create_ce_target_branch
        create_ee_target_branch

        return if SharedStatus.dry_run?

        update_managed_component_versions
        compile_changelogs
        update_versions

        # Now that EE has been updated, we need to wait for the Merge Train to
        # sync all changes from EE to CE. Not waiting for this could result in
        # us tagging outdated commits/code.
        wait_for_ee_to_ce_sync
        create_ce_commit_to_run_ci

        ce_tag = create_ce_tag
        ee_tag = create_ee_tag

        update_monthly_tag_metrics

        start_new_minor_release
        add_release_data_for_tags(ce_tag, ee_tag)

        notify_slack(Project::GitlabCe, version.to_ce)
        notify_slack(Project::GitlabEe, version)
      end

      def create_ce_target_branch
        # CE stable branches must be created before we can sync the EE changes
        # to them. Branching off from master would include commits we won't
        # actually release, which is confusing. Thus, we create the initial
        # branch from the last stable branch.
        source = Version.new(version.previous_minor).stable_branch

        logger.info(
          'Creating CE target branch',
          project: ce_project_path,
          source: source,
          branch: ce_target_branch
        )

        return if SharedStatus.dry_run?

        client.find_or_create_branch(ce_target_branch, source, ce_project_path)
      end

      def create_ee_target_branch
        source = source_for_target_branch

        logger.info(
          'Creating EE target branch',
          project: project_path,
          source: source,
          branch: target_branch
        )

        unless SharedStatus.dry_run?
          client.find_or_create_branch(
            target_branch,
            source,
            project_path
          )
        end

        notify_stable_branch_creation
      end

      def update_monthly_tag_metrics
        return unless version.rc?
        return unless matching_monthly_version?

        ReleaseTools::Metrics::MonthlyReleaseStatus.new(status: :tagged_rc).execute
      end

      def update_managed_component_versions
        # When releasing an RC, there is no stable branch yet for the component
        # (as Gitaly/Pages RCs don't create stable branches). This means the
        # <project>_SERVER_VERSION should stay as-is.
        return if version.rc?

        component_versions = managed_versioning_projects.each_with_object({}) do |project_model, versions|
          next unless project_model.respond_to?(:version_file)

          versions[project_model.version_file] = managed_component_version(project_model)
        end

        # We skip CI tests here as we'll be comitting a few more files, making
        # pipelines being created at this point redundant.
        commit_version_files(
          target_branch,
          component_versions,
          message: "Update managed components version to #{version.to_ce}",
          skip_ci: true
        )
      end

      def managed_versioning_projects
        return ManagedVersioning::PROJECTS unless Feature.enabled?(:release_the_kas)

        [ReleaseTools::Project::Kas].concat(ManagedVersioning::PROJECTS)
      end

      # Creates a Merge Train pipeline to sync EE to CE and waits for it to
      # complete.
      #
      # We can't rely on the state of one or more files to determine if CE is in
      # sync with EE, as these files (e.g. a changelog) may not change when
      # operations are retried. This would result in this code thinking CE and
      # EE are in sync, even when this may not be the case (e.g. not all code
      # has been synced yet).
      #
      # To handle this, we create a Merge Train pipeline ourselves and wait for
      # it to finish. In some cases code may have already been synced, but this
      # approach is the only way we can guarantee that CE and EE are in sync.
      def wait_for_ee_to_ce_sync
        return if Feature.enabled?(:skip_foss_merge_train)

        logger.info(
          'Creating pipeline to sync EE to CE',
          project: project_path,
          target_branch: target_branch,
          ce_project_path: ce_project_path,
          ce_target_branch: ce_target_branch
        )

        initial_pipeline = GitlabOpsClient.create_pipeline(
          Project::MergeTrain,
          {
            MERGE_FOSS: '1',
            SOURCE_PROJECT: project_path,
            SOURCE_BRANCH: target_branch,
            TARGET_PROJECT: ce_project_path,
            TARGET_BRANCH: ce_target_branch
          }
        )

        url = initial_pipeline.web_url

        logger.info('Created pipeline to sync EE to CE', pipeline: url)

        Retriable.retriable(intervals: WAIT_SYNC_INTERVALS, on: PipelineTooSlow) do
          pipeline = GitlabOpsClient
            .pipeline(Project::MergeTrain, initial_pipeline.id)

          case pipeline.status
          when 'success'
            logger.info('The EE to CE sync finished', pipeline: url)
            return
          when 'running', 'pending', 'created', 'waiting_for_resource'
            logger.info('The EE to CE sync is still running', pipeline: url)
            raise PipelineTooSlow
          else
            logger.error(
              'The EE to CE sync did not succeed',
              pipeline: url,
              status: pipeline.status
            )

            raise PipelineFailed
          end
        end
      end

      def create_ce_commit_to_run_ci
        # When bumping versions on CE, we skip pipelines for the version
        # commits. We then sync the EE changes to CE.
        #
        # If there are no changes to sync to CE and we tag the latest commit,
        # that tag will point to a commit with `ci skip`; preventing pipelines
        # from running.
        #
        # To handle this, we check if the latest commit skips pipelines. If so,
        # we create a dummy commit.
        #
        # This should only happen rarely, as most of the time the Merge Train
        # _will_ have changes to sync.
        path = ce_project_path
        ref = ce_target_branch
        commit = client.commit(path, ref: ref)

        return unless commit.message.include?('[ci skip]')

        message = <<~MSG.strip
          Ensure pipelines run for #{version.to_ce}

          The last commit synced to this repository includes the "ci skip" tag,
          preventing tag pipelines from running. To ensure these pipelines do run,
          we create this commit. This normally is only necessary if a Merge Train
          sync didn't introduce any new changes.

          For more information, refer to issue
          https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/1355.
        MSG

        # When committing changes using the API, at least one action must be
        # present. However, it's fine if that action doesn't actually change
        # anything. Thus, to create a commit we update the VERSION file to what
        # it's already set to, creating an empty commit in the process that
        # allows tags to trigger a pipeline.
        client.create_commit(
          path,
          ref,
          message,
          [
            {
              action: 'update',
              file_path: 'VERSION',
              content: version.to_ce.to_s
            }
          ]
        )
      end

      def compile_changelogs
        return if version.rc?

        logger.info('Compiling changelog', project: project_path)

        ChangelogCompiler
          .new(project_path, client: client)
          .compile(version, branch: target_branch)
      end

      def update_versions
        logger.info(
          'Updating EE version file',
          project: project_path,
          version: version,
          branch: target_branch
        )

        # On EE this will be the last commit on the stable branch. To ensure
        # tagging pipelines run, we _don't_ skip any CI pipelines here. We
        # explicily set `skip_ci: false` so that it doesn't look like we just
        # forgot to set it at all.
        #
        # For CE it's fine to skip, because we will create a commit to sync EE
        # changes to CE.
        commit_version_files(
          target_branch,
          { VERSION_FILE => version.to_s },
          skip_ci: false,
          skip_merge_train: true
        )

        logger.info(
          'Updating CE version file',
          project: ce_project_path,
          version: version,
          branch: ce_target_branch
        )

        commit_version_files(
          ce_target_branch,
          { VERSION_FILE => version.to_ce.to_s },
          project: ce_project_path,
          skip_ci: true
        )
      end

      def start_new_minor_release
        return unless version.monthly?

        ee_pre = version.to_upcoming_pre_release
        ce_pre = version.to_ce.to_upcoming_pre_release

        logger.info(
          'Starting a new EE pre-release',
          project: project_path,
          version: ee_pre,
          branch: DEFAULT_BRANCH
        )

        commit_version_files(
          DEFAULT_BRANCH,
          { VERSION_FILE => ee_pre },
          skip_ci: true
        )

        logger.info(
          'Starting a new CE pre-release',
          project: ce_project_path,
          version: ce_pre,
          branch: DEFAULT_BRANCH
        )

        commit_version_files(
          DEFAULT_BRANCH,
          { VERSION_FILE => ce_pre },
          project: ce_project_path,
          skip_ci: true
        )
      end

      def create_ee_tag
        tag = version.tag

        logger.info('Creating EE tag', tag: tag, project: project_path)

        client.find_or_create_tag(
          project_path,
          tag,
          target_branch,
          message: "Version #{tag}"
        )
      end

      def create_ce_tag
        tag = version.to_ce.tag

        logger.info('Creating CE tag', tag: tag, project: ce_project_path)

        client.tag(ce_project_path, tag: tag)
      rescue Gitlab::Error::NotFound
        client_tag = client.create_tag(
          ce_project_path,
          tag,
          ce_target_branch,
          "Version #{tag}"
        )
        ReleaseTools::Metrics::TrackReleaseTagged.new(version).execute
        client_tag
      end

      def add_release_data_for_tags(ce_tag, ee_tag)
        meta_version = version.to_normalized_version

        logger.info(
          'Recording release data',
          project: project_path,
          version: meta_version,
          ce_tag: ce_tag.name,
          ee_tag: ee_tag.name
        )

        release_metadata.add_release(
          name: 'gitlab-ee',
          version: meta_version,
          sha: ee_tag.commit.id,
          ref: ee_tag.name,
          tag: true
        )

        release_metadata.add_release(
          name: 'gitlab-ce',
          version: meta_version,
          sha: ce_tag.commit.id,
          ref: ce_tag.name,
          tag: true
        )
      end

      def source_for_target_branch
        if @commit
          logger.info('Using specific commit', project: project_path, commit: @commit)
        end

        @commit || super
      end

      def project
        Project::GitlabEe
      end

      def ce_target_branch
        version.to_ce.stable_branch
      end

      def ce_project_path
        Project::GitlabCe.canonical_or_security_path
      end

      private

      def managed_component_version(project_model)
        # Feature flag to be removed with https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/2735
        # if no warnings are logged
        if Feature.enabled?(:fetch_managed_component_version) && project_model != ReleaseTools::Project::Kas
          project = project_model.canonical_or_security_path

          # Managed component branches don't use the -ee suffix.
          branch = version.to_ce.stable_branch

          commit_version = Retriable.with_context(:api) do
            client.file_contents(project, 'VERSION', branch).strip
          end

          if commit_version != version.to_ce
            logger.warn(
              'Managed component version does not match version being released',
              project: project_model.path,
              component_version: commit_version,
              release_version: version.to_ce
            )
          end

          commit_version
        else
          version.to_ce
        end
      end

      def matching_monthly_version?
        current_monthly_minor_version = ReleaseTools::Version.new(ReleaseTools::GitlabReleasesGemClient.version_for_date(Date.today)).to_minor
        tag_minor_version = version.to_minor

        current_monthly_minor_version == tag_minor_version
      end
    end
  end
end
