# frozen_string_literal: true

class GitMergeWorker < Matterhorn::Worker
  sidekiq_options retry: false

  def perform(project, pull_request_number, *meta)
    @project = project
    @pull_request_number = pull_request_number
    @admin = meta[0]['admin']
    @comment_id = meta[0]['comment_id']
    @start_time = Time.now

    begin
      sleep 5 # give it a second to properly register the active job
      live_update update_queue_position: true

      # Early failure cases
      # 1. queued job does not match configured project
      # 2. PR is already merged or closed
      # 3. PR is marked as unable to merge in GitHub (most likely merge conflicts)
      return merge_fail "Project mismatch: #{project} (job) != #{PROJECT} (env)" unless PROJECT == project
      return if pull_request_merged?(false) || pull_request_closed?(false) # silently kill the job if PR is already merged or closed
      return merge_fail "Merge conflicts on PR ##{pull_request.number} must be resolved before attempting to merge.", retry_message: true unless able_to_merge?

      # Initial checks pass, so let's do this!
      update_branch

      sleep 5 # GitHub can be slow updating its data... wait a sec before verifying required checks
      comment "Waiting for required checks on PR ##{pull_request.number}..."
      return unless required_checks_successful?

      # if we hit this point, should be good to merge
      comment "Merging #{pull_request.head.ref} into #{pull_request.base.ref} for PR ##{pull_request.number}..."
      return if clear_pull_request && (pull_request_merged? || pull_request_closed?) # did we change our mind and close the PR?
      perform_merge
      sleep 10 # post-merge race condition exists that could show a merge conflict in the next job
      merge_success "Finished merging PR ##{pull_request.number}"
      delete_branch
      @status = 'success'
    rescue StandardError => e
      comment "An error occurred: #{e.message}", status: 'Error', log_level: :error
      raise e
    ensure
      queue = Matterhorn::Models::Queue.new(@project)
      queue.jobs[jid].status = @status == 'success' ? 'success' : 'failed'
      queue.jobs[jid].completed_at = Time.now.to_f
      redis.lpush(queue.past_jobs_key, queue.jobs[jid].to_json)
      redis.rpop(queue.past_jobs_key) if redis.llen(queue.past_jobs_key) > 20

      live_update override_active: true, update_queue_position: true
      send_status_webhook queue: queue, job: queue.jobs[jid]
    end
  end

  def live_update(override_active: false, update_queue_position: false)
    sleep 1 # prevent flooding of live updates on the worker
    queue = Matterhorn::Models::Queue.new(@project, override_active: override_active)
    Matterhorn::FayeMessage.send(channel: queue.channel, data: Matterhorn::API::Response.new(queue: queue, jobs: queue.jobs))

    if update_queue_position
      queue.queue.each_with_index do |jid, i|
        next if i.zero?
        job = queue.jobs[jid]
        next unless (comment_id = job.item['args']&.send('[]', 2)&.send('[]', 'comment_id'))

        if i == 1
          message = '**Merge job queued** - you are next'
        else
          message = "**Merge job queued** - there are #{i} jobs ahead of you in the queue"
        end

        team = project_settings[:savant_team]
        message += "\nFollow your queue progression at https://savant.xarth.tv/#{team}/merge-queue" unless team.nil?

        sleep 0.25 # prevent flooding the github API
        github.update_comment(@project, comment_id, message)
      end
    end
  rescue StandardError => e
    log.error "#{e.message}: #{e.backtrace}"
  end

  # Sends a job status update to Orko's (and maybe others in the future) webhook.
  # @param queue: [Matterhorn::Models::Queue]
  # @param job: [Matterhorn::Models::Job]
  def send_status_webhook(queue:, job:)
    response = Matterhorn::API::Response.new(queue: queue, job: job)

    payload = response.to_json
    timestamp = response.timestamp.to_i

    signature_base = "v0:#{timestamp}:#{payload}".encode('UTF-8')
    signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), MATTERHORN_SECRET_KEY, signature_base)

    headers = {
      content_type: :json,
      accept: :json,
      x_matterhorn_request_timestamp: timestamp,
      x_matterhorn_signature: "v0=#{signature}"
    }

    url = URI.join(ORKO_DOMAIN, 'merge-queue/pr-status')
    RestClient.post(url.to_s, payload, headers)
  rescue StandardError => e
    log.error "#{e.message}: #{e.backtrace}"
  end

  def redis
    @redis ||= Redis.new(url: REDIS_URL)
  end

  def github
    @github ||= Octokit::Client.new(access_token: GITHUB_ACCESS_TOKEN)
  end

  def project_settings
    @project_settings ||= begin
      raise "Invalid project #{@project} specified to #{self.class}" unless PROJECTS[:projects].key?(@project)
      PROJECTS[:projects][@project]
    end
  end

  def pull_request
    @pull_request ||= github.pull(@project, @pull_request_number)
  end

  def comment(message, status: 'In Progress', log_level: :info)
    full_message = "### Merge Status: #{status}\n#{message}"
    full_message = "*SIMULATION MODE*\n#{full_message}" if simulation_only?

    unless silent_mode?
      if @comment_id
        github.update_comment(@project, @comment_id, full_message)
      else
        @comment_id = github.add_comment(@project, pull_request.number, full_message).id
      end
    end
    log.send(log_level, message)
  end

  def merge_success(message)
    comment message, status: 'Success'
  end

  def merge_fail(message, retry_message: false)
    message += ' Please resolve any issues and submit the `+merge` command to try again.' if retry_message
    comment message, status: 'Failed', log_level: :error
  end

  def able_to_merge?
    while pull_request.mergeable.nil?
      log.warn "GitHub still calculating mergeability on PR ##{@pull_request_number}. Checking again in 10 seconds."
      sleep 10
      clear_pull_request
    end
    pull_request.mergeable
  end

  def update_branch
    if simulation_only?
      sleep 5 # simulate delay for performing the base => head merge
    else
      github.merge(@project, pull_request.head.ref, pull_request.base.ref)
    end
  end

  def clear_pull_request
    @pull_request = nil
    true
  end

  def pull_request_merged?(do_comment = true)
    if pull_request.merged
      message = "PR ##{pull_request.number} is already merged."
      if do_comment
        merge_success message
      else
        log.info message
      end
      return true
    end
    false
  end

  def pull_request_closed?(do_comment = true)
    if pull_request.state == 'closed'
      message = "PR ##{pull_request.number} is closed."
      if do_comment
        merge_fail message
      else
        log.error message
      end
      return true
    end
    false
  end

  def required_checks_successful?
    start = Time.now
    verified_success = false
    loop do
      sleep 10
      live_update

      # The status API on it's own isn't trustworthy.
      status = github.status(@project, pull_request.head.ref)
      success = verify_required_checks(status)

      log.info "API Status: #{status.state}; Required State: #{success}; Verified: #{verified_success}; Statuses: #{status.statuses.count}; Required: #{required_checks.contexts.count}" if success || verified_success
      return true if verified_success && success
      verified_success = success

      # we can bypass lingering checks if we've already failed at least one requirement
      # explicitly check for false, as `nil` means we're still pending required checks
      if success == false
        log.error "API Status: #{status.state}; Required State: #{success}; Verified: #{verified_success}; Statuses: #{status.statuses.count}; Required: #{required_checks.contexts.count}"
        log.error "Required check contexts: #{required_checks.contexts.inspect}"
        log.error "Status contexts: #{status.statuses.collect { |st| [st.context, st.state] }}"
        merge_fail "Required checks on PR ##{pull_request.number} failed.", retry_message: true
        return false
      elsif Time.now - start > required_check_timeout
        merge_fail "Required checks on PR ##{pull_request.number} timed out after #{(Time.now - start).to_i} seconds.", retry_message: true
        return false
      end
    end
  end

  def required_checks
    # branch protection API is still in "Preview", so is not "production ready"
    # but we need this feature, so ignore the warning by passing in the correct Accept header
    @required_checks ||= github.branch_protection(@project, pull_request.base.ref, accept: Octokit::Preview::PREVIEW_TYPES[:branch_protection]).required_status_checks
  end

  def verify_required_checks(status)
    return true if required_checks.contexts.empty? # short circuit if branch is not protected
    return nil if status.statuses.empty? # also short circuit if branch is protected and nothing is being reported

    missing_contexts = required_checks.contexts - status.statuses.collect { |status| status.context }
    if missing_contexts.count.positive?
      # To reach this it means the Status API thinks we're complete but it's not returning statuses for at least one required status check.
      log.warn "Missing status data for #{missing_contexts.count} required status contexts; Missing Contexts: #{missing_contexts.inspect}" if status.state == 'success'
      return nil
    end

    required_checks.contexts.each do |context|
      pending = true
      status.statuses.each do |stat|
        next unless context == stat.context
        return false if ['failure', 'error'].include?(stat.state) # we've failed at least one
        pending = false if stat.state == 'success' # indeterminate state, waiting for at least one check to finish
      end
      return nil if pending # indeterminate state, waiting for at least one check to finish
    end
    true # all required checks successful
  end

  def perform_merge
    start = Time.now
    loop do
      begin
        sleep 10 # quick attempt at avoiding a race condition against the GitHub API
        override_enforce_admins if admin?
        github.merge_pull_request(@project, pull_request.number) unless simulation_only?
        break
      rescue Octokit::Conflict => e
        elapsed = (Time.now - start).to_i
        if elapsed > 120
          log.error "Github API has returned a 409 while attempting to merge.  Giving up after #{elapsed} seconds\n#{e.message}"
          raise
        else
          log.warn "Github API has returned a 409 while attempting to merge.  Will retry for a max of 120 seconds (#{elapsed} elapsed)\n#{e.message}"
        end
      end
    end
  ensure
    reset_enforce_admins if admin?
  end

  def current_branch_protections
    @saved_branch_protections ||= github.branch_protection(@project, pull_request.base.ref, accept: Octokit::Preview::PREVIEW_TYPES[:branch_protection])
    return nil if @saved_branch_protections.nil?
    protections = {}

    protections[:enforce_admins] = @saved_branch_protections[:enforce_admins][:enabled] || nil

    if @saved_branch_protections[:required_status_checks]
      protections[:required_status_checks] = {
        strict: @saved_branch_protections[:required_status_checks][:strict],
        contexts: @saved_branch_protections[:required_status_checks][:contexts]
      }
    else
      protections[:required_status_checks] = nil
    end

    if @saved_branch_protections[:required_pull_request_reviews]
      protections[:required_pull_request_reviews] = {
        dismiss_stale_reviews: @saved_branch_protections[:required_pull_request_reviews][:dismiss_stale_reviews],
        require_code_owner_reviews: @saved_branch_protections[:required_pull_request_reviews][:require_code_owner_reviews],
      }
      if @saved_branch_protections[:required_pull_request_reviews][:dismissal_restrictions]
        protections[:required_pull_request_reviews][:dismissal_restrictions] = {
          users: @saved_branch_protections[:required_pull_request_reviews][:dismissal_restrictions][:users].map(&:login),
          teams: @saved_branch_protections[:required_pull_request_reviews][:dismissal_restrictions][:teams].map(&:slug)
        }
      end
      if @saved_branch_protections[:required_pull_request_reviews][:required_approving_review_count]
        protections[:required_pull_request_reviews][:required_approving_review_count] = @saved_branch_protections[:required_pull_request_reviews][:required_approving_review_count]
      end
    else
      protections[:required_pull_request_reviews] = nil
    end

    if @saved_branch_protections[:restrictions]
      protections[:restrictions] = {
        users: @saved_branch_protections[:restrictions][:users].map(&:login),
        teams: @saved_branch_protections[:restrictions][:teams].map(&:slug)
      }
    else
      protections[:restrictions] = nil
    end

    protections
  end

  def override_enforce_admins
    updated_protections = current_branch_protections
    return if @saved_branch_protections.nil? || @saved_branch_protections[:enforce_admins][:enabled] == false
    updated_protections[:enforce_admins] = false
    github.protect_branch(@project, pull_request.base.ref, updated_protections)
  end

  def reset_enforce_admins
    return if @saved_branch_protections.nil? || @saved_branch_protections[:enforce_admins][:enabled] == false
    github.protect_branch(@project, pull_request.base.ref, current_branch_protections)
  end

  def delete_branch
    if simulation_only?
      sleep 10
    else
      sleep 5 # avoid a race condition similar to with perform_merge
      github.delete_branch(@project, pull_request.head.ref)
      sleep 5 # avoid a race condition similar to with perform_merge
    end
  end

  def required_check_timeout
    if project_settings[:required_check_timeout]
      project_settings[:required_check_timeout]
    else
      DEFAULT_JOB_TIMEOUT # default to 10 minute timeout for required checks
    end
  end

  def admin?
    @admin
  end

  def simulation_only?
    project_settings[:simulation_only]
  end

  def silent_mode?
    project_settings[:silent_mode]
  end
end
