# frozen_string_literal: true

module ReleaseTools
  # rubocop: disable Metrics/ClassLength
  class GitlabClient
    include ::SemanticLogger::Loggable

    DEFAULT_GITLAB_API_ENDPOINT = 'https://gitlab.com/api/v4'
    DEFAULT_GITLAB_GRAPHQL_ENDPOINT = 'https://gitlab.com/api/graphql'

    # The regular expression to use for matching auto deploy branch names.
    AUTO_DEPLOY_BRANCH_REGEX = /\A\d+-\d+-auto-deploy-\d{8,}\z/

    # Some methods get delegated directly to the internal client
    class << self
      extend Forwardable

      def_delegator :client, :commit_refs
      def_delegator :client, :create_group_label
      def_delegator :client, :create_issue_link
      def_delegator :client, :create_merge_request_comment
      def_delegator :client, :create_variable
      def_delegator :client, :create_environment
      def_delegator :client, :delete_issue_link
      def_delegator :client, :edit_issue
      def_delegator :client, :environments
      def_delegator :client, :group_labels
      def_delegator :client, :group_milestones
      def_delegator :client, :group_projects
      def_delegator :client, :issue
      def_delegator :client, :issue_links
      def_delegator :client, :protect_branch
      def_delegator :client, :unprotect_branch
      def_delegator :client, :update_merge_request
      def_delegator :client, :update_variable
      def_delegator :client, :version
      def_delegator :client, :job
      def_delegator :client, :search_in_project
    end

    class MissingMilestone
      def id
        nil
      end
    end

    def self.current_user
      @current_user ||= client.user
    end

    def self.default_branch(project)
      client.project(project_path(project)).default_branch
    end

    def self.job_play(project, *)
      client.job_play(project_path(project), *)
    end

    def self.file_contents(project, *)
      client.file_contents(project_path(project), *)
    end

    def self.get_file(project, *)
      client.get_file(project_path(project), *)
    end

    def self.create_file(project, *)
      client.create_file(project_path(project), *)
    end

    def self.edit_file(project, *)
      client.edit_file(project_path(project), *)
    end

    def self.issues(project = Project::GitlabCe, options = {})
      client.issues(project_path(project), options)
    end

    def self.merge_base(project, refs)
      client.merge_base(project_path(project), refs)
    end

    def self.merge_requests(project = Project::GitlabCe, options = {})
      client.merge_requests(project_path(project), options)
    end

    def self.merge_request(project = Project::GitlabCe, iid:)
      client.merge_request(project_path(project), iid)
    end

    def self.merge_request_changes(project, iid:)
      client.merge_request_changes(project_path(project), iid)
    end

    def self.pipelines(project = Project::GitlabCe, options = {})
      client.pipelines(project_path(project), options)
    end

    def self.pipeline(project = Project::GitlabCe, pipeline_id)
      client.pipeline(project_path(project), pipeline_id)
    end

    def self.pipeline_jobs(project = Project::GitlabCe, pipeline_id = nil, options = {})
      client.pipeline_jobs(project_path(project), pipeline_id, options)
    end

    # rubocop:disable Metrics/ParameterLists
    def self.pipeline_job_by_name(project = Project::GitlabCe, pipeline_id = nil, job_name = nil, options = {})
      client.pipeline_jobs(project_path(project), pipeline_id, { per_page: 100 }.merge(options)).auto_paginate do |job|
        return job if job.name == job_name
      end
    end
    # rubocop:enable Metrics/ParameterLists

    def self.job_trace(project = Project::GitlabCe, job_id)
      client.job_trace(project_path(project), job_id)
    end

    def self.run_trigger(project = Project::GitlabCe, token, ref, options)
      client.run_trigger(project_path(project), token, ref, options)
    end

    def self.commit_merge_requests(project = Project::GitlabCe, sha:)
      client.commit_merge_requests(project_path(project), sha)
    end

    def self.compare(project = Project::GitlabCe, from:, to:)
      client.compare(project_path(project), from, to)
    end

    def self.commits(project = Project::GitlabCe, options = {})
      client.commits(project_path(project), options)
    end

    def self.commit(project = Project::GitlabCe, ref:)
      client.commit(project_path(project), ref)
    end

    def self.create_commit(project, *)
      client.create_commit(project_path(project), *)
    end

    def self.commit_diff(project, sha)
      client.commit_diff(project_path(project), sha)
    end

    def self.commit_status(project, sha, options = {})
      client.commit_status(project_path(project), sha, options)
    end

    def self.create_issue_note(project = Project::GitlabCe, issue:, body:)
      client.create_issue_note(project_path(project), issue.iid, body)
    end

    def self.issue_notes(project, issue_iid:)
      client.issue_notes(project_path(project), issue_iid)
    end

    def self.edit_issue_note(project, issue_iid:, note_id:, body:)
      client.edit_issue_note(project_path(project), issue_iid, note_id, body)
    end

    def self.close_issue(project = Project::GitlabCe, issue)
      client.close_issue(project_path(project), issue.iid)
    end

    def self.milestones(project = Project::GitlabCe, options = {})
      # FIXME (rspeicher): See https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/1032
      project_milestones = client.milestones(project_path(project), options).auto_paginate
      group_milestones = client.group_milestones('gitlab-org', options).auto_paginate

      project_milestones + group_milestones
    end

    def self.current_milestone
      ReleaseTools::ProductMilestone.current || MissingMilestone.new
    end

    def self.milestone(project = Project::GitlabCe, title:)
      return MissingMilestone.new if title.nil?

      # FIXME (rspeicher): See https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/1032
      milestones(project, include_parent_milestones: true)
        .detect { |m| m.title == title } || raise("Milestone #{title} not found for project #{project_path(project)}!")
    end

    # Create an issue in the CE project based on the provided issue
    #
    # issue - An object that responds to the following messages:
    #   :title       - Issue title String
    #   :description - Issue description String
    #   :labels      - Comma-separated String of label names
    #   :version     - Version object
    #   :assignees   - An Array of user IDs to use as the assignees.
    # project - An object that responds to :path
    #
    # The issue is always assigned to the authenticated user.
    #
    # Returns a Gitlab::ObjectifiedHash object
    def self.create_issue(issue, project = Project::GitlabCe)
      assignees =
        if issue.respond_to?(:assignees)
          issue.assignees
        else
          [current_user.id]
        end

      options = {
        description: issue.description,
        assignee_ids: assignees,
        labels: issue.labels,
        confidential: issue.confidential?
      }

      options[:due_date] = issue.due_date if issue.respond_to?(:due_date)
      options[:milestone_id] = milestone(project, title: issue.version.milestone_name).id if issue.try(:version)

      client.create_issue(
        project_path(project),
        issue.title,
        options
      )
    end

    # Update an issue in the CE project based on the provided issue
    #
    # issue - An object that responds to the following messages:
    #   :title       - Issue title String
    #   :description - Issue description String
    #   :labels      - Comma-separated String of label names
    # project - An object that responds to :path
    #
    # The issue is always assigned to the authenticated user.
    #
    # Returns a Gitlab::ObjectifiedHash object
    def self.update_issue(issue, project = Project::GitlabCe)
      options = {}
      options[:description] = issue.description if issue.try(:description)
      options[:labels] = issue.labels if issue.try(:labels)
      options[:confidential] = issue.confidential? if issue.try(:confidential?)
      options[:milestone_id] = milestone(project, title: issue.version.milestone_name).id if issue.try(:version)

      client.edit_issue(
        project_path(project),
        issue.iid,
        options
      )
    end

    # Link an issue as related to another
    #
    # issue - An Issuable object
    # target - An Issuable object
    def self.link_issues(issue, target)
      # NOTE: The GitLab gem doesn't currently support paths for the target_project_id
      path = client.url_encode(project_path(issue.project))

      # NOTE: `target_project_id` parameter doesn't support encoded values
      #   See https://gitlab.com/gitlab-org/gitlab/-/issues/9143
      client.post(
        "/projects/#{path}/issues/#{issue.iid}/links", query: {
          target_project_id: project_path(target.project),
          target_issue_iid: target.iid
        }
      )
    end

    # Delete an issue link two related issues
    #
    # issue - An Issuable object
    # link_id - an Issue link id (returned by issue_links)
    def self.delete_issue_link(issue, link_id)
      client.delete_issue_link(project_path(issue.project), issue.iid, link_id)
    end

    # Create a branch with the given name
    #
    # branch_name - Name of the new branch
    # ref - commit sha or existing branch ref
    # project - An object that responds to :path
    #
    # Returns a Gitlab::ObjectifiedHash object
    def self.create_branch(branch_name, ref, project = Project::GitlabCe)
      client.create_branch(project_path(project), branch_name, ref)
    end

    def self.delete_branch(branch_name, project = Project::GitlabCe)
      client.delete_branch(project_path(project), branch_name)
    end

    def self.branches(project = Project::GitlabCe, options = {})
      client.branches(project_path(project), options)
    end

    # Find a branch in a given project
    #
    # Returns a Gitlab::ObjectifiedHash object, or nil
    def self.find_branch(branch_name, project = Project::GitlabCe)
      client.branch(project_path(project), branch_name)
    rescue Gitlab::Error::NotFound
      nil
    end

    def self.tree(project, options = {})
      client.tree(project_path(project), options)
    end

    def self.find_or_create_branch(branch, ref, project = Project::GitlabCe)
      if (existing = find_branch(branch, project))
        existing
      else
        create_branch(branch, ref, project)
      end
    end

    def self.find_or_create_tag(project, tag, ref, message: nil)
      tag(project, tag: tag)
    rescue Gitlab::Error::NotFound
      create_tag(project, tag, ref, message)
    end

    # Create a merge request in the given project based on the provided merge request
    #
    # merge_request - An object that responds to the following messages:
    #   :title       - Merge request title String
    #   :description - Merge request description String
    #   :labels      - Comma-separated String of label names
    #   :source_branch - The source branch
    #   :target_branch - The target branch
    # project - An object that responds to :path
    #
    # The merge request is always assigned to the authenticated user.
    #
    # Returns a Gitlab::ObjectifiedHash object
    def self.create_merge_request(merge_request, project = Project::GitlabCe)
      milestone =
        if merge_request.milestone.nil?
          current_milestone
        else
          milestone(project, title: merge_request.milestone)
        end

      params = {
        description: merge_request.description,
        labels: merge_request.labels,
        source_branch: merge_request.source_branch,
        target_branch: merge_request.target_branch,
        milestone_id: milestone.id,
        remove_source_branch: true
      }

      params[:assignee_ids] =
        if merge_request.assignee_ids.present?
          merge_request.assignee_ids
        else
          [current_user.id]
        end

      client.create_merge_request(project_path(project), merge_request.title, params)
    end

    # Accept a merge request in the given project specified by the iid
    #
    # merge_request - An object that responds to the following message:
    #   :iid  - Internal id of merge request
    # project - An object that responds to :path
    #
    # Returns a Gitlab::ObjectifiedHash object
    def self.accept_merge_request(merge_request, project = Project::GitlabCe)
      params = {
        merge_when_pipeline_succeeds: true
      }
      client.accept_merge_request(project_path(project), merge_request.iid, params)
    end

    def self.cancel_merge_when_pipeline_succeeds(merge_request)
      client.post(
        "/projects/#{merge_request.project_id}/merge_requests/#{merge_request.iid}/cancel_merge_when_pipeline_succeeds"
      )
    end

    # Find an issue in the given project based on the provided issue
    #
    # issue - An object that responds to the following messages:
    #   :title  - Issue title String
    #   :labels - Comma-separated String of label names
    # project - An object that responds to :path
    #
    # Returns a Gitlab::ObjectifiedHash object, or nil
    def self.find_issue(issue, project = Project::GitlabCe)
      opts = {
        labels: issue.labels,
        search: issue.title
      }

      opts[:milestone] = issue.version&.milestone_name if issue.try(:version)

      issues(project, opts).detect { |i| i.title == issue.title }
    end

    def self.cherry_pick(project = Project::GitlabCe, ref:, target:, dry_run: false, message: nil)
      raise ArgumentError, "Invalid ref" if ref.blank?

      options = { dry_run: dry_run }

      # If `message` is explicitly passed as `nil`, the Grape API treats this as
      # an empty String, resulting in an empty commit message.
      options[:message] = message if message

      client.cherry_pick_commit(project_path(project), ref, target, options)
    end

    # Add the given merge request, for the given sha to the given project's Merge Train
    #
    # merge_request - An object that responds to the following messages:
    #   :iid  - Merge request IID
    #   :project_id - Project ID
    # sha - The SHA at which the MR should be added to the Merge Train
    #
    # Returns the response's status
    def self.add_to_merge_train(merge_request, sha, graphql: true, pipeline_succeed: false, **args)
      if graphql
        result = graphql_query(ReleaseTools::GitlabGraphqlQueries::ADD_TO_MERGE_TRAIN_QUERY, project: merge_request.project_id, mr: merge_request.iid.to_s, sha: sha, **args)
        raise "MR couldn't be added to Merge Train: #{result.errors.messages.to_h.to_json}" if result.errors.any?

        result.merge_request_accept.merge_request.auto_merge_enabled
      else
        client.post(
          "/projects/#{merge_request.project_id}/merge_trains/merge_requests/#{merge_request.iid}",
          body: {
            sha: sha,
            when_pipeline_succeeds: pipeline_succeed
          }
        )
      end
    end

    def self.graphql_query(parsed_query, **args)
      client = graphql_client(args.delete(:token))
      client.query(parsed_query, **args)
    end

    def self.client
      @client ||= Gitlab.client(
        endpoint: DEFAULT_GITLAB_API_ENDPOINT,
        private_token: ENV.fetch('RELEASE_BOT_PRODUCTION_TOKEN', nil),
        httparty: httparty_opts
      )
    end

    def self.graphql_client(token = nil)
      GraphqlAdapter.new(
        endpoint: DEFAULT_GITLAB_GRAPHQL_ENDPOINT,
        token: token || ENV.fetch('RELEASE_BOT_PRODUCTION_TOKEN', nil)
      )
    end

    def self.httparty_opts
      { logger: logger, log_level: :debug }
    end

    # Overridden by GitLabDevClient
    def self.project_path(project)
      if project.respond_to?(:path)
        project.path
      else
        project
      end
    end

    # Overridden by GitLabOpsClient
    def self.project_id(project)
      project.canonical_id
    end

    def self.tag(project, tag:)
      client.tag(project_path(project), tag)
    end

    def self.create_tag(project, *)
      client.create_tag(project_path(project), *)
    end

    def self.tags(project, *)
      client.tags(project_path(project), *)
    end

    # rubocop: disable Metrics/ParameterLists
    def self.create_deployment(project, environment, ref:, sha:, status:, tag: false)
      client.post(
        "/projects/#{client.url_encode(project_path(project))}/deployments",
        body: {
          ref: ref,
          sha: sha,
          tag: tag,
          status: status,
          environment: environment
        }
      )
    end
    # rubocop: enable Metrics/ParameterLists

    def self.update_deployment(project, id, attrs = {})
      client.put(
        "/projects/#{client.url_encode(project_path(project))}/deployments/#{id}",
        body: attrs
      )
    end

    # rubocop: disable Metrics/ParameterLists
    def self.update_or_create_deployment(project, environment, ref:, sha:, status:, tag: false)
      # First try to find a running deployment with this environment, ref, and sha
      existing = deployments(project, environment, status: 'running')
        .detect { |dep| dep.ref == ref && dep.sha == sha }

      if existing && existing.status == status
        existing

      elsif existing
        update_deployment(project, existing.id, status: status)

      else
        create_deployment(project, environment, ref: ref, sha: sha, status: status, tag: tag)
      end
    end
    # rubocop: enable Metrics/ParameterLists

    # rubocop: disable Metrics/ParameterLists
    def self.deployments(project, environment, status: nil, order_by: 'id', sort: 'desc', opts: {})
      options = {
        environment: environment,
        order_by: order_by,
        sort: sort
      }
      options[:status] = status if status

      options = opts.merge(options)

      client.deployments(project_path(project), options)
    end
    # rubocop: enable Metrics/ParameterLists

    def self.last_successful_deployment(project_path, environment)
      options = {
        environment: environment,
        status: 'success',
        order_by: 'id',
        sort: 'desc',
        per_page: 1
      }

      client.deployments(project_path, options).first
    end

    def self.deployed_merge_requests(project, deployment_id)
      project_path = client.url_encode(project_path(project))

      client.get(
        "/projects/#{project_path}/deployments/#{deployment_id}/merge_requests"
      )
    end

    def self.related_merge_requests(project, issue_iid)
      project_path = client.url_encode(project_path(project))

      client.get(
        "/projects/#{project_path}/issues/#{issue_iid}/related_merge_requests"
      )
    end

    def self.remote_mirrors(project)
      client.remote_mirrors(project_path(project))
    end

    def self.sync_remote_mirror(project, mirror_id)
      project_path = client.url_encode(project_path(project))

      client.post(
        "/projects/#{project_path}/remote_mirrors/#{mirror_id}/sync"
      )
    end

    def self.create_merge_request_pipeline(project_id, merge_request_id)
      client.post(
        "/projects/#{client.url_encode(project_id)}/merge_requests/#{merge_request_id}/pipelines"
      )
    end

    def self.compile_changelog(project, version, commit, branch, skip_ci: true)
      path = project_path(project)

      message = ["Update changelog for #{version}"]
      message << "\n[ci skip]" if skip_ci

      client.post(
        "/projects/#{client.url_encode(path)}/repository/changelog",
        body: {
          version: version,
          to: commit,
          branch: branch,
          message: message.join("\n")
        }
      )
    end

    def self.merge_request_commits(project, id)
      client.merge_request_commits(project_path(project), id)
    end

    def self.pipeline_bridges(project = Project::ReleaseTools, pipeline_id)
      client.pipeline_bridges(project_path(project), pipeline_id)
    end

    def self.create_pipeline(project, variables, branch: project.default_branch)
      client.create_pipeline(
        project_path(project),
        branch,
        variables
      )
    end

    def self.download_raw_job_artifact(project, job_id, artifact_path)
      client.get(
        "/projects/#{project_id(project)}/jobs/#{job_id}/artifacts/#{artifact_path}",
        format: nil,
        headers: { Accept: 'application/octet-stream' },
        parser: ::Gitlab::Request::Parser
      )
    end

    def self.cancel_job(project, job_id)
      client.job_cancel(project_path(project), job_id)
    end

    def self.pipeline_variables(project, pipeline_id)
      path = client.url_encode(project_path(project))
      client.get("/projects/#{path}/pipelines/#{pipeline_id}/variables")
    end

    def self.update_project_variable(project, variable_key, value)
      path = client.url_encode(project_path(project))
      client.put("/projects/#{path}/variables/#{variable_key}", body: { value: value })
    end

    def self.pipeline_schedules(project)
      client.pipeline_schedules(project_path(project))
    end

    def self.pipeline_schedule(project, pipeline_schedule_id)
      client.pipeline_schedule(project_path(project), pipeline_schedule_id)
    end

    def self.pipeline_schedule_take_ownership(project, pipeline_schedule_id)
      client.pipeline_schedule_take_ownership(project_path(project), pipeline_schedule_id)
    end

    def self.run_pipeline_schedule(project, pipeline_schedule_id)
      client.run_pipeline_schedule(project_path(project), pipeline_schedule_id)
    end

    def self.edit_pipeline_schedule(project, pipeline_schedule_id, options = {})
      client.edit_pipeline_schedule(project_path(project), pipeline_schedule_id, options)
    end

    def self.edit_pipeline_schedule_variable(project, pipeline_schedule_id, key, options = {})
      client.edit_pipeline_schedule_variable(project_path(project), pipeline_schedule_id, key, options)
    end

    def self.security_blog_merge_request(security: true)
      project = ReleaseTools::Project::WWWGitlabCom
      path = security ? project.security_path : project.path

      client.merge_requests(path, labels: 'patch release post', state: 'opened').first
    end

    def self.next_security_tracking_issue
      project = ReleaseTools::Project::GitlabEe
      label = 'upcoming security release'

      # project.path is required because the security tracking issue only exists on canonical
      client
        .issues(project.path, labels: label, state: 'opened')
        .first
    end

    def self.current_security_task_issue
      project = ReleaseTools::Project::Release::Tasks
      labels = ['security', 'Monthly Release']

      client
        .issues(project.path, labels: labels, state: 'opened')
        .first
    end

    def self.security_communication_issue
      project = ReleaseTools::Project::GlSecurity::SecurityCommunications::Communications
      label = 'Security Release Blog Alert::development'

      client
        .issues(project.path, labels: label, state: 'opened')
        .first
    end

    def self.project_access_tokens(project)
      path = client.url_encode(project_path(project))
      client.get("/projects/#{path}/access_tokens")
    end

    def self.rotate_project_access_token(project, token_id, expires_at: nil)
      path = client.url_encode(project_path(project))
      client.post("/projects/#{path}/access_tokens/#{token_id}/rotate",
                  body: { expires_at: expires_at }.compact)
    end

    def self.approve_merge_request(project, merge_request_iid)
      client
        .approve_merge_request(project_path(project), merge_request_iid)
    end

    def self.merge_request_approvals(project, merge_request_iid)
      client
        .merge_request_approvals(project_path(project), merge_request_iid)
    end

    def self.merge_request_pipelines(project, merge_request_iid)
      client.merge_request_pipelines(project_path(project), merge_request_iid)
    end

    def self.merge_request_diffs(project, merge_request_iid)
      project_path = client.url_encode(project_path(project))

      client.get("/projects/#{project_path}/merge_requests/#{merge_request_iid}/diffs")
    end

    def self.edit_resource_group(resource_group_key:, process_mode:, project: ENV.fetch('CI_PROJECT_PATH'))
      path = client.url_encode(project_path(project))
      client.put("/projects/#{path}/resource_groups/#{resource_group_key}", body: { process_mode: process_mode })
    end

    def self.issue_label_events(project, issue_iid)
      client.issue_label_events(project_path(project), issue_iid)
    end
  end
  # rubocop: enable Metrics/ClassLength
end
