import { DateTime } from 'luxon';
import { GlobalConfig } from '../../../../config/global';
import {
  PLATFORM_INTEGRATION_UNAUTHORIZED,
  PLATFORM_RATE_LIMIT_EXCEEDED,
  REPOSITORY_CHANGED,
} from '../../../../constants/error-messages';
import * as _comment from '../../../../modules/platform/comment';
import { getPrBodyStruct } from '../../../../modules/platform/pr-body';
import type { Pr } from '../../../../modules/platform/types';
import { ExternalHostError } from '../../../../types/errors/external-host-error';
import type { PrCache } from '../../../../util/cache/repository/types';
import { fingerprint } from '../../../../util/fingerprint';
import { toBase64 } from '../../../../util/string';
import * as _limits from '../../../global/limits';
import type { BranchConfig, BranchUpgradeConfig } from '../../../types';
import { embedChangelogs } from '../../changelog';
import * as _statusChecks from '../branch/status-checks';
import * as _prBody from './body';
import type { ChangeLogChange, ChangeLogRelease } from './changelog/types';
import * as _participants from './participants';
import * as _prCache from './pr-cache';
import { generatePrBodyFingerprintConfig } from './pr-fingerprint';
import { ensurePr } from '.';
import { git, logger, partial, platform, scm } from '~test/util';

vi.mock('../../changelog');

vi.mock('../../../global/limits');
const limits = vi.mocked(_limits);

vi.mock('../branch/status-checks');
const checks = vi.mocked(_statusChecks);

vi.mock('./body');
const prBody = vi.mocked(_prBody);

vi.mock('./participants');
const participants = vi.mocked(_participants);

vi.mock('../../../../modules/platform/comment');
const comment = vi.mocked(_comment);

vi.mock('./pr-cache');
const prCache = vi.mocked(_prCache);

describe('workers/repository/update/pr/index', () => {
  describe('ensurePr', () => {
    const number = 123;
    const sourceBranch = 'renovate-branch';
    const prTitle = 'Some title';
    const body = 'Some body';
    const bodyStruct = getPrBodyStruct(body);

    const pr: Pr = {
      number,
      sourceBranch,
      title: prTitle,
      bodyStruct,
      state: 'open',
      targetBranch: 'base',
    };

    const config: BranchConfig = {
      manager: 'some-manager',
      branchName: sourceBranch,
      baseBranch: 'base',
      upgrades: [],
      prTitle,
    };

    beforeEach(() => {
      GlobalConfig.reset();
      prBody.getPrBody.mockReturnValue(body);
    });

    describe('Create', () => {
      it('creates PR', async () => {
        platform.createPr.mockResolvedValueOnce(pr);

        const res = await ensurePr(config);

        expect(res).toEqual({ type: 'with-pr', pr });
        expect(limits.incCountValue).toHaveBeenCalledTimes(2);
        expect(limits.incCountValue).toHaveBeenCalledWith('ConcurrentPRs');
        expect(limits.incCountValue).toHaveBeenCalledWith('HourlyPRs');
        expect(logger.logger.info).toHaveBeenCalledWith(
          { pr: pr.number, prTitle },
          'PR created',
        );
        expect(prCache.setPrCache).toHaveBeenCalled();
      });

      it('aborts PR creation once limit is exceeded', async () => {
        platform.createPr.mockResolvedValueOnce(pr);
        limits.isLimitReached.mockReturnValueOnce(true);

        config.fetchChangeLogs = 'pr';

        const res = await ensurePr(config);

        expect(res).toEqual({ type: 'without-pr', prBlockedBy: 'RateLimited' });
        expect(platform.createPr).not.toHaveBeenCalled();
        expect(prCache.setPrCache).not.toHaveBeenCalled();
      });

      it('ignores PR limits on vulnerability alert', async () => {
        platform.createPr.mockResolvedValueOnce(pr);
        limits.isLimitReached.mockReturnValueOnce(true);

        const prConfig = { ...config, isVulnerabilityAlert: true };
        delete prConfig.prTitle; // for coverage
        const res = await ensurePr(prConfig);

        expect(res).toEqual({ type: 'with-pr', pr });
        expect(platform.createPr).toHaveBeenCalled();
        expect(prCache.setPrCache).toHaveBeenCalled();
      });

      it('creates rollback PR', async () => {
        platform.createPr.mockResolvedValueOnce(pr);

        const res = await ensurePr({ ...config, updateType: 'rollback' });

        expect(res).toEqual({ type: 'with-pr', pr });
        expect(logger.logger.info).toHaveBeenCalledWith('Creating Rollback PR');
        expect(prCache.setPrCache).toHaveBeenCalled();
      });

      it('skips PR creation due to non-green branch check', async () => {
        checks.resolveBranchStatus.mockResolvedValueOnce('yellow');

        const res = await ensurePr({ ...config, prCreation: 'status-success' });

        expect(res).toEqual({
          type: 'without-pr',
          prBlockedBy: 'AwaitingTests',
        });
        expect(prCache.setPrCache).not.toHaveBeenCalled();
      });

      it('creates PR for green branch checks', async () => {
        checks.resolveBranchStatus.mockResolvedValueOnce('green');
        platform.createPr.mockResolvedValueOnce(pr);

        const res = await ensurePr({ ...config, prCreation: 'status-success' });

        expect(res).toEqual({ type: 'with-pr', pr });
        expect(platform.createPr).toHaveBeenCalled();
        expect(prCache.setPrCache).toHaveBeenCalled();
      });

      it('skips PR creation for unapproved dependencies', async () => {
        checks.resolveBranchStatus.mockResolvedValueOnce('yellow');

        const res = await ensurePr({ ...config, prCreation: 'approval' });

        expect(res).toEqual({
          type: 'without-pr',
          prBlockedBy: 'NeedsApproval',
        });
        expect(prCache.setPrCache).not.toHaveBeenCalled();
      });

      it('skips PR creation before prNotPendingHours is hit', async () => {
        const now = DateTime.now();
        const then = now.minus({ hours: 1 });

        checks.resolveBranchStatus.mockResolvedValueOnce('yellow');
        git.getBranchLastCommitTime.mockResolvedValueOnce(then.toJSDate());

        const res = await ensurePr({
          ...config,
          prCreation: 'not-pending',
          prNotPendingHours: 2,
        });

        expect(res).toEqual({
          type: 'without-pr',
          prBlockedBy: 'AwaitingTests',
        });
        expect(prCache.setPrCache).not.toHaveBeenCalled();
      });

      it('skips PR creation due to stabilityStatus', async () => {
        const now = DateTime.now();
        const then = now.minus({ hours: 1 });

        checks.resolveBranchStatus.mockResolvedValueOnce('yellow');
        git.getBranchLastCommitTime.mockResolvedValueOnce(then.toJSDate());

        const res = await ensurePr({
          ...config,
          prCreation: 'not-pending',
          stabilityStatus: 'green',
        });

        expect(res).toEqual({
          type: 'without-pr',
          prBlockedBy: 'AwaitingTests',
        });
        expect(prCache.setPrCache).not.toHaveBeenCalled();
      });

      it('creates PR after prNotPendingHours is hit', async () => {
        const now = DateTime.now();
        const then = now.minus({ hours: 2 });

        checks.resolveBranchStatus.mockResolvedValueOnce('yellow');
        git.getBranchLastCommitTime.mockResolvedValueOnce(then.toJSDate());
        platform.createPr.mockResolvedValueOnce(pr);

        const res = await ensurePr({
          ...config,
          prCreation: 'not-pending',
          prNotPendingHours: 1,
        });

        expect(res).toEqual({ type: 'with-pr', pr });
        expect(prCache.setPrCache).toHaveBeenCalled();
      });

      describe('Error handling', () => {
        it('handles unknown error', async () => {
          const err = new Error('unknown');
          platform.createPr.mockRejectedValueOnce(err);

          const res = await ensurePr(config);

          expect(res).toEqual({ type: 'without-pr', prBlockedBy: 'Error' });
          expect(prCache.setPrCache).not.toHaveBeenCalled();
        });

        it('handles error for PR that already exists', async () => {
          const err: Error & { body?: unknown } = new Error('unknown');
          err.body = {
            message: 'Validation failed',
            errors: [{ message: 'A pull request already exists' }],
          };
          platform.createPr.mockRejectedValueOnce(err);

          const res = await ensurePr(config);

          expect(res).toEqual({ type: 'without-pr', prBlockedBy: 'Error' });
          expect(logger.logger.warn).toHaveBeenCalledWith(
            'A pull requests already exists',
          );
          expect(prCache.setPrCache).not.toHaveBeenCalled();
        });

        it('deletes branch on 502 error', async () => {
          const err: Error & { statusCode?: number } = new Error('unknown');
          err.statusCode = 502;
          platform.createPr.mockRejectedValueOnce(err);

          const res = await ensurePr(config);

          expect(res).toEqual({ type: 'without-pr', prBlockedBy: 'Error' });
          expect(prCache.setPrCache).not.toHaveBeenCalled();
          expect(scm.deleteBranch).toHaveBeenCalledWith('renovate-branch');
        });
      });
    });

    describe('Update', () => {
      it('updates PR if labels have changed in config', async () => {
        const prDebugData = {
          createdInVer: '1.0.0',
          targetBranch: 'main',
          labels: ['old_label'],
        };

        const existingPr: Pr = {
          ...pr,
          bodyStruct: getPrBodyStruct(
            `\n<!--renovate-debug:${toBase64(
              JSON.stringify(prDebugData),
            )}-->\n Some body`,
          ),
          labels: ['old_label'],
        };
        platform.getBranchPr.mockResolvedValueOnce(existingPr);
        prBody.getPrBody.mockReturnValueOnce(
          `\n<!--renovate-debug:${toBase64(
            JSON.stringify({ ...prDebugData, labels: ['new_label'] }),
          )}-->\n Some body`,
        );
        config.labels = ['new_label'];
        const res = await ensurePr(config);

        expect(res).toEqual({
          type: 'with-pr',
          pr: {
            ...pr,
            labels: ['old_label'],
            bodyStruct: {
              hash: expect.any(String),
              debugData: {
                createdInVer: '1.0.0',
                labels: ['new_label'],
                targetBranch: 'main',
              },
            },
          },
        });
        expect(platform.updatePr).toHaveBeenCalled();
        expect(platform.createPr).not.toHaveBeenCalled();
        expect(logger.logger.debug).toHaveBeenCalledWith(
          {
            branchName: 'renovate-branch',
            prCurrentLabels: ['old_label'],
            configuredLabels: ['new_label'],
          },
          `PR labels have changed`,
        );
        expect(prCache.setPrCache).toHaveBeenCalled();
      });

      it('skips pr update if existing pr does not have labels in debugData', async () => {
        const existingPr: Pr = {
          ...pr,
          labels: ['old_label'],
        };
        platform.getBranchPr.mockResolvedValueOnce(existingPr);

        config.labels = ['new_label'];
        const res = await ensurePr(config);

        expect(res).toEqual({
          type: 'with-pr',
          pr: { ...pr, labels: ['old_label'] },
        });
        expect(platform.updatePr).not.toHaveBeenCalled();
        expect(platform.createPr).not.toHaveBeenCalled();
        expect(logger.logger.debug).not.toHaveBeenCalledWith(
          {
            branchName: 'renovate-branch',
            oldLabels: ['old_label'],
            newLabels: ['new_label'],
          },
          `PR labels have changed`,
        );
        expect(prCache.setPrCache).toHaveBeenCalled();
      });

      it('skips pr update if pr labels have been modified by user', async () => {
        const prDebugData = {
          createdInVer: '1.0.0',
          targetBranch: 'main',
          labels: ['old_label'],
        };

        const existingPr: Pr = {
          ...pr,
          bodyStruct: getPrBodyStruct(
            `\n<!--renovate-debug:${toBase64(
              JSON.stringify(prDebugData),
            )}-->\n Some body`,
          ),
        };
        platform.getBranchPr.mockResolvedValueOnce(existingPr);

        config.labels = ['new_label'];
        const res = await ensurePr(config);

        expect(res).toEqual({
          type: 'with-pr',
          pr: {
            ...pr,
            bodyStruct: {
              hash: expect.any(String),
              debugData: {
                createdInVer: '1.0.0',
                labels: ['old_label'],
                targetBranch: 'main',
              },
            },
          },
        });
        expect(platform.updatePr).not.toHaveBeenCalled();
        expect(platform.createPr).not.toHaveBeenCalled();
        expect(logger.logger.debug).not.toHaveBeenCalledWith(
          {
            branchName: 'renovate-branch',
            prCurrentLabels: ['old_label'],
            configuredLabels: ['new_label'],
          },
          `PR labels have changed`,
        );
        expect(logger.logger.debug).toHaveBeenCalledWith(
          { prInitialLabels: ['old_label'], prCurrentLabels: [] },
          'PR labels have been modified by user, skipping labels update',
        );
        expect(prCache.setPrCache).toHaveBeenCalled();
      });

      it('updates PR due to title change', async () => {
        const changedPr: Pr = { ...pr, title: 'Another title' }; // user changed the prTitle
        platform.getBranchPr.mockResolvedValueOnce(changedPr);

        const res = await ensurePr(config);

        expect(res).toEqual({ type: 'with-pr', pr }); // we redo the prTitle as per config
        expect(platform.updatePr).toHaveBeenCalled();
        expect(platform.createPr).not.toHaveBeenCalled();
        expect(logger.logger.info).toHaveBeenCalledWith(
          { pr: changedPr.number, prTitle },
          `PR updated`,
        );
        expect(prCache.setPrCache).toHaveBeenCalled();
      });

      it('updates PR due to body change', async () => {
        const changedPr: Pr = {
          ...pr,
          bodyStruct: getPrBodyStruct(`${body} updated`), // user changed prBody
        };
        platform.getBranchPr.mockResolvedValueOnce(changedPr);

        const res = await ensurePr(config);

        expect(res).toEqual({ type: 'with-pr', pr }); // we redo the prBody as per config
        expect(platform.updatePr).toHaveBeenCalled();
        expect(platform.createPr).not.toHaveBeenCalled();
        expect(prCache.setPrCache).toHaveBeenCalled();
        expect(logger.logger.info).toHaveBeenCalledWith(
          { pr: changedPr.number, prTitle },
          `PR updated`,
        );
      });

      it('updates PR target branch if base branch changed in config', async () => {
        platform.getBranchPr.mockResolvedValueOnce(pr);

        const res = await ensurePr({ ...config, baseBranch: 'new_base' }); // user changed base branch in config

        expect(platform.updatePr).toHaveBeenCalled();
        expect(platform.createPr).not.toHaveBeenCalled();
        expect(prCache.setPrCache).toHaveBeenCalled();
        expect(logger.logger.info).toHaveBeenCalledWith(
          { pr: pr.number, prTitle },
          `PR updated`,
        );
        expect(logger.logger.debug).toHaveBeenCalledWith(
          {
            branchName: 'renovate-branch',
            oldBaseBranch: 'base',
            newBaseBranch: 'new_base',
          },
          'PR base branch has changed',
        );
        expect(res).toEqual({
          type: 'with-pr',
          pr: { ...pr, targetBranch: 'new_base' }, // updated target branch of pr
        });
      });

      it('ignores reviewable content', async () => {
        // See: https://reviewable.io/

        const reviewableContent =
          '<!-- Reviewable:start -->something<!-- Reviewable:end -->';
        const changedPr: Pr = {
          ...pr,
          bodyStruct: getPrBodyStruct(`${body}${reviewableContent}`),
        };
        platform.getBranchPr.mockResolvedValueOnce(changedPr);

        const res = await ensurePr(config);

        expect(res).toEqual({ type: 'with-pr', pr: changedPr });
        expect(platform.updatePr).not.toHaveBeenCalled();
        expect(platform.createPr).not.toHaveBeenCalled();
        expect(prCache.setPrCache).toHaveBeenCalled();
        expect(logger.logger.debug).toHaveBeenCalledWith(
          'Pull Request #123 does not need updating',
        );
      });
    });

    describe('dry-run', () => {
      beforeEach(() => {
        GlobalConfig.set({ dryRun: 'full' });
      });

      it('dry-runs PR creation', async () => {
        platform.createPr.mockResolvedValueOnce(pr);

        const res = await ensurePr(config);

        expect(res).toEqual({
          type: 'with-pr',
          pr: { number: 0 },
        });
        expect(platform.updatePr).not.toHaveBeenCalled();
        expect(platform.createPr).not.toHaveBeenCalled();
        expect(logger.logger.info).toHaveBeenCalledWith(
          `DRY-RUN: Would create PR: ${prTitle}`,
        );
      });

      it('dry-runs PR update', async () => {
        const changedPr: Pr = { ...pr, title: 'Another title' };
        platform.getBranchPr.mockResolvedValueOnce(changedPr);

        const res = await ensurePr(config);

        expect(res).toEqual({ type: 'with-pr', pr: changedPr });
        expect(platform.updatePr).not.toHaveBeenCalled();
        expect(platform.createPr).not.toHaveBeenCalled();
        expect(logger.logger.info).toHaveBeenCalledWith(
          `DRY-RUN: Would update PR #${pr.number}`,
        );
      });

      it('skips automerge failure comment', async () => {
        platform.createPr.mockResolvedValueOnce(pr);
        checks.resolveBranchStatus.mockResolvedValueOnce('red');
        platform.massageMarkdown.mockReturnValueOnce('markdown content');

        await ensurePr({
          ...config,
          automerge: true,
          automergeType: 'branch',
          branchAutomergeFailureMessage: 'branch status error',
          suppressNotifications: [],
        });

        expect(comment.ensureComment).not.toHaveBeenCalled();
      });
    });

    describe('Automerge', () => {
      it('handles branch automerge', async () => {
        const res = await ensurePr({
          ...config,
          automerge: true,
          automergeType: 'branch',
        });

        expect(res).toEqual({
          type: 'without-pr',
          prBlockedBy: 'BranchAutomerge',
        });
        expect(platform.updatePr).not.toHaveBeenCalled();
        expect(platform.createPr).not.toHaveBeenCalled();
        expect(prCache.setPrCache).not.toHaveBeenCalled();
      });

      it('forces PR on dashboard check', async () => {
        platform.createPr.mockResolvedValueOnce(pr);

        const res = await ensurePr({
          ...config,
          automerge: true,
          automergeType: 'branch',
          reviewers: ['somebody'],
          dependencyDashboardChecks: {
            'renovate-branch': 'approvePr',
          },
        });

        expect(res).toEqual({ type: 'with-pr', pr });
        expect(prCache.setPrCache).toHaveBeenCalled();
      });

      it('adds assignees for PR automerge with red status', async () => {
        const changedPr: Pr = {
          ...pr,
          hasAssignees: false,
        };
        platform.getBranchPr.mockResolvedValueOnce(changedPr);
        checks.resolveBranchStatus.mockResolvedValueOnce('red');

        const res = await ensurePr({
          ...config,
          automerge: true,
          automergeType: 'pr',
          assignAutomerge: false,
        });

        expect(res).toEqual({ type: 'with-pr', pr: changedPr });
        expect(participants.addParticipants).toHaveBeenCalled();
        expect(prCache.setPrCache).toHaveBeenCalled();
      });

      it('adds reviewers for PR automerge with red status and existing ignorable reviewers that can be ignored', async () => {
        const changedPr: Pr = {
          ...pr,
          hasAssignees: false,
          reviewers: ['renovate-approve'],
        };
        platform.getBranchPr.mockResolvedValueOnce(changedPr);
        checks.resolveBranchStatus.mockResolvedValueOnce('red');

        const res = await ensurePr({
          ...config,
          automerge: true,
          automergeType: 'pr',
          assignAutomerge: false,
          ignoreReviewers: ['renovate-approve'],
        });

        expect(res).toEqual({ type: 'with-pr', pr: changedPr });
        expect(participants.addParticipants).toHaveBeenCalled();
      });

      it('skips branch automerge and forces PR creation due to artifact errors', async () => {
        platform.createPr.mockResolvedValueOnce(pr);

        const res = await ensurePr({
          ...config,
          automerge: true,
          automergeType: 'branch',
          artifactErrors: [{ lockFile: 'foo', stderr: 'bar' }],
        });

        expect(res).toEqual({ type: 'with-pr', pr });
        expect(platform.createPr).toHaveBeenCalled();
        expect(participants.addParticipants).not.toHaveBeenCalled();
        expect(prCache.setPrCache).toHaveBeenCalled();
      });

      it('skips branch automerge and forces PR creation due to prNotPendingHours exceeded', async () => {
        const now = DateTime.now();
        const then = now.minus({ hours: 2 });

        git.getBranchLastCommitTime.mockResolvedValueOnce(then.toJSDate());
        checks.resolveBranchStatus.mockResolvedValueOnce('yellow');
        platform.createPr.mockResolvedValueOnce(pr);

        const res = await ensurePr({
          ...config,
          automerge: true,
          automergeType: 'branch',
          stabilityStatus: 'green',
          prNotPendingHours: 1,
        });

        expect(res).toEqual({ type: 'with-pr', pr });
        expect(platform.createPr).toHaveBeenCalled();
        expect(prCache.setPrCache).toHaveBeenCalled();
      });

      it('automerges branch when prNotPendingHours are not exceeded', async () => {
        const now = DateTime.now();
        const then = now.minus({ hours: 1 });

        git.getBranchLastCommitTime.mockResolvedValueOnce(then.toJSDate());
        checks.resolveBranchStatus.mockResolvedValueOnce('yellow');
        platform.createPr.mockResolvedValueOnce(pr);

        const res = await ensurePr({
          ...config,
          automerge: true,
          automergeType: 'branch',
          stabilityStatus: 'green',
          prNotPendingHours: 2,
        });

        expect(res).toEqual({
          type: 'without-pr',
          prBlockedBy: 'BranchAutomerge',
        });
        expect(platform.createPr).not.toHaveBeenCalled();
      });

      it('comments on automerge failure', async () => {
        platform.createPr.mockResolvedValueOnce(pr);
        checks.resolveBranchStatus.mockResolvedValueOnce('red');
        vi.spyOn(platform, 'massageMarkdown').mockImplementation(
          (prBody) => 'markdown content',
        );
        await ensurePr({
          ...config,
          automerge: true,
          automergeType: 'branch',
          branchAutomergeFailureMessage: 'branch status error',
          suppressNotifications: [],
        });

        expect(platform.createPr).toHaveBeenCalled();
        expect(platform.massageMarkdown).toHaveBeenCalled();
        expect(comment.ensureComment).toHaveBeenCalledWith({
          content: 'markdown content',
          number: 123,
          topic: 'Branch automerge failure',
        });
      });

      it('handles ensureComment error', async () => {
        platform.createPr.mockResolvedValueOnce(pr);
        checks.resolveBranchStatus.mockResolvedValueOnce('red');
        platform.massageMarkdown.mockReturnValueOnce('markdown content');
        comment.ensureComment.mockRejectedValueOnce(new Error('unknown'));

        const res = await ensurePr({
          ...config,
          automerge: true,
          automergeType: 'branch',
          branchAutomergeFailureMessage: 'branch status error',
          suppressNotifications: [],
        });

        expect(res).toEqual({ type: 'without-pr', prBlockedBy: 'Error' });
      });

      it('logs unknown error', async () => {
        const changedPr: Pr = {
          ...pr,
          hasAssignees: false,
        };
        platform.getBranchPr.mockResolvedValueOnce(changedPr);
        checks.resolveBranchStatus.mockResolvedValueOnce('red');

        const err = new Error('unknown');
        participants.addParticipants.mockRejectedValueOnce(err);

        await ensurePr({
          ...config,
          automerge: true,
          automergeType: 'pr',
          assignAutomerge: false,
        });

        expect(logger.logger.warn).toHaveBeenCalledWith(
          { err, prTitle },
          'Failed to ensure PR',
        );
      });

      it('re-throws ExternalHostError', async () => {
        const changedPr: Pr = {
          ...pr,
          hasAssignees: false,
        };
        platform.getBranchPr.mockResolvedValueOnce(changedPr);
        checks.resolveBranchStatus.mockResolvedValueOnce('red');

        const err = new ExternalHostError(new Error('unknown'));
        participants.addParticipants.mockRejectedValueOnce(err);

        await expect(
          ensurePr({
            ...config,
            automerge: true,
            automergeType: 'pr',
            assignAutomerge: false,
          }),
        ).rejects.toThrow(err);
      });

      it.each`
        message
        ${REPOSITORY_CHANGED}
        ${PLATFORM_RATE_LIMIT_EXCEEDED}
        ${PLATFORM_INTEGRATION_UNAUTHORIZED}
      `(
        're-throws error with specific message: "$message"',
        async ({ message }) => {
          const changedPr: Pr = {
            ...pr,
            hasAssignees: false,
          };
          platform.getBranchPr.mockResolvedValueOnce(changedPr);
          checks.resolveBranchStatus.mockResolvedValueOnce('red');

          const err = new Error(message);
          participants.addParticipants.mockRejectedValueOnce(err);

          await expect(
            ensurePr({
              ...config,
              automerge: true,
              automergeType: 'pr',
              assignAutomerge: false,
            }),
          ).rejects.toThrow(err);
        },
      );
    });

    describe('Changelog', () => {
      const dummyChanges: ChangeLogChange[] = [
        {
          date: DateTime.fromISO('2000-01-01').toJSDate(),
          message: '',
          sha: '',
        },
      ];

      const dummyRelease: ChangeLogRelease = {
        version: '',
        gitRef: '',
        changes: dummyChanges,
        compare: {},
        date: '',
      };

      const dummyUpgrade = partial<BranchUpgradeConfig>({
        branchName: sourceBranch,
        depType: 'foo',
        depName: 'bar',
        manager: 'npm',
        currentValue: '1.2.3',
        newVersion: '4.5.6',
        logJSON: {
          hasReleaseNotes: true,
          project: {
            type: 'github',
            repository: 'some/repo',
            baseUrl: 'https://github.com',
            apiBaseUrl: 'https://api.github.com/',
            sourceUrl: 'https://github.com/some/repo',
          },
          versions: [
            { ...dummyRelease, version: '1.2.3' },
            { ...dummyRelease, version: '2.3.4' },
            { ...dummyRelease, version: '3.4.5' },
            { ...dummyRelease, version: '4.5.6' },
          ],
        },
      });

      it('processes changelogs', async () => {
        platform.createPr.mockResolvedValueOnce(pr);

        const res = await ensurePr({
          ...config,
          upgrades: [dummyUpgrade],
        });

        expect(res).toEqual({ type: 'with-pr', pr });
        const [[bodyConfig]] = prBody.getPrBody.mock.calls;
        expect(bodyConfig).toMatchObject({
          hasReleaseNotes: true,
          upgrades: [
            {
              hasReleaseNotes: true,
              releases: [
                { version: '1.2.3' },
                { version: '2.3.4' },
                { version: '3.4.5' },
                { version: '4.5.6' },
              ],
            },
          ],
        });
      });

      it('handles missing GitHub token', async () => {
        platform.createPr.mockResolvedValueOnce(pr);

        const res = await ensurePr({
          ...config,
          upgrades: [
            {
              ...dummyUpgrade,
              logJSON: { error: 'MissingGithubToken' },
              prBodyNotes: [],
            },
          ],
        });

        expect(res).toEqual({ type: 'with-pr', pr });

        const {
          upgrades: [{ prBodyNotes }],
        } = prBody.getPrBody.mock.calls[0][0];
        expect(prBodyNotes).toBeNonEmptyArray();
      });

      it('removes duplicate changelogs', async () => {
        platform.createPr.mockResolvedValueOnce(pr);

        const upgrade = partial<BranchUpgradeConfig>({
          ...dummyUpgrade,
          sourceUrl: 'https://github.com/foo/bar',
          sourceDirectory: '/src',
        });
        const res = await ensurePr({
          ...config,
          upgrades: [upgrade, upgrade, { ...upgrade, depType: 'test' }],
        });

        expect(res).toEqual({ type: 'with-pr', pr });
        const [[bodyConfig]] = prBody.getPrBody.mock.calls;
        expect(bodyConfig).toMatchObject({
          branchName: 'renovate-branch',
          hasReleaseNotes: true,
          prTitle: 'Some title',
          upgrades: [
            { depType: 'foo', hasReleaseNotes: true },
            { depType: 'test', hasReleaseNotes: false },
          ],
        });
      });

      it('remove duplicates release notes', async () => {
        platform.createPr.mockResolvedValueOnce(pr);
        const upgrade = {
          ...dummyUpgrade,
          logJSON: undefined,
          sourceUrl: 'https://github.com/foo/bar',
          hasReleaseNotes: true,
        };
        delete upgrade.logJSON;

        const res = await ensurePr({
          ...config,
          upgrades: [upgrade, { ...upgrade, depType: 'test' }],
        });

        expect(res).toEqual({ type: 'with-pr', pr });
        const [[bodyConfig]] = prBody.getPrBody.mock.calls;
        expect(bodyConfig).toMatchObject({
          branchName: 'renovate-branch',
          hasReleaseNotes: true,
          prTitle: 'Some title',
          upgrades: [
            { depType: 'foo', hasReleaseNotes: true },
            { depType: 'test', hasReleaseNotes: false },
          ],
        });
      });

      // compares currentVersion and currentValue separately to
      // prevent removal false duplicates
      it('stricter de-deuplication of changelogs', async () => {
        platform.createPr.mockResolvedValueOnce(pr);
        const upgrade = {
          ...dummyUpgrade,
          currentValue:
            '1.21.5-alpine3.18@sha256:d8b99943fb0587b79658af03d4d4e8b57769b21dcf08a8401352a9f2a7228754',
          newValue:
            '1.21.6-alpine3.18@sha256:3354c3a94c3cf67cb37eb93a8e9474220b61a196b13c26f1c01715c301b22a69',
          currentVersion: '1.21.5-alpine3.18',
          newVersion: '1.21.6-alpine3.18',
          logJSON: undefined,
          sourceUrl: 'https://github.com/foo/bar',
          hasReleaseNotes: true,
        };
        delete upgrade.logJSON;

        const res = await ensurePr({
          ...config,
          upgrades: [
            upgrade,
            {
              ...upgrade,
              currentValue:
                '1.21.5-alpine3.19@sha256:d8b99943fb0587b79658af03d4d4e8b57769b21dcf08a8401352a9f2a7228754',
              newValue:
                '1.21.6-alpine3.19@sha256:3354c3a94c3cf67cb37eb93a8e9474220b61a196b13c26f1c01715c301b22a69',
              currentVersion: '1.21.5-alpine3.19',
              newVersion: '1.21.6-alpine3.19',
            },
            // adding this object for coverage
            {
              ...upgrade,
              currentValue: undefined,
              newValue:
                '1.21.6-alpine3.19@sha256:3354c3a94c3cf67cb37eb93a8e9474220b61a196b13c26f1c01715c301b22a69',
              currentVersion: '1.21.5-alpine3.19',
              newVersion: undefined,
            },
          ],
        });

        expect(res).toEqual({ type: 'with-pr', pr });
        const [[bodyConfig]] = prBody.getPrBody.mock.calls;
        expect(bodyConfig.upgrades).toHaveLength(3);
      });
    });

    describe('prCache', () => {
      const existingPr: Pr = {
        ...pr,
      };
      let cachedPr: PrCache | null = null;

      it('adds pr-cache when not present', async () => {
        platform.getBranchPr.mockResolvedValue(existingPr);
        cachedPr = null;
        prCache.getPrCache.mockReturnValueOnce(cachedPr);
        const res = await ensurePr(config);
        expect(res).toEqual({
          type: 'with-pr',
          pr: existingPr,
        });
        expect(logger.logger.debug).toHaveBeenCalledWith(
          'Pull Request #123 does not need updating',
        );
        expect(prCache.setPrCache).toHaveBeenCalledTimes(1);
      });

      it('does not update lastEdited pr-cache when pr fingerprint is same but pr was edited within 24hrs', async () => {
        platform.getBranchPr.mockResolvedValue(existingPr);
        cachedPr = {
          bodyFingerprint: fingerprint(generatePrBodyFingerprintConfig(config)),
          lastEdited: new Date().toISOString(),
        };
        prCache.getPrCache.mockReturnValueOnce(cachedPr);
        const res = await ensurePr(config);
        expect(res).toEqual({
          type: 'with-pr',
          pr: existingPr,
        });
        expect(logger.logger.debug).toHaveBeenCalledWith(
          'Pull Request #123 does not need updating',
        );
        expect(logger.logger.debug).toHaveBeenCalledWith(
          'PR cache matches but it has been edited in the past 24hrs, so processing PR',
        );
        expect(prCache.setPrCache).toHaveBeenCalledWith(
          sourceBranch,
          cachedPr.bodyFingerprint,
          false,
        );
      });

      it('updates pr-cache when pr fingerprint is different', async () => {
        platform.getBranchPr.mockResolvedValue(existingPr);
        cachedPr = {
          bodyFingerprint: 'old',
          lastEdited: new Date('2020-01-20T00:00:00Z').toISOString(),
        };
        prCache.getPrCache.mockReturnValueOnce(cachedPr);
        const res = await ensurePr(config);
        expect(res).toEqual({
          type: 'with-pr',
          pr: existingPr,
        });
        expect(logger.logger.debug).toHaveBeenCalledWith(
          'PR fingerprints mismatch, processing PR',
        );
        expect(prCache.setPrCache).toHaveBeenCalledTimes(1);
      });

      it('skips fetching changelogs when cache is valid and pr was lastEdited before 24hrs', async () => {
        config.repositoryCache = 'enabled';
        platform.getBranchPr.mockResolvedValue(existingPr);
        cachedPr = {
          bodyFingerprint: fingerprint(
            generatePrBodyFingerprintConfig({
              ...config,
              fetchChangeLogs: 'pr',
            }),
          ),
          lastEdited: new Date('2020-01-20T00:00:00Z').toISOString(),
        };
        prCache.getPrCache.mockReturnValueOnce(cachedPr);
        const res = await ensurePr({ ...config, fetchChangeLogs: 'pr' });
        expect(res).toEqual({
          type: 'with-pr',
          pr: existingPr,
        });
        expect(logger.logger.debug).toHaveBeenCalledWith(
          'PR cache matches and no PR changes in last 24hrs, so skipping PR body check',
        );
        expect(embedChangelogs).toHaveBeenCalledTimes(0);
      });

      it('updates PR when rebase requested by user regardless of pr-cache state', async () => {
        config.repositoryCache = 'enabled';
        platform.getBranchPr.mockResolvedValue({
          number,
          sourceBranch,
          title: prTitle,
          bodyStruct: {
            hash: 'hash-with-checkbox-checked',
            rebaseRequested: true,
          },
          state: 'open',
        });
        cachedPr = {
          bodyFingerprint: fingerprint(
            generatePrBodyFingerprintConfig({
              ...config,
              fetchChangeLogs: 'pr',
            }),
          ),
          lastEdited: new Date('2020-01-20T00:00:00Z').toISOString(),
        };
        prCache.getPrCache.mockReturnValueOnce(cachedPr);
        const res = await ensurePr({ ...config, fetchChangeLogs: 'pr' });
        expect(res).toEqual({
          type: 'with-pr',
          pr: {
            number,
            sourceBranch,
            title: prTitle,
            bodyStruct,
            state: 'open',
            targetBranch: 'base',
          },
        });
        expect(logger.logger.debug).toHaveBeenCalledWith(
          'PR rebase requested, so skipping cache check',
        );
        expect(logger.logger.debug).not.toHaveBeenCalledWith(
          `Pull Request #${number} does not need updating`,
        );
        expect(embedChangelogs).toHaveBeenCalledTimes(1);
      });

      it('logs when cache is enabled but pr-cache is absent', async () => {
        config.repositoryCache = 'enabled';
        platform.getBranchPr.mockResolvedValue(existingPr);
        prCache.getPrCache.mockReturnValueOnce(null);
        await ensurePr(config);
        expect(logger.logger.debug).toHaveBeenCalledWith('PR cache not found');
      });

      it('does not log when cache is disabled and pr-cache is absent', async () => {
        config.repositoryCache = 'disabled';
        platform.getBranchPr.mockResolvedValue(existingPr);
        prCache.getPrCache.mockReturnValueOnce(null);
        await ensurePr(config);
        expect(logger.logger.debug).not.toHaveBeenCalledWith(
          'PR cache not found',
        );
      });
    });
  });
});
