# frozen_string_literal: true

module ReleaseTools
  module PublicRelease
    # rubocop: disable Metrics/ModuleLength
    # An interface for API release classes.
    #
    # This interface/module provides a variety of default methods, and methods
    # that an including class must implement. By implementing these methods,
    # release classes can reuse behaviour (such as creating a tag) that is the
    # same for all release classes, while still being able to override behaviour
    # where necessary.
    #
    # This module must not dictate the implementation of releases, beyond
    # requiring some methods that have to be implemented to use the methods
    # provided by this module.
    #
    # For example, the `execute` method in this module is deliberately left
    # empty (besides raising an error). If this method were to instead call
    # other methods (e.g. a method for compiling a changelog), releases classes
    # would have to start overwriting methods the moment they want to change
    # this behaviour. Such tight coupling can make for code that is hard to
    # debug, understand, test, and extend.
    #
    # In other words, all this module should do is:
    #
    # 1. Provide a public interface (but _not_ an implementation) for release
    #    classes.
    # 2. Provide some default methods that most release classes will need. As a
    #    rule of thumb, don't add a method here unless it's used by three or
    #    more release classes.
    module Release
      PRODUCTION_ENVIRONMENT = 'gprd'
      STAGING_ENVIRONMENT = 'gstg'

      DifferentCommitsError = Class.new(StandardError)

      def self.included(into)
        into.include(::SemanticLogger::Loggable)
      end

      def release_metadata
        raise NotImplementedError
      end

      def version
        raise NotImplementedError
      end

      def project
        raise NotImplementedError
      end

      def client
        raise NotImplementedError
      end

      def execute
        raise NotImplementedError
      end

      def project_path
        project.canonical_or_security_path
      end

      def tag_name
        version.tag
      end

      def source_for_target_branch
        last_production_commit
      end

      def target_branch
        version.stable_branch
      end

      def create_target_branch
        source = source_for_target_branch

        logger.info(
          'Creating 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 notify_slack(project, version)
        path = project.canonical_or_security_path

        tag = omnibus?(project) ? version : version.tag
        logger.info('Notifying Slack', project: path, version: version)
        Slack::TagNotification.release(project, tag)
      end

      def omnibus?(project)
        project == Project::OmnibusGitlab
      end

      # Fetches the current production commit for a GitLab component, it uses two
      # sources for this:
      #
      # - Deployment tracker through `last_production_commit_deployments`, and,
      # - release/metadata through `last_production_commit_metadata`.
      #
      # The release/metadata is being rolled out on
      # https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/2607, and until
      # it's safely tested this method returns the sha from the deployment tracker.
      #
      # Changes under the release/metadata implementation are under a feature flag.
      def last_production_commit
        sha_deployments = last_production_commit_deployments

        return sha_deployments unless Feature.enabled?(:release_metadata_as_source)

        sha_metadata = last_production_commit_metadata

        if sha_metadata == sha_deployments
          logger.info('Commits are the same', sha_metadata: sha_metadata, sha_deployments: sha_deployments)
        else
          logger.error('Commits are not the same', sha_metadata: sha_metadata, sha_deployments: sha_deployments)

          raise DifferentCommitsError
        end

        sha_deployments
      rescue DifferentCommitsError
        sha_deployments
      end

      def last_production_commit_deployments
        env = PRODUCTION_ENVIRONMENT

        # Branches are to always be created on the public projects, using the
        # deployments tracked in those projects. To ensure this we use the
        # public project path. This way even during a patch release we fetch
        # this data from the public project.
        sha = client
          .deployments(project.path, env, status: 'success').first&.sha

        unless sha
          raise "The project #{project_path} has no deployments for #{env}. " \
                'If this project does not use GitLab deployments, re-define the ' \
                'source_for_target_branch instance method to return the source ' \
                'branch name'
        end

        logger.info('Last production commit via project deployments', project: project.path, sha: sha)

        sha
      end

      def last_production_commit_metadata
        sha = current_gprd_product_version[project.metadata_project_name].sha

        logger.info('Last production commit via metadata', project: project.path, sha: sha)

        sha
      end

      # Update one or more VERSION files.
      #
      # The `versions` argument is a `Hash` that maps version file names (e.g.
      # `VERSION` or `GITALY_SERVER_VERSION`) to their new version identifiers.
      # For example:
      #
      #     { 'VERSION' => '1.2.3', 'GITALY_SERVER_VERSION' => '2.3.4' }
      #
      # By default this method will trigger pipelines for the commit it creates,
      # as this is the safest default. To disable this, set `skip_ci` to `true`.
      # rubocop: disable Metrics/ParameterLists
      def commit_version_files(
        branch,
        versions,
        message: 'Update VERSION files',
        project: project_path,
        skip_ci: false,
        skip_merge_train: false
      )
        actions = versions.each_with_object([]) do |(file, version), memo|
          action =
            begin
              # We strip newlines from both the old file contents and the new
              # contents. This ensures that if the old file contains a trailing
              # newline, and the new content does as well, we don't create empty
              # commits. This _does_ happen if we compare the existing stripped
              # content with the new content as-is, provided said new content
              # contains a trailing newline.
              if client.file_contents(project, file, branch).strip != version.strip
                'update'
              end
            rescue Gitlab::Error::NotFound
              'create'
            end

          next unless action

          logger.info(
            "Setting version file #{file} to #{version.tr("\n", ' ')}",
            project: project,
            action: action,
            file: file,
            version: version,
            branch: branch
          )

          memo << { action: action, file_path: file, content: version }
        end

        return if actions.empty?

        tags = []

        tags << '[ci skip]' if skip_ci
        tags << '[merge-train skip]' if skip_merge_train

        message += "\n\n#{tags.join("\n")}" if tags.any?

        client.create_commit(project, branch, message, actions)
      end
      # rubocop: enable Metrics/ParameterLists

      # Returns the GitLab components metadata based on the last successful
      # production deployment tracked in the release/metadata project in OPS. The
      # metadata is retrieved via ProductVersion
      def current_gprd_product_version
        ops_client = GitlabOpsClient
        release_metadata = Project::Release::Metadata

        last_deployment = ops_client
          .deployments(
            release_metadata.path,
            PRODUCTION_ENVIRONMENT,
            status: 'success'
          )
          .first

        ProductVersion.from_metadata_sha(last_deployment.sha)
      end

      def notify_stable_branch_creation
        logger.info('Checking if project and version are eligible for stable branch creation notification', project: project, version: version)

        # We only want to notify projects under Managed Versioning.
        return unless ReleaseTools::ManagedVersioning::PROJECTS.include?(project)

        # Stable branches are created when the RC for the monthly release is created.
        return unless version.rc?

        stable_branch_name = ReleaseTools::ReleaseManagers::Schedule
                               .new
                               .active_version
                               .stable_branch(ee: project.ee_branch?)

        logger.info('Checking if target branch is the stable branch for current monthly release', stable_branch_name: stable_branch_name, target_branch: target_branch)

        return unless target_branch == stable_branch_name

        # Make sure the new stable branch exists
        return unless GitlabClient.find_branch(stable_branch_name, project_path)

        Security::NotifyStableBranchCreation
          .new(Security::IssueCrawler.new, project, stable_branch_name)
          .execute
      end
    end
    # rubocop: enable Metrics/ModuleLength
  end
end
