import is from '@sindresorhus/is';
import semver from 'semver';
import {
  REPOSITORY_ACCESS_FORBIDDEN,
  REPOSITORY_ARCHIVED,
  REPOSITORY_BLOCKED,
  REPOSITORY_CHANGED,
  REPOSITORY_EMPTY,
  REPOSITORY_MIRRORED,
} from '../../../constants/error-messages';
import { logger } from '../../../logger';
import type { BranchStatus } from '../../../types';
import { deduplicateArray } from '../../../util/array';
import { parseJson } from '../../../util/common';
import { getEnv } from '../../../util/env';
import * as git from '../../../util/git';
import { setBaseUrl } from '../../../util/http/gitea';
import { map } from '../../../util/promises';
import { sanitize } from '../../../util/sanitize';
import { ensureTrailingSlash } from '../../../util/url';
import { getPrBodyStruct, hashBody } from '../pr-body';
import type {
  AutodiscoverConfig,
  BranchStatusConfig,
  CreatePRConfig,
  EnsureCommentConfig,
  EnsureCommentRemovalConfig,
  EnsureIssueConfig,
  FindPRConfig,
  Issue,
  MergePRConfig,
  Platform,
  PlatformParams,
  PlatformResult,
  Pr,
  RepoParams,
  RepoResult,
  RepoSortMethod,
  SortMethod,
  UpdatePrConfig,
} from '../types';
import { repoFingerprint } from '../util';
import { smartTruncate } from '../utils/pr-body';
import * as helper from './gitea-helper';
import { giteaHttp } from './gitea-helper';
import { GiteaPrCache } from './pr-cache';
import type {
  CombinedCommitStatus,
  Comment,
  IssueState,
  Label,
  PRMergeMethod,
  PRUpdateParams,
  Repo,
} from './types';
import {
  DRAFT_PREFIX,
  getMergeMethod,
  getRepoUrl,
  smartLinks,
  toRenovatePR,
  trimTrailingApiPath,
  usableRepo,
} from './utils';

interface GiteaRepoConfig {
  ignorePrAuthor: boolean;
  repository: string;
  mergeMethod: PRMergeMethod;

  issueList: Promise<Issue[]> | null;
  labelList: Promise<Label[]> | null;
  defaultBranch: string;
  cloneSubmodules: boolean;
  cloneSubmodulesFilter: string[] | undefined;
  hasIssuesEnabled: boolean;
}

export const id = 'gitea';

const defaults = {
  hostType: 'gitea',
  endpoint: 'https://gitea.com/',
  version: '0.0.0',
  isForgejo: false,
};

let config: GiteaRepoConfig = {} as any;
let botUserID: number;
let botUserName: string;

export function resetPlatform(): void {
  config = {} as any;
  botUserID = undefined as never;
  botUserName = undefined as never;
  defaults.hostType = 'gitea';
  defaults.endpoint = 'https://gitea.com/';
  defaults.version = '0.0.0';
  defaults.isForgejo = false;
  setBaseUrl(defaults.endpoint);
}

function toRenovateIssue(data: Issue): Issue {
  return {
    number: data.number,
    state: data.state,
    title: data.title,
    body: data.body,
  };
}

function matchesState(actual: string, expected: string): boolean {
  if (expected === 'all') {
    return true;
  }
  if (expected.startsWith('!')) {
    return actual !== expected.substring(1);
  }

  return actual === expected;
}

function findCommentByTopic(
  comments: Comment[],
  topic: string,
): Comment | null {
  return comments.find((c) => c.body.startsWith(`### ${topic}\n\n`)) ?? null;
}

function findCommentByContent(
  comments: Comment[],
  content: string,
): Comment | null {
  return comments.find((c) => c.body.trim() === content) ?? null;
}

function getLabelList(): Promise<Label[]> {
  if (config.labelList === null) {
    const repoLabels = helper
      .getRepoLabels(config.repository, {
        memCache: false,
      })
      .then((labels) => {
        logger.debug(`Retrieved ${labels.length} repo labels`);
        return labels;
      });

    const orgLabels = helper
      .getOrgLabels(config.repository.split('/')[0], {
        memCache: false,
      })
      .then((labels) => {
        logger.debug(`Retrieved ${labels.length} org labels`);
        return labels;
      })
      .catch((err) => {
        // Will fail if owner of repo is not org or Gitea version < 1.12
        logger.debug(`Unable to fetch organization labels`);
        return [] as Label[];
      });

    config.labelList = Promise.all([repoLabels, orgLabels]).then((labels) =>
      ([] as Label[]).concat(...labels),
    );
  }

  return config.labelList;
}

async function lookupLabelByName(name: string): Promise<number | null> {
  logger.debug(`lookupLabelByName(${name})`);
  const labelList = await getLabelList();
  return labelList.find((l) => l.name === name)?.id ?? null;
}

interface FetchRepositoriesArgs {
  topic?: string;
  sort?: RepoSortMethod;
  order?: SortMethod;
}

async function fetchRepositories({
  topic,
  sort,
  order,
}: FetchRepositoriesArgs): Promise<string[]> {
  const repos = await helper.searchRepos({
    uid: botUserID,
    archived: false,
    ...(topic && {
      topic: true,
      q: topic,
    }),
    ...(sort && {
      sort,
    }),
    ...(order && {
      order,
    }),
  });
  return repos.filter(usableRepo).map((r) => r.full_name);
}

const platform: Platform = {
  async initPlatform({
    endpoint,
    token,
  }: PlatformParams): Promise<PlatformResult> {
    if (!token) {
      throw new Error('Init: You must configure a Gitea personal access token');
    }

    if (endpoint) {
      let baseEndpoint = trimTrailingApiPath(endpoint);
      baseEndpoint = ensureTrailingSlash(baseEndpoint);
      defaults.endpoint = baseEndpoint;
    } else {
      logger.debug('Using default Gitea endpoint: ' + defaults.endpoint);
    }
    setBaseUrl(defaults.endpoint);

    let gitAuthor: string;
    try {
      const user = await helper.getCurrentUser({ token });
      gitAuthor = `${user.full_name ?? user.username} <${user.email}>`;
      botUserID = user.id;
      botUserName = user.username;
      const env = getEnv();
      /* v8 ignore start: experimental feature */
      if (semver.valid(env.RENOVATE_X_PLATFORM_VERSION)) {
        defaults.version = env.RENOVATE_X_PLATFORM_VERSION!;
      } /* v8 ignore stop */ else {
        defaults.version = await helper.getVersion({ token });
      }
      if (defaults.version?.includes('gitea-')) {
        defaults.isForgejo = true;
        logger.info(
          `Detected Forgejo instance, please use 'forgejo' platform instead`,
        );
      }
      logger.debug(
        `${defaults.isForgejo ? 'Forgejo' : 'Gitea'} version: ${defaults.version}`,
      );
    } catch (err) {
      logger.debug(
        { err },
        'Error authenticating with Gitea. Check your token',
      );
      throw new Error('Init: Authentication failure');
    }

    return {
      endpoint: defaults.endpoint,
      gitAuthor,
    };
  },

  async getRawFile(
    fileName: string,
    repoName?: string,
    branchOrTag?: string,
  ): Promise<string | null> {
    const repo = repoName ?? config.repository;
    const contents = await helper.getRepoContents(repo, fileName, branchOrTag);
    return contents.contentString ?? null;
  },

  async getJsonFile(
    fileName: string,
    repoName?: string,
    branchOrTag?: string,
  ): Promise<any> {
    // TODO #22198
    const raw = await platform.getRawFile(fileName, repoName, branchOrTag);
    return parseJson(raw, fileName);
  },

  async initRepo({
    repository,
    cloneSubmodules,
    cloneSubmodulesFilter,
    gitUrl,
    ignorePrAuthor,
  }: RepoParams): Promise<RepoResult> {
    let repo: Repo;

    config = {} as any;
    config.repository = repository;
    config.cloneSubmodules = !!cloneSubmodules;
    config.cloneSubmodulesFilter = cloneSubmodulesFilter;
    config.ignorePrAuthor = !!ignorePrAuthor;

    // Try to fetch information about repository
    try {
      repo = await helper.getRepo(repository);
    } catch (err) {
      logger.debug({ err }, 'Unknown Gitea initRepo error');
      throw err;
    }

    // Ensure appropriate repository state and permissions
    if (repo.archived) {
      logger.debug('Repository is archived - aborting renovation');
      throw new Error(REPOSITORY_ARCHIVED);
    }
    if (repo.mirror) {
      logger.debug('Repository is a mirror - aborting renovation');
      throw new Error(REPOSITORY_MIRRORED);
    }
    if (repo.permissions.pull === false || repo.permissions.push === false) {
      logger.debug(
        'Repository does not permit pull or push - aborting renovation',
      );
      throw new Error(REPOSITORY_ACCESS_FORBIDDEN);
    }
    if (repo.empty) {
      logger.debug('Repository is empty - aborting renovation');
      throw new Error(REPOSITORY_EMPTY);
    }

    if (repo.has_pull_requests === false) {
      logger.debug('Repo has disabled pull requests - aborting renovation');
      throw new Error(REPOSITORY_BLOCKED);
    }

    if (repo.allow_rebase && repo.default_merge_style === 'rebase') {
      config.mergeMethod = 'rebase';
    } else if (
      repo.allow_rebase_explicit &&
      repo.default_merge_style === 'rebase-merge'
    ) {
      config.mergeMethod = 'rebase-merge';
    } else if (
      repo.allow_squash_merge &&
      repo.default_merge_style === 'squash'
    ) {
      config.mergeMethod = 'squash';
    } else if (
      repo.allow_merge_commits &&
      repo.default_merge_style === 'merge'
    ) {
      config.mergeMethod = 'merge';
    } else if (
      repo.allow_fast_forward_only_merge &&
      repo.default_merge_style === 'fast-forward-only'
    ) {
      config.mergeMethod = 'fast-forward-only';
    } else {
      logger.debug(
        'Repository has no allowed merge methods - aborting renovation',
      );
      throw new Error(REPOSITORY_BLOCKED);
    }

    // Determine author email and branches
    config.defaultBranch = repo.default_branch;
    logger.debug(`${repository} default branch = ${config.defaultBranch}`);

    const url = getRepoUrl(repo, gitUrl, defaults.endpoint);

    // Initialize Git storage
    await git.initRepo({
      ...config,
      url,
    });

    // Reset cached resources
    config.issueList = null;
    config.labelList = null;
    config.hasIssuesEnabled = !repo.external_tracker && repo.has_issues;

    return {
      defaultBranch: config.defaultBranch,
      isFork: !!repo.fork,
      repoFingerprint: repoFingerprint(repo.id, defaults.endpoint),
    };
  },

  async getRepos(config?: AutodiscoverConfig): Promise<string[]> {
    logger.debug('Auto-discovering Gitea repositories');
    try {
      if (config?.topics) {
        logger.debug({ topics: config.topics }, 'Auto-discovering by topics');
        const fetchRepoArgs: FetchRepositoriesArgs[] = config.topics.map(
          (topic) => {
            return {
              topic,
              sort: config.sort,
              order: config.order,
            };
          },
        );
        const repos = await map(fetchRepoArgs, fetchRepositories);
        return deduplicateArray(repos.flat());
      } else if (config?.namespaces) {
        logger.debug(
          { namespaces: config.namespaces },
          'Auto-discovering by organization',
        );
        const repos = await map(
          config.namespaces,
          async (organization: string) => {
            const orgRepos = await helper.orgListRepos(organization);
            return orgRepos
              .filter((r) => !r.mirror && !r.archived)
              .map((r) => r.full_name);
          },
        );
        return deduplicateArray(repos.flat());
      } else {
        return await fetchRepositories({
          sort: config?.sort,
          order: config?.order,
        });
      }
    } catch (err) {
      logger.error({ err }, 'Gitea getRepos() error');
      throw err;
    }
  },

  async setBranchStatus({
    branchName,
    context,
    description,
    state,
    url: target_url,
  }: BranchStatusConfig): Promise<void> {
    try {
      // Create new status for branch commit
      const branchCommit = git.getBranchCommit(branchName);
      // TODO: check branchCommit

      await helper.createCommitStatus(config.repository, branchCommit!, {
        state: helper.renovateToGiteaStatusMapping[state] || 'pending',
        context,
        description,
        ...(target_url && { target_url }),
      });

      // Refresh caches by re-fetching commit status for branch
      await helper.getCombinedCommitStatus(config.repository, branchName, {
        memCache: false,
      });
    } catch (err) {
      logger.warn({ err }, 'Failed to set branch status');
    }
  },

  async getBranchStatus(
    branchName: string,
    internalChecksAsSuccess: boolean,
  ): Promise<BranchStatus> {
    let ccs: CombinedCommitStatus;
    try {
      ccs = await helper.getCombinedCommitStatus(config.repository, branchName);
    } catch (err) {
      if (err.statusCode === 404) {
        logger.debug(
          'Received 404 when checking branch status, assuming branch deletion',
        );
        throw new Error(REPOSITORY_CHANGED);
      }

      logger.debug('Unknown error when checking branch status');
      throw err;
    }

    logger.debug({ ccs }, 'Branch status check result');
    if (
      !internalChecksAsSuccess &&
      ccs.worstStatus === 'success' &&
      ccs.statuses.every((status) => status.context?.startsWith('renovate/'))
    ) {
      logger.debug(
        'Successful checks are all internal renovate/ checks, so returning "pending" branch status',
      );
      return 'yellow';
    }

    /* v8 ignore next */
    return helper.giteaToRenovateStatusMapping[ccs.worstStatus] ?? 'yellow';
  },

  async getBranchStatusCheck(
    branchName: string,
    context: string,
  ): Promise<BranchStatus | null> {
    const ccs = await helper.getCombinedCommitStatus(
      config.repository,
      branchName,
    );
    const cs = ccs.statuses.find((s) => s.context === context);
    if (!cs) {
      return null;
    } // no status check exists
    const status = helper.giteaToRenovateStatusMapping[cs.status];
    if (status) {
      return status;
    }
    logger.warn(
      { check: cs },
      'Could not map Gitea status value to Renovate status',
    );
    return 'yellow';
  },

  getPrList(): Promise<Pr[]> {
    return GiteaPrCache.getPrs(
      giteaHttp,
      config.repository,
      config.ignorePrAuthor,
      botUserName,
    );
  },

  async getPr(number: number): Promise<Pr | null> {
    // Search for pull request in cached list or attempt to query directly
    const prList = await platform.getPrList();
    let pr = prList.find((p) => p.number === number) ?? null;
    if (pr) {
      logger.debug('Returning from cached PRs');
    } else {
      logger.debug('PR not found in cached PRs - trying to fetch directly');
      const gpr = await helper.getPR(config.repository, number);
      pr = toRenovatePR(gpr, botUserName);

      // Add pull request to cache for further lookups / queries
      if (pr) {
        await GiteaPrCache.setPr(
          giteaHttp,
          config.repository,
          config.ignorePrAuthor,
          botUserName,
          pr,
        );
      }
    }

    // Abort and return null if no match was found
    if (!pr) {
      return null;
    }

    return pr;
  },

  async findPr({
    branchName,
    prTitle: title,
    state = 'all',
    includeOtherAuthors,
    targetBranch,
  }: FindPRConfig): Promise<Pr | null> {
    logger.debug(`findPr(${branchName}, ${title!}, ${state})`);
    if (includeOtherAuthors && is.string(targetBranch)) {
      // do not use pr cache as it only fetches prs created by the bot account
      const pr = await helper.getPRByBranch(
        config.repository,
        targetBranch,
        branchName,
      );
      if (!pr) {
        return null;
      }

      return toRenovatePR(pr, null);
    }
    const prList = await platform.getPrList();
    const pr = prList.find(
      (p) =>
        p.sourceRepo === config.repository &&
        p.sourceBranch === branchName &&
        matchesState(p.state, state) &&
        (!title || p.title === title),
    );

    if (pr) {
      logger.debug(`Found PR #${pr.number}`);
    }
    return pr ?? null;
  },

  async createPr({
    sourceBranch,
    targetBranch,
    prTitle,
    prBody: rawBody,
    labels: labelNames,
    platformPrOptions,
    draftPR,
  }: CreatePRConfig): Promise<Pr> {
    let title = prTitle;
    const base = targetBranch;
    const head = sourceBranch;
    const body = sanitize(rawBody);
    if (draftPR) {
      title = DRAFT_PREFIX + title;
    }

    logger.debug(`Creating pull request: ${title} (${head} => ${base})`);
    try {
      const labels = Array.isArray(labelNames)
        ? await map(labelNames, lookupLabelByName)
        : [];
      const gpr = await helper.createPR(config.repository, {
        base,
        head,
        title,
        body,
        labels: labels.filter(is.number),
      });

      if (platformPrOptions?.usePlatformAutomerge) {
        // Only Gitea v1.24.0+ and Forgejo v10.0.0+ support delete_branch_after_merge.
        // This is required to not have undesired behavior when renovate finds existing branches on next run.
        if (
          semver.gte(defaults.version, defaults.isForgejo ? '10.0.0' : '1.24.0')
        ) {
          try {
            await helper.mergePR(config.repository, gpr.number, {
              Do:
                getMergeMethod(platformPrOptions?.automergeStrategy) ??
                config.mergeMethod,
              merge_when_checks_succeed: true,
              delete_branch_after_merge: true,
            });

            logger.debug(
              { prNumber: gpr.number },
              'Gitea-native automerge: success',
            );
          } catch (err) {
            logger.warn(
              { err, prNumber: gpr.number },
              'Gitea-native automerge: fail',
            );
          }
        } else {
          logger.debug(
            { prNumber: gpr.number },
            `Gitea-native automerge: not supported on this version of ${defaults.isForgejo ? 'Forgejo' : 'Gitea'}. Use ${defaults.isForgejo ? '10.0.0' : '1.24.0'} or newer.`,
          );
        }
      }

      const pr = toRenovatePR(gpr, botUserName);
      if (!pr) {
        throw new Error('Can not parse newly created Pull Request');
      }

      await GiteaPrCache.setPr(
        giteaHttp,
        config.repository,
        config.ignorePrAuthor,
        botUserName,
        pr,
      );
      return pr;
    } catch (err) {
      // When the user manually deletes a branch from Renovate, the PR remains but is no longer linked to any branch. In
      // the most recent versions of Gitea, the PR gets automatically closed when that happens, but older versions do
      // not handle this properly and keep the PR open. As pushing a branch with the same name resurrects the PR, this
      // would cause a HTTP 409 conflict error, which we hereby gracefully handle.
      if (err.statusCode === 409) {
        logger.warn(
          { prTitle: title, sourceBranch },
          'Attempting to gracefully recover from 409 Conflict response in createPr()',
        );

        // Refresh cached PR list and search for pull request with matching information
        GiteaPrCache.forceSync();
        const pr = await platform.findPr({
          branchName: sourceBranch,
          state: 'open',
        });

        // If a valid PR was found, return and gracefully recover from the error. Otherwise, abort and throw error.
        if (pr?.bodyStruct) {
          if (pr.title !== title || pr.bodyStruct.hash !== hashBody(body)) {
            logger.debug(
              `Recovered from 409 Conflict, but PR for ${sourceBranch} is outdated. Updating...`,
            );
            await platform.updatePr({
              number: pr.number,
              prTitle: title,
              prBody: body,
            });
            pr.title = title;
            pr.bodyStruct = getPrBodyStruct(body);
          } else {
            logger.debug(
              `Recovered from 409 Conflict and PR for ${sourceBranch} is up-to-date`,
            );
          }

          return pr;
        }
      }

      throw err;
    }
  },

  async updatePr({
    number,
    prTitle,
    prBody: body,
    labels,
    state,
    targetBranch,
  }: UpdatePrConfig): Promise<void> {
    let title = prTitle;
    if ((await getPrList()).find((pr) => pr.number === number)?.isDraft) {
      title = DRAFT_PREFIX + title;
    }

    const prUpdateParams: PRUpdateParams = {
      title,
      ...(body && { body }),
      ...(state && { state }),
    };
    if (targetBranch) {
      prUpdateParams.base = targetBranch;
    }

    /**
     * Update PR labels.
     * In the Gitea API, labels are replaced on each update if the field is present.
     * If the field is not present (i.e., undefined), labels aren't updated.
     * However, the labels array must contain label IDs instead of names,
     * so a lookup is performed to fetch the details (including the ID) of each label.
     */
    if (Array.isArray(labels)) {
      prUpdateParams.labels = (await map(labels, lookupLabelByName)).filter(
        is.number,
      );
      if (labels.length !== prUpdateParams.labels.length) {
        logger.warn(
          'Some labels could not be looked up. Renovate may halt label updates assuming changes by others.',
        );
      }
    }

    const gpr = await helper.updatePR(
      config.repository,
      number,
      prUpdateParams,
    );
    const pr = toRenovatePR(gpr, botUserName);
    if (pr) {
      await GiteaPrCache.setPr(
        giteaHttp,
        config.repository,
        config.ignorePrAuthor,
        botUserName,
        pr,
      );
    }
  },

  async mergePr({ id, strategy }: MergePRConfig): Promise<boolean> {
    try {
      await helper.mergePR(config.repository, id, {
        Do: getMergeMethod(strategy) ?? config.mergeMethod,
      });
      return true;
    } catch (err) {
      logger.warn({ err, id }, 'Merging of PR failed');
      return false;
    }
  },

  getIssueList(): Promise<Issue[]> {
    if (config.hasIssuesEnabled === false) {
      return Promise.resolve([]);
    }
    config.issueList ??= helper
      .searchIssues(config.repository, { state: 'all' }, { memCache: false })
      .then((issues) => {
        const issueList = issues.map(toRenovateIssue);
        logger.debug(`Retrieved ${issueList.length} Issues`);
        return issueList;
      });

    return config.issueList;
  },

  async getIssue(number: number, memCache = true): Promise<Issue | null> {
    if (config.hasIssuesEnabled === false) {
      return null;
    }
    try {
      const body = (
        await helper.getIssue(config.repository, number, { memCache })
      ).body;
      return {
        number,
        body,
      };
    } catch (err) /* v8 ignore start */ {
      logger.debug({ err, number }, 'Error getting issue');
      return null;
    } /* v8 ignore stop */
  },

  async findIssue(title: string): Promise<Issue | null> {
    const issueList = await platform.getIssueList();
    const issue = issueList.find(
      (i) => i.state === 'open' && i.title === title,
    );

    if (!issue) {
      return null;
    }
    // TODO: types (#22198)
    logger.debug(`Found Issue #${issue.number!}`);
    // TODO #22198
    return getIssue!(issue.number!);
  },

  async ensureIssue({
    title,
    reuseTitle,
    body: content,
    labels: labelNames,
    shouldReOpen,
    once,
  }: EnsureIssueConfig): Promise<'updated' | 'created' | null> {
    logger.debug(`ensureIssue(${title})`);
    if (config.hasIssuesEnabled === false) {
      logger.info(
        'Cannot ensure issue because issues are disabled in this repository',
      );
      return null;
    }
    try {
      const body = smartLinks(content);

      const issueList = await platform.getIssueList();
      let issues = issueList.filter((i) => i.title === title);
      if (!issues.length) {
        issues = issueList.filter((i) => i.title === reuseTitle);
      }

      const labels = Array.isArray(labelNames)
        ? (await Promise.all(labelNames.map(lookupLabelByName))).filter(
            is.number,
          )
        : undefined;

      // Update any matching issues which currently exist
      if (issues.length) {
        let activeIssue = issues.find((i) => i.state === 'open');

        // If no active issue was found, decide if it shall be skipped, re-opened or updated without state change
        if (!activeIssue) {
          if (once) {
            logger.debug('Issue already closed - skipping update');
            return null;
          }
          if (shouldReOpen) {
            logger.debug('Reopening previously closed Issue');
          }

          // Pick the last issue in the list as the active one
          activeIssue = issues[issues.length - 1];
        }

        // Close any duplicate issues
        for (const issue of issues) {
          if (issue.state === 'open' && issue.number !== activeIssue.number) {
            // TODO: types (#22198)
            logger.warn({ issueNo: issue.number! }, 'Closing duplicate issue');
            // TODO #22198
            await helper.closeIssue(config.repository, issue.number!);
          }
        }

        // Check if issue has already correct state
        if (
          activeIssue.title === title &&
          activeIssue.body === body &&
          activeIssue.state === 'open'
        ) {
          logger.debug(
            // TODO: types (#22198)
            `Issue #${activeIssue.number!} is open and up to date - nothing to do`,
          );
          return null;
        }

        // Update issue body and re-open if enabled
        // TODO: types (#22198)
        logger.debug(`Updating Issue #${activeIssue.number!}`);
        const existingIssue = await helper.updateIssue(
          config.repository,
          // TODO #22198
          activeIssue.number!,
          {
            body,
            title,
            state: shouldReOpen ? 'open' : (activeIssue.state as IssueState),
          },
        );

        // Test whether the issues need to be updated
        const existingLabelIds = (existingIssue.labels ?? []).map(
          (label) => label.id,
        );
        if (
          labels &&
          (labels.length !== existingLabelIds.length ||
            labels.filter((labelId) => !existingLabelIds.includes(labelId))
              .length !== 0)
        ) {
          await helper.updateIssueLabels(
            config.repository,
            // TODO #22198
            activeIssue.number!,
            {
              labels,
            },
          );
        }

        return 'updated';
      }

      // Create new issue and reset cache
      const issue = await helper.createIssue(config.repository, {
        body,
        title,
        labels,
      });
      logger.debug(`Created new Issue #${issue.number}`);
      config.issueList = null;

      return 'created';
    } catch (err) {
      logger.warn({ err }, 'Could not ensure issue');
    }

    return null;
  },

  async ensureIssueClosing(title: string): Promise<void> {
    logger.debug(`ensureIssueClosing(${title})`);
    if (config.hasIssuesEnabled === false) {
      return;
    }
    const issueList = await platform.getIssueList();
    for (const issue of issueList) {
      if (issue.state === 'open' && issue.title === title) {
        logger.debug(`Closing issue...issueNo: ${issue.number!}`);
        // TODO #22198
        await helper.closeIssue(config.repository, issue.number!);
      }
    }
  },

  async deleteLabel(issue: number, labelName: string): Promise<void> {
    logger.debug(`Deleting label ${labelName} from Issue #${issue}`);
    const label = await lookupLabelByName(labelName);
    if (label) {
      await helper.unassignLabel(config.repository, issue, label);
    } else {
      logger.warn({ issue, labelName }, 'Failed to lookup label for deletion');
    }
  },

  async ensureComment({
    number: issue,
    topic,
    content,
  }: EnsureCommentConfig): Promise<boolean> {
    try {
      let body = sanitize(content);
      const commentList = await helper.getComments(config.repository, issue);

      // Search comment by either topic or exact body
      let comment: Comment | null = null;
      if (topic) {
        comment = findCommentByTopic(commentList, topic);
        body = `### ${topic}\n\n${body}`;
      } else {
        comment = findCommentByContent(commentList, body);
      }

      // Create a new comment if no match has been found, otherwise update if necessary
      if (!comment) {
        comment = await helper.createComment(config.repository, issue, body);
        logger.info(
          { repository: config.repository, issue, comment: comment.id },
          'Comment added',
        );
      } else if (comment.body === body) {
        logger.debug(`Comment #${comment.id} is already up-to-date`);
      } else {
        await helper.updateComment(config.repository, comment.id, body);
        logger.debug(
          { repository: config.repository, issue, comment: comment.id },
          'Comment updated',
        );
      }

      return true;
    } catch (err) {
      logger.warn({ err, issue, subject: topic }, 'Error ensuring comment');
      return false;
    }
  },

  async ensureCommentRemoval(
    deleteConfig: EnsureCommentRemovalConfig,
  ): Promise<void> {
    const { number: issue } = deleteConfig;
    const key =
      deleteConfig.type === 'by-topic'
        ? deleteConfig.topic
        : deleteConfig.content;
    logger.debug(`Ensuring comment "${key}" in #${issue} is removed`);
    const commentList = await helper.getComments(config.repository, issue);

    let comment: Comment | null = null;
    if (deleteConfig.type === 'by-topic') {
      comment = findCommentByTopic(commentList, deleteConfig.topic);
    } else if (deleteConfig.type === 'by-content') {
      const body = sanitize(deleteConfig.content);
      comment = findCommentByContent(commentList, body);
    }

    // Abort and do nothing if no matching comment was found
    if (!comment) {
      return;
    }

    // Try to delete comment
    try {
      await helper.deleteComment(config.repository, comment.id);
    } catch (err) {
      logger.warn(
        { err, issue, config: deleteConfig },
        'Error deleting comment',
      );
    }
  },

  async getBranchPr(branchName: string): Promise<Pr | null> {
    logger.debug(`getBranchPr(${branchName})`);
    const pr = await platform.findPr({ branchName, state: 'open' });
    return pr ? platform.getPr(pr.number) : null;
  },

  async addAssignees(number: number, assignees: string[]): Promise<void> {
    logger.debug(
      `Updating assignees '${assignees?.join(', ')}' on Issue #${number}`,
    );
    await helper.updateIssue(config.repository, number, {
      assignees,
    });
  },

  async addReviewers(number: number, reviewers: string[]): Promise<void> {
    logger.debug(`Adding reviewers '${reviewers?.join(', ')}' to #${number}`);
    if (semver.lt(defaults.version, '1.14.0')) {
      logger.debug(
        { version: defaults.version },
        'Adding reviewer not yet supported.',
      );
      return;
    }
    try {
      await helper.requestPrReviewers(config.repository, number, { reviewers });
    } catch (err) {
      logger.warn({ err, number, reviewers }, 'Failed to assign reviewer');
    }
  },

  massageMarkdown(prBody: string): string {
    return smartTruncate(smartLinks(prBody), maxBodyLength());
  },

  maxBodyLength,
};

export function maxBodyLength(): number {
  return 1000000;
}

/* eslint-disable @typescript-eslint/unbound-method */
export const {
  addAssignees,
  addReviewers,
  createPr,
  deleteLabel,
  ensureComment,
  ensureCommentRemoval,
  ensureIssue,
  ensureIssueClosing,
  findIssue,
  findPr,
  getBranchPr,
  getBranchStatus,
  getBranchStatusCheck,
  getIssue,
  getRawFile,
  getJsonFile,
  getIssueList,
  getPr,
  massageMarkdown,
  getPrList,
  getRepos,
  initPlatform,
  initRepo,
  mergePr,
  setBranchStatus,
  updatePr,
} = platform;
