# frozen_string_literal: true

module ReleaseTools
  module PublicRelease
    # A release of Helm using the API.
    class HelmGitlabRelease
      include Release

      attr_reader :version, :client, :release_metadata, :gitlab_version

      # The Markdown file that maps Helm versions to GitLab versions.
      VERSION_MAPPING_FILE = 'doc/installation/version_mappings.md'

      # The name of the file containing the Chart information.
      CHART_FILE = 'Chart.yaml'

      # The name of the file containing the Chart's default values.
      VALUES_FILE = 'values.yaml'

      # The default branch of the charts project.
      DEFAULT_BRANCH = Project::HelmGitlab.default_branch

      # The file the chart uses to validate required stops are respected
      RUN_CHECK_TPL = 'templates/_runcheck.tpl'

      # The charts that we manage, and the source of their appVersion fields.
      #
      # The keys are the names of the directories the chart files reside in. The
      # directory "." is used for the top-level chart file.
      #
      # The following values are available:
      #
      # * `:gitlab_version`: appVersion will be populated with the GitLab Rails
      #    version.
      # * `:unmanaged`: appVersion is left as-is, and only the charts version is
      #    updated. This is useful for components where appVersion is managed
      #    separate from Release Tools.
      # * a `String`: the path/name of the VERSION file to read in the GitLab EE
      #   repository. The contents of this file will be used to populate the
      #   appVersion field.
      CHART_APP_VERSION_SOURCES = {
        '.' => :gitlab_version,
        'geo-logcursor' => :gitlab_version,
        'gitlab-grafana' => :gitlab_version,
        'migrations' => :gitlab_version,
        'operator' => :gitlab_version,
        'sidekiq' => :gitlab_version,
        'spamcheck' => :unmanaged,
        'toolbox' => :gitlab_version,
        'webservice' => :gitlab_version,
        'mailroom' => :gitlab_version,
        'gitlab-exporter' => :unmanaged,
        'gitaly' => Project::Gitaly.version_file,
        'gitlab-shell' => Project::GitlabShell.version_file,
        'kas' => Project::Kas.version_file,
        'praefect' => Project::Gitaly.version_file,
        'gitlab-pages' => Project::GitlabPages.version_file,

        # TODO: Remove when 14.5 is the earliest supported version (i.e., 14.8)
        # See https://gitlab.com/groups/gitlab-org/charts/-/epics/25
        'task-runner' => :gitlab_version
      }.freeze

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

      def execute
        if gitlab_version.rc?
          logger.info('Not releasing Helm for an RC', version: version, gitlab_version: gitlab_version)

          return
        end

        logger.info('Starting release of Helm', version: version, gitlab_version: gitlab_version)

        create_target_branch

        return if SharedStatus.dry_run?

        compile_changelog
        update_versions
        update_version_mapping
        update_last_upgrade_stop

        tag = create_tag

        add_release_metadata(tag)
        notify_slack(project, version)
      end

      def compile_changelog
        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
        stable_charts = all_chart_files
        default_charts = all_chart_files(DEFAULT_BRANCH)

        # We validate first so we either update all charts, or none at all. This
        # way we notice unrecognised charts as early as possible.
        verify_chart_files(stable_charts)
        verify_chart_files(default_charts)

        update_target_branch_versions(stable_charts)
        update_default_branch_versions(default_charts)
      end

      def update_target_branch_versions(charts)
        # Update Chart files and root values.yaml on target branch.
        update = update_chart_files(charts)
        update.merge!(update_global_version_value(target_branch))
        commit_version_files(target_branch, update, message: version_files_commit_message)
      end

      def update_default_branch_versions(charts)
        # On the default branch we need to only update the "version" field of
        # the Chart file.
        update = update_default_chart_files(charts)
        commit_version_files(DEFAULT_BRANCH, update, message: version_files_commit_message)
      end

      def verify_chart_files(files)
        files.each do |file|
          directory = File.dirname(file)
          name = File.basename(directory)

          next if CHART_APP_VERSION_SOURCES.key?(name)

          logger.error('Aborting Helm release due to unrecognised chart directory', directory: directory)

          raise <<~ERROR
            The chart located at #{directory} is not recognised by Release Tools.

            You will have to add its directory name (#{name}) to the
            CHART_APP_VERSION_SOURCES constant in this file, with the correct
            source for its appVersion field. For example, if the chart uses
            the same appVersion as the GitLab Rails version, add the following
            entry:

              '#{name}' => :gitlab_version
          ERROR
        end
      end

      def update_chart_files(files)
        to_update = {}

        files.each do |file|
          name = File.basename(File.dirname(file))
          yaml = read_file(file)
          hash = YAML.safe_load(yaml)
          source = CHART_APP_VERSION_SOURCES[name]

          app_version =
            case source
            when :gitlab_version
              # parseAppVersion won't add a `v` if `prepend` is true or false:
              # https://gitlab.com/gitlab-org/charts/gitlab/-/blob/9dc2e51bc7d6bbc775ebd2901aea22ca11f416e6/charts/gitlab/templates/_helpers.tpl#L27-32
              "v#{gitlab_version.to_normalized_version}"
            when :unmanaged
              # If a chart's appVersion is unmanaged, we still want to update
              # the version field. So instead of using something like `next`, we
              # return `nil` and handle this below.
              nil
            else
              read_ee_file(source)
            end

          hash['appVersion'] = app_version if app_version
          hash['version'] = version.to_s
          to_update[file] = YAML.dump(hash)
        end

        to_update
      end

      def update_default_chart_files(files)
        to_update = {}

        files.each do |file|
          yaml = read_file(file, branch: DEFAULT_BRANCH)
          hash = YAML.safe_load(yaml)

          # We don't want to overwrite the version with an older one. For
          # example:
          #
          # * On the default branch the `version` field 2.0.0
          # * We are running a patch release for 1.5.0
          #
          # In a case like this, the `version` field is left as-is, instead of
          # being (incorrectly) set to 1.5.0.
          next unless version > Version.new(hash['version'])

          hash['version'] = version.to_s
          to_update[file] = YAML.dump(hash)
        end

        to_update
      end

      def update_global_version_value(branch = target_branch)
        gitlab_version_regex = /(((?=#)# *|)gitlabVersion:[^\n]*)/
        global_values_file = VALUES_FILE
        values = read_file(global_values_file, branch: branch)

        # Replace global.gitlabVersion preserving comments.
        values.sub!(gitlab_version_regex, "gitlabVersion: \"#{gitlab_version.to_normalized_version}\"")

        { global_values_file => values }
      end

      def update_version_mapping
        update_version_mapping_on_branch(target_branch)

        # For the default branch we can't reuse the Markdown of the target
        # branch, as both branches may have different content in these files.
        # For example, when running a patch release for an older version the
        # target branch won't have entries for newer versions.
        update_version_mapping_on_branch(DEFAULT_BRANCH)
      end

      def update_version_mapping_on_branch(branch)
        mapping = updated_version_mapping(branch)

        commit_version_files(
          branch,
          { VERSION_MAPPING_FILE => mapping.to_s },
          message: "Update version mapping for #{version}"
        )
      end

      def updated_version_mapping(branch)
        @updated_version_mapping ||= {}
        @updated_version_mapping[branch] ||= begin
          markdown = read_file(VERSION_MAPPING_FILE, branch: branch)

          mapping = Helm::VersionMapping.parse(markdown)
          mapping.add(version, gitlab_version)

          mapping
        end
      end

      def create_tag
        logger.info('Creating tag', tag: tag_name, project: project_path)

        client.find_or_create_tag(
          project_path,
          tag_name,
          target_branch,
          message: "Version #{tag_name} - contains GitLab EE #{gitlab_version.to_normalized_version}"
        )
      end

      def add_release_metadata(tag)
        meta_version = version.to_normalized_version

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

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

      def update_last_upgrade_stop
        return if ReleaseTools::Feature.disabled?(:maintain_upgrade_stops)

        begin
          stop = UpgradeStop.new.last_required_stop
          chart_stop = required_stop_chart_version(stop)

          logger.info('Last required stop for version detected', last_required_stop: stop)
          logger.info('Last required stop for chart version calculated', last_required_stop: chart_stop)

          action = change_upgrade_stop_action!(stop, chart_stop)

          if action.nil?
            logger.info('Nothing to be done to apply upgrade stop to GitLab Helm chart',
                        version: version.to_minor, last_required_stop: stop)
            return
          end

          Retriable.with_context(:api) do
            client.create_commit(
              project_path,
              project.default_branch,
              "Update upgrade stop to #{stop}\n\n[ci skip]",
              [action]
            )
          end
        rescue StandardError => ex
          error_msg = <<~ERROR
            Something went wrong with the GitLab Helm chart upgrade stop.
            Disable the 'maintain_upgrade_stops' feature flag, and retry the job.
            Please notify Distribution about this error.
          ERROR

          logger.fatal(error_msg, error: ex)

          raise
        end
      end

      def change_upgrade_stop_action!(stop, chart_stop)
        gitlab_version_regex = /(?<=[\s]|^)MIN_VERSION=[^\n]*/
        chart_version_regex = /(?<=[\s]|^)CHART_MIN_VERSION=[^\n]*/

        content = read_file(RUN_CHECK_TPL, branch: project.default_branch)
        raise_regex_failure(gitlab_version_regex) unless gitlab_version_regex.match?(content)
        raise_regex_failure(chart_version_regex) unless chart_version_regex.match?(content)

        new_content = content
                        .sub(gitlab_version_regex, "MIN_VERSION=#{stop}")
                        .sub(chart_version_regex, "CHART_MIN_VERSION=#{chart_stop}")

        return nil if content == new_content

        {
          action: 'update',
          file_path: RUN_CHECK_TPL,
          content: new_content
        }
      end

      def raise_regex_failure(regexp)
        raise "The regexp '#{regexp}' does not match in #{RUN_CHECK_TPL}."
      end

      def raise_required_stop_is_not_mapped(version)
        raise "We can't make #{version} a required stop because it was not detected in the Helm Chart version mapping."
      end

      def required_stop_chart_version(gitlab_version_stop)
        mapping = updated_version_mapping(DEFAULT_BRANCH)

        # TODO: Currently the Helm chart does not support required stops based on patch versions
        # Since this method receives a Version#to_minor, but our version mapping
        # has only full versions, we temporarily recreate the full_version with patch `.0`,
        # just so we're able to detect the version in the version mapping.
        gitlab_version_stop_full_version = ReleaseTools::Version.new(gitlab_version_stop).to_patch
        chart_version = mapping.rows.find { |r| r.gitlab_version == gitlab_version_stop_full_version }&.helm_version
        raise raise_required_stop_is_not_mapped(gitlab_version_stop_full_version) unless chart_version

        chart_version.to_minor
      end

      def read_file(file, project: project_path, branch: target_branch)
        Retriable.with_context(:api) do
          client.file_contents(project, file, branch).strip
        end
      end

      def read_ee_file(file)
        project = Project::GitlabEe.canonical_or_security_path
        branch = gitlab_version.stable_branch(ee: true)

        read_file(file, project: project, branch: branch)
      end

      def all_chart_files(branch = target_branch)
        # We use a Set here so that if we retry the operation below, we don't
        # end up producing duplicate paths.
        paths = Set.new([CHART_FILE])

        Retriable.with_context(:api) do
          client
            .tree(project_path, ref: branch, path: 'charts/gitlab/charts', per_page: 100)
            .auto_paginate do |entry|
              paths << File.join(entry.path, CHART_FILE) if entry.type == 'tree'
            end
        end

        paths.to_a
      end

      def project
        Project::HelmGitlab
      end

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

        @commit || DEFAULT_BRANCH
      end

      def version_files_commit_message
        "Update versions for #{version}\n\n[ci skip]"
      end
    end
  end
end
