import type { Account } from '@gitlens/git/models/author.js';
import type { DefaultBranch } from '@gitlens/git/models/defaultBranch.js';
import type { IssueOrPullRequest } from '@gitlens/git/models/issueOrPullRequest.js';
import { PullRequest } from '@gitlens/git/models/pullRequest.js';
import type { Provider } from '@gitlens/git/models/remoteProvider.js';
import type { RepositoryMetadata } from '@gitlens/git/models/repositoryMetadata.js';
import { CancellationError } from '@gitlens/utils/cancellation.js';
import { trace } from '@gitlens/utils/decorators/log.js';
import type { Disposable } from '@gitlens/utils/disposable.js';
import { Logger } from '@gitlens/utils/logger.js';
import type { ScopedLogger } from '@gitlens/utils/logger.scoped.js';
import { getScopedLogger } from '@gitlens/utils/logger.scoped.js';
import { maybeStopWatch } from '@gitlens/utils/stopwatch.js';
import { equalsIgnoreCase } from '@gitlens/utils/string.js';
import { joinUriPath, parseUri } from '@gitlens/utils/uri.js';
import type { TokenWithInfo } from '../../authentication/models.js';
import type { IntegrationServiceContext } from '../../context.js';
import {
	AuthenticationError,
	AuthenticationErrorReason,
	ProviderFetchError,
	RequestClientError,
	RequestNotFoundError,
	RequestRateLimitError,
} from '../../errors.js';
import { selectGitLabUserForCommit } from './gitlab.utils.js';
import type {
	GitLabCommit,
	GitLabIssue,
	GitLabMergeRequest,
	GitLabMergeRequestFull,
	GitLabMergeRequestREST,
	GitLabMergeRequestState,
	GitLabProjectREST,
	GitLabUser,
} from './models.js';
import { fromGitLabMergeRequest, fromGitLabMergeRequestREST, fromGitLabMergeRequestState } from './models.js';

// drop it as soon as we switch to @gitkraken/providers-api
const gitlabUserIdPrefix = 'gid://gitlab/User/';
const gitlabMergeRequestIdPrefix = 'gid://gitlab/MergeRequest/';

function buildGitLabUserId(id: string | number | undefined): string | undefined {
	return typeof id === 'string' && id?.startsWith(gitlabUserIdPrefix)
		? id.substring(gitlabUserIdPrefix.length)
		: String(id);
}

export class GitLabApi implements Disposable {
	private readonly _disposable: Disposable;
	private _projectIds = new Map<string, Promise<string | undefined>>();

	constructor(private readonly ctx: IntegrationServiceContext) {
		this._disposable = ctx.config.onDidChange(e => {
			if (e.httpProxy || e.remotes) {
				this.resetCaches();
			}
		});
	}

	dispose(): void {
		this._disposable.dispose();
	}

	private resetCaches(): void {
		this._projectIds.clear();
	}

	@trace({
		args: (provider, token, owner, repo, rev) => ({
			provider: provider.name,
			token: `<token:${token.microHash}>`,
			owner: owner,
			repo: repo,
			rev: rev,
		}),
	})
	async getAccountForCommit(
		provider: Provider,
		token: TokenWithInfo,
		owner: string,
		repo: string,
		rev: string,
		options?: {
			baseUrl?: string;
			avatarSize?: number;
		},
		cancellation?: AbortSignal,
	): Promise<Account | undefined> {
		const scope = getScopedLogger();

		const projectId = await this.getProjectId(provider, token, owner, repo, options?.baseUrl, cancellation);
		if (!projectId) return undefined;

		try {
			const commit = await this.request<GitLabCommit>(
				provider,
				token,
				options?.baseUrl,
				`v4/projects/${projectId}/repository/commits/${rev}?stats=false`,
				{
					method: 'GET',
					// ...options,
				},
				cancellation,
				scope,
			);

			let users = await this.findUser(provider, token, commit.author_name, options, cancellation);
			let user = selectGitLabUserForCommit(users, commit.author_name, commit.author_email);

			// If the name search was ambiguous (multiple users share the name), try an email-scoped search to
			// disambiguate before giving up — otherwise we'd show a stranger's avatar (see #2205)
			if (user == null && commit.author_email) {
				const exactNameMatches = users.filter(u => equalsIgnoreCase(u.name, commit.author_name)).length;
				if (exactNameMatches > 1) {
					users = await this.findUser(provider, token, commit.author_email, options, cancellation);
					user = selectGitLabUserForCommit(users, commit.author_name, commit.author_email);
				}
			}

			if (user == null) return undefined;

			// If the avatarUrl is a relative URL, make it absolute using the webUrl (assuming the webUrl is the root URL with the username tacked on)
			if (user.avatarUrl && !/^([a-zA-Z][\w+.-]+):/.test(user.avatarUrl)) {
				user.avatarUrl = joinUriPath(parseUri(user.webUrl), '..', user.avatarUrl).toString();
			}

			return {
				provider: provider,
				id: String(user.id),
				name: user.name || undefined,
				email: commit.author_email || undefined,
				avatarUrl: user.avatarUrl || undefined,
				username: user.username || undefined,
			};
		} catch (ex) {
			if (ex instanceof RequestNotFoundError) return undefined;

			throw this.handleException(ex, provider, scope);
		}
	}

	@trace({
		args: (provider, token, _owner, _repo, email) => ({
			provider: provider.name,
			token: `<token:${token.microHash}>`,
			email: email,
		}),
	})
	async getAccountForEmail(
		provider: Provider,
		token: TokenWithInfo,
		_owner: string,
		_repo: string,
		email: string,
		options?: {
			baseUrl?: string;
			avatarSize?: number;
		},
	): Promise<Account | undefined> {
		const scope = getScopedLogger();

		try {
			const [user] = await this.findUser(provider, token, email, options);
			if (user == null) return undefined;

			return {
				provider: provider,
				id: String(user.id),
				name: user.name || undefined,
				email: user.publicEmail || undefined,
				avatarUrl: user.avatarUrl || undefined,
				username: user.username || undefined,
			};
		} catch (ex) {
			if (ex instanceof RequestNotFoundError) return undefined;

			throw this.handleException(ex, provider, scope);
		}
	}

	@trace({
		args: (provider, token, owner, repo) => ({
			provider: provider.name,
			token: `<token:${token.microHash}>`,
			owner: owner,
			repo: repo,
		}),
	})
	async getDefaultBranch(
		provider: Provider,
		token: TokenWithInfo,
		owner: string,
		repo: string,
		options?: {
			baseUrl?: string;
		},
		cancellation?: AbortSignal,
	): Promise<DefaultBranch | undefined> {
		const scope = getScopedLogger();

		interface QueryResult {
			data: {
				project:
					| {
							repository: { rootRef: string } | null | undefined;
					  }
					| null
					| undefined;
			};
		}

		try {
			const query = `query getDefaultBranch(
	$fullPath: ID!
) {
	project(fullPath: $fullPath) {
		repository {
			rootRef
		}
}`;

			const rsp = await this.graphql<QueryResult>(
				provider,
				token,
				options?.baseUrl,
				query,
				{
					fullPath: `${owner}/${repo}`,
				},
				cancellation,
				scope,
			);

			const defaultBranch = rsp?.data?.project?.repository?.rootRef ?? undefined;
			if (defaultBranch == null) return undefined;

			return {
				provider: provider,
				name: defaultBranch,
			};
		} catch (ex) {
			if (ex instanceof RequestNotFoundError) return undefined;

			throw this.handleException(ex, provider, scope);
		}
	}

	@trace({
		args: (provider, token, owner, repo, number) => ({
			provider: provider.name,
			token: `<token:${token.microHash}>`,
			owner: owner,
			repo: repo,
			number: number,
		}),
	})
	async getIssueOrPullRequest(
		provider: Provider,
		token: TokenWithInfo,
		owner: string,
		repo: string,
		number: number,
		options?: {
			baseUrl?: string;
		},
		cancellation?: AbortSignal,
	): Promise<IssueOrPullRequest | undefined> {
		const scope = getScopedLogger();

		interface QueryResult {
			data: {
				project: {
					mergeRequest: GitLabMergeRequest | null;
					issue: GitLabIssue | null;
				} | null;
			};
		}

		try {
			const query = `query getIssueOrMergeRequest(
	$fullPath: ID!
	$iid: String!
) {
	project(fullPath: $fullPath) {
		mergeRequest(iid: $iid) {
			author {
				id
				name
				avatarUrl
				webUrl
			}
			iid
			title
			description
			state
			createdAt
			updatedAt
			mergedAt
			webUrl
		}
		issue(iid: $iid) {
			author {
				id
				name
				avatarUrl
				webUrl
			}
			iid
			title
			description
			state
			createdAt
			updatedAt
			closedAt
			webUrl
		}
	}
}`;

			const rsp = await this.graphql<QueryResult>(
				provider,
				token,
				options?.baseUrl,
				query,
				{
					fullPath: `${owner}/${repo}`,
					iid: String(number),
				},
				cancellation,
				scope,
			);

			if (rsp?.data?.project?.issue != null) {
				const issue = rsp.data.project.issue;
				return {
					provider: provider,
					type: 'issue',
					id: issue.iid,
					nodeId: undefined,
					createdDate: new Date(issue.createdAt),
					updatedDate: new Date(issue.updatedAt),
					title: issue.title,
					closed: issue.state === 'closed',
					closedDate: issue.closedAt == null ? undefined : new Date(issue.closedAt),
					url: issue.webUrl,
					state: issue.state === 'locked' ? 'closed' : issue.state,
				};
			}

			if (rsp?.data?.project?.mergeRequest != null) {
				const mergeRequest = rsp.data.project.mergeRequest;
				return {
					provider: provider,
					type: 'pullrequest',
					id: mergeRequest.iid,
					nodeId: undefined,
					createdDate: new Date(mergeRequest.createdAt),
					updatedDate: new Date(mergeRequest.updatedAt),
					title: mergeRequest.title,
					closed: mergeRequest.state === 'closed',
					// TODO@eamodio this isn't right, but GitLab doesn't seem to provide a closedAt on merge requests in GraphQL
					closedDate: mergeRequest.state === 'closed' ? new Date(mergeRequest.updatedAt) : undefined,
					url: mergeRequest.webUrl,
					state: mergeRequest.state === 'locked' ? 'closed' : mergeRequest.state,
				};
			}

			return undefined;
		} catch (ex) {
			if (ex instanceof RequestNotFoundError) return undefined;

			throw this.handleException(ex, provider, scope);
		}
	}

	@trace({
		args: (provider, token, owner, repo, branch) => ({
			provider: provider.name,
			token: `<token:${token.microHash}>`,
			owner: owner,
			repo: repo,
			branch: branch,
		}),
	})
	async getPullRequestForBranch(
		provider: Provider,
		token: TokenWithInfo,
		owner: string,
		repo: string,
		branch: string,
		options?: {
			baseUrl?: string;
			avatarSize?: number;
			include?: GitLabMergeRequestState[];
		},
		cancellation?: AbortSignal,
	): Promise<PullRequest | undefined> {
		const scope = getScopedLogger();

		interface QueryResult {
			data: {
				project: {
					mergeRequests?: {
						nodes: GitLabMergeRequest[];
					} | null;
					opened?: {
						nodes: GitLabMergeRequest[];
					} | null;
					closed?: {
						nodes: GitLabMergeRequest[];
					} | null;
					merged?: {
						nodes: GitLabMergeRequest[];
					} | null;
				} | null;
			};
		}

		try {
			const fragment = `
			nodes {
				iid
				author {
					id
					name
					avatarUrl
					webUrl
				}
				title
				description
				state
				createdAt
				updatedAt
				mergedAt
				webUrl
			}`;

			const query = `query getMergeRequestForBranch(
	$fullPath: ID!
	$branches: [String!]
) {
	project(fullPath: $fullPath) {
		${
			options?.include == null
				? `mergeRequests(sourceBranches: $branches sort: UPDATED_DESC first: 1) {
			${fragment}
		}`
				: ''
		}
		${
			options?.include?.includes('opened')
				? `opened: mergeRequests(sourceBranches: $branches state: opened sort: UPDATED_DESC first: 1) {
			${fragment}
		}`
				: ''
		}
		${
			options?.include?.includes('merged')
				? `merged: mergeRequests(sourceBranches: $branches state: merged sort: UPDATED_DESC first: 1) {
			${fragment}
		}`
				: ''
		}
		${
			options?.include?.includes('closed')
				? `closed: mergeRequests(sourceBranches: $branches state: closed sort: UPDATED_DESC first: 1) {
			${fragment}
		}`
				: ''
		}
	}
}`;

			const rsp = await this.graphql<QueryResult>(
				provider,
				token,
				options?.baseUrl,
				query,
				{
					fullPath: `${owner}/${repo}`,
					branches: [branch],
					state: options?.include,
				},
				cancellation,
				scope,
			);

			let pr: GitLabMergeRequest | undefined;

			if (options?.include == null) {
				pr = rsp?.data?.project?.mergeRequests?.nodes?.[0];
			} else {
				for (const state of options.include) {
					let mr;
					if (state === 'opened') {
						mr = rsp?.data?.project?.opened?.nodes?.[0];
					} else if (state === 'merged') {
						mr = rsp?.data?.project?.merged?.nodes?.[0];
					} else if (state === 'closed') {
						mr = rsp?.data?.project?.closed?.nodes?.[0];
					}

					if (mr != null && (pr == null || new Date(mr.updatedAt) > new Date(pr.updatedAt))) {
						pr = mr;
					}
				}
			}

			if (pr == null) return undefined;

			return new PullRequest(
				provider,
				{
					id: buildGitLabUserId(pr.author?.id) ?? '',
					name: pr.author?.name ?? 'Unknown',
					avatarUrl: pr.author?.avatarUrl ?? '',
					url: pr.author?.webUrl ?? '',
				},
				// oxlint-disable-next-line typescript/no-unnecessary-type-conversion
				String(pr.iid),
				undefined,
				pr.title,
				pr.webUrl,
				{ owner: owner, repo: repo },
				fromGitLabMergeRequestState(pr.state),
				new Date(pr.createdAt),
				new Date(pr.updatedAt),
				// TODO@eamodio this isn't right, but GitLab doesn't seem to provide a closedAt on merge requests in GraphQL
				pr.state !== 'closed' ? undefined : new Date(pr.updatedAt),
				pr.mergedAt == null ? undefined : new Date(pr.mergedAt),
			);
		} catch (ex) {
			if (ex instanceof RequestNotFoundError) return undefined;

			throw this.handleException(ex, provider, scope);
		}
	}

	@trace({
		args: (provider, token, owner, repo, rev) => ({
			provider: provider.name,
			token: `<token:${token.microHash}>`,
			owner: owner,
			repo: repo,
			rev: rev,
		}),
	})
	async getPullRequestForCommit(
		provider: Provider,
		token: TokenWithInfo,
		owner: string,
		repo: string,
		rev: string,
		options?: {
			baseUrl?: string;
			avatarSize?: number;
		},
		cancellation?: AbortSignal,
	): Promise<PullRequest | undefined> {
		const scope = getScopedLogger();

		const projectId = await this.getProjectId(provider, token, owner, repo, options?.baseUrl, cancellation);
		if (!projectId) return undefined;

		try {
			const mrs = await this.request<GitLabMergeRequestREST[]>(
				provider,
				token,
				options?.baseUrl,
				`v4/projects/${projectId}/repository/commits/${rev}/merge_requests`,
				{
					method: 'GET',
					// ...options,
				},
				cancellation,
				scope,
			);
			if (mrs == null || mrs.length === 0) return undefined;

			if (mrs.length > 1) {
				mrs.sort(
					(a, b) =>
						(a.state === 'opened' ? -1 : 1) - (b.state === 'opened' ? -1 : 1) ||
						new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
				);
			}

			return fromGitLabMergeRequestREST(mrs[0], provider, { owner: owner, repo: repo });
		} catch (ex) {
			if (ex instanceof RequestNotFoundError) return undefined;

			throw this.handleException(ex, provider, scope);
		}
	}

	@trace({
		args: (provider, token, owner, repo, id) => ({
			provider: provider.name,
			token: `<token:${token.microHash}>`,
			owner: owner,
			repo: repo,
			id: id,
		}),
	})
	async getPullRequest(
		provider: Provider,
		token: TokenWithInfo,
		owner: string,
		repo: string,
		id: number,
		options?: {
			baseUrl?: string;
		},
		cancellation?: AbortSignal,
	): Promise<PullRequest | undefined> {
		const scope = getScopedLogger();

		interface QueryResult {
			data: {
				project: {
					mergeRequest: GitLabMergeRequestFull | null;
				} | null;
			};
		}

		try {
			const query = `query getMergeRequest(
	$fullPath: ID!
	$iid: String!
) {
	project(fullPath: $fullPath) {
		mergeRequest(iid: $iid) {
			id,
			iid
			state,
			author {
				id
				name
				avatarUrl
				webUrl
			}
			diffRefs {
				baseSha
				headSha
			}
			title
			description
			webUrl
			createdAt
			updatedAt
			mergedAt
			targetBranch
			sourceBranch
			project {
				id
				fullPath
				webUrl
			}
			sourceProject {
				id
				fullPath
				webUrl
			}
		}
	}
}`;

			const rsp = await this.graphql<QueryResult>(
				provider,
				token,
				options?.baseUrl,
				query,
				{
					fullPath: `${owner}/${repo}`,
					iid: String(id),
				},
				cancellation,
				scope,
			);

			if (rsp?.data?.project?.mergeRequest == null) return undefined;

			const pr = rsp.data.project.mergeRequest;
			return fromGitLabMergeRequest(pr, provider);
		} catch (ex) {
			if (ex instanceof RequestNotFoundError) return undefined;

			throw this.handleException(ex, provider, scope);
		}
	}

	@trace({
		args: (provider, token, owner, repo) => ({
			provider: provider.name,
			token: `<token:${token.microHash}>`,
			owner: owner,
			repo: repo,
		}),
	})
	async getRepositoryMetadata(
		provider: Provider,
		token: TokenWithInfo,
		owner: string,
		repo: string,
		options?: {
			baseUrl?: string;
		},
		cancellation?: AbortSignal,
	): Promise<RepositoryMetadata | undefined> {
		const scope = getScopedLogger();

		const projectId = await this.getProjectId(provider, token, owner, repo, options?.baseUrl, cancellation);
		if (!projectId) return undefined;

		try {
			const proj = await this.request<GitLabProjectREST>(
				provider,
				token,
				options?.baseUrl,
				`v4/projects/${projectId}`,
				{
					method: 'GET',
					// ...options,
				},
				cancellation,
				scope,
			);
			if (proj == null) return undefined;

			return {
				provider: provider,
				owner: proj.namespace.full_path,
				name: proj.path,
				isFork: proj.forked_from_project != null,
				parent:
					proj.forked_from_project != null
						? {
								owner: proj.forked_from_project.namespace.full_path,
								name: proj.forked_from_project.path,
							}
						: undefined,
			} satisfies RepositoryMetadata;
		} catch (ex) {
			if (ex instanceof RequestNotFoundError) return undefined;

			throw this.handleException(ex, provider, scope);
		}
	}

	@trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
	async searchPullRequests(
		provider: Provider,
		token: TokenWithInfo,
		options?: { search?: string; user?: string; repos?: string[]; baseUrl?: string; avatarSize?: number },
		cancellation?: AbortSignal,
	): Promise<PullRequest[]> {
		const scope = getScopedLogger();
		const search = options?.search;
		if (!search) {
			return [];
		}

		try {
			const perPageLimit = 20; // with bigger amount we exceed the max GraphQL complexity in the next query
			const restPRs = await this.request<GitLabMergeRequestREST[]>(
				provider,
				token,
				options?.baseUrl,
				`v4/search/?scope=merge_requests&search=${search}&per_page=${perPageLimit}`,
				{
					method: 'GET',
				},
				cancellation,
				scope,
			);
			if (restPRs.length === 0) {
				return [];
			}

			interface QueryResult {
				data: Record<`mergeRequest_${number}`, GitLabMergeRequestFull>;
			}

			const queryArgs = restPRs.map((_, i) => `$id_${i}: MergeRequestID!`).join('\n');
			const queryFields = restPRs
				.map((_, i) => `mergeRequest_${i}: mergeRequest(id: $id_${i}) { ...mergeRequestFields }`)
				.join('\n');
			// Set of fields includes only additional fields that are not included in GitLabMergeRequestREST.
			// It's limited as much as possible to reduce complexity of the query.
			const queryMrFields = `fragment mergeRequestFields on MergeRequest {
				diffRefs {
					baseSha
					headSha
				}
				project {
					id
					fullPath
					webUrl
				}
				sourceProject {
					id
					fullPath
					webUrl
				}
			}`;
			const query = `query getMergeRequests (${queryArgs}) {${queryFields}} ${queryMrFields}`;

			const params = restPRs.reduce<Record<`id_${number}`, string>>((ids, gitlabRestPr, i) => {
				ids[`id_${i}`] = `${gitlabMergeRequestIdPrefix}${gitlabRestPr.id}`;
				return ids;
			}, {});
			const rsp = await this.graphql<QueryResult>(
				provider,
				token,
				options?.baseUrl,
				query,
				params,
				cancellation,
				scope,
			);
			if (rsp?.data != null) {
				const resultPRs = restPRs.reduce<PullRequest[]>((accum, restPR, i) => {
					const graphQlPR = rsp.data[`mergeRequest_${i}`];
					if (graphQlPR == null) {
						return accum;
					}

					const fullPr: GitLabMergeRequestFull = {
						...graphQlPR,
						iid: String(restPR.iid),
						id: String(restPR.id),
						state: restPR.state,
						author: {
							id: buildGitLabUserId(restPR.author?.id) ?? '',
							name: restPR.author?.name ?? 'Unknown',
							avatarUrl: restPR.author?.avatar_url ?? '',
							webUrl: restPR.author?.web_url ?? '',
						},
						title: restPR.title,
						description: restPR.description,
						webUrl: restPR.web_url,
						createdAt: restPR.created_at,
						updatedAt: restPR.updated_at,
						mergedAt: restPR.merged_at,
						sourceBranch: restPR.source_branch,
						targetBranch: restPR.target_branch,
					};
					accum.push(fromGitLabMergeRequest(fullPr, provider));
					return accum;
				}, []);
				return resultPRs;
			}
			return [];
		} catch (ex) {
			if (ex instanceof RequestNotFoundError) return [];

			throw this.handleException(ex, provider, scope);
		}
	}

	private async findUser(
		provider: Provider,
		token: TokenWithInfo,
		search: string,
		options?: {
			baseUrl?: string;
			avatarSize?: number;
		},
		cancellation?: AbortSignal,
	): Promise<GitLabUser[]> {
		const scope = getScopedLogger();

		interface QueryResult {
			data: {
				users: {
					nodes: {
						id: string;
						name: string;
						username: string;
						publicEmail?: string;
						state: string;
						avatarUrl: string;
						webUrl: string;
					}[];
				};
			};
		}

		try {
			const query = `query findUser(
$search: String!
) {
	users(search: $search) {
		nodes {
			id
			name
			username,
			publicEmail,
			state
			avatarUrl
			webUrl
		}
	}
}`;
			const rsp = await this.graphql<QueryResult>(
				provider,
				token,
				options?.baseUrl,
				query,
				{
					search: search,
				},
				cancellation,
				scope,
			);

			const matches = rsp?.data?.users?.nodes;
			if (matches == null || matches.length === 0) return [];

			const users: GitLabUser[] = [];

			for (const user of matches) {
				const match = /gid:\/\/gitlab\/User\/([0-9]+)\b/.exec(user.id);
				if (match == null) continue;

				users.push({
					id: parseInt(match[1], 10),
					name: user.name,
					username: user.username,
					publicEmail: user.publicEmail || undefined,
					state: user.state,
					avatarUrl: user.avatarUrl,
					webUrl: user.webUrl,
				});
			}

			return users;
		} catch (ex) {
			if (ex instanceof RequestNotFoundError) return [];

			this.handleException(ex, provider, scope);
			return [];
		}
	}

	getProjectId(
		provider: Provider,
		token: TokenWithInfo,
		group: string,
		repo: string,
		baseUrl: string | undefined,
		cancellation: AbortSignal | undefined,
	): Promise<string | undefined> {
		const { accessToken } = token;
		const key = `${accessToken}|${group}/${repo}`;

		let projectId = this._projectIds.get(key);
		if (projectId == null) {
			projectId = this.getProjectIdCore(provider, token, group, repo, baseUrl, cancellation);
			this._projectIds.set(key, projectId);
		}

		return projectId;
	}

	private async getProjectIdCore(
		provider: Provider,
		token: TokenWithInfo,
		group: string,
		repo: string,
		baseUrl: string | undefined,
		cancellation: AbortSignal | undefined,
	): Promise<string | undefined> {
		const scope = getScopedLogger();

		interface QueryResult {
			data: { project: { id: string } };
		}

		try {
			const query = `query getProjectId(
	$fullPath: ID!
) {
	project(fullPath: $fullPath) {
		id
	}
}`;
			const rsp = await this.graphql<QueryResult>(
				provider,
				token,
				baseUrl,
				query,
				{
					fullPath: `${group}/${repo}`,
				},
				cancellation,
				scope,
			);

			const gid = rsp?.data?.project?.id;
			if (gid == null) return undefined;

			const match = /gid:\/\/gitlab\/Project\/([0-9]+)\b/.exec(gid);
			if (match == null) return undefined;

			const projectId = match[1];

			scope?.addExitInfo(`projectId=${projectId}`);
			return projectId;
		} catch (ex) {
			if (ex instanceof RequestNotFoundError) return undefined;

			this.handleException(ex, provider, scope);
			return undefined;
		}
	}

	private async graphql<T extends object>(
		provider: Provider,
		token: TokenWithInfo,
		baseUrl: string | undefined,
		query: string,
		variables: Record<string, any>,
		cancellation: AbortSignal | undefined,
		scope: ScopedLogger | undefined,
	): Promise<T | undefined> {
		const { accessToken } = token;
		let rsp: Response;
		try {
			const sw = maybeStopWatch(`[GITLAB] POST ${baseUrl}`, { log: { onlyExit: true } });

			try {
				if (cancellation?.aborted) throw new CancellationError();

				rsp = await this.ctx.http.wrapForForcedInsecureSSL(provider.getIgnoreSSLErrors(), () =>
					this.ctx.http.fetch(`${baseUrl ?? 'https://gitlab.com/api'}/graphql`, {
						method: 'POST',
						headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
						signal: cancellation,
						body: JSON.stringify({ query: query, variables: variables }),
					}),
				);

				if (rsp.ok) {
					const data = (await rsp.json()) as T | { errors: { message: string }[] };

					if ('errors' in data) throw new ProviderFetchError('GitLab', rsp, data.errors);
					return data;
				}

				throw new ProviderFetchError('GitLab', rsp);
			} finally {
				const match = /(^[^({\n]+)/.exec(query);
				const message = ` ${match?.[1].trim() ?? query}`;

				sw?.stop({ message: message });
			}
		} catch (ex) {
			if (ex instanceof ProviderFetchError || ex.name === 'AbortError') {
				this.handleRequestError(provider, token, ex, scope);
			} else if (Logger.isDebugging) {
				this.ctx.hooks?.ui?.onError?.(`GitLab request failed: ${ex.message}`);
			}

			throw ex;
		}
	}

	private async request<T>(
		provider: Provider,
		token: TokenWithInfo,
		baseUrl: string | undefined,
		route: string,
		options: { method: RequestInit['method'] } & Record<string, unknown>,
		cancellation: AbortSignal | undefined,
		scope: ScopedLogger | undefined,
	): Promise<T> {
		const { accessToken } = token;
		const url = `${baseUrl ?? 'https://gitlab.com/api'}/${route}`;

		let rsp: Response;
		try {
			const sw = maybeStopWatch(`[GITLAB] ${options?.method ?? 'GET'} ${url}`, { log: { onlyExit: true } });

			try {
				if (cancellation?.aborted) throw new CancellationError();

				rsp = await this.ctx.http.wrapForForcedInsecureSSL(provider.getIgnoreSSLErrors(), () =>
					this.ctx.http.fetch(url, {
						headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
						signal: cancellation,
						...options,
					}),
				);

				if (rsp.ok) {
					return (await rsp.json()) as T;
				}

				throw new ProviderFetchError('GitLab', rsp);
			} finally {
				sw?.stop();
			}
		} catch (ex) {
			if (ex instanceof ProviderFetchError || ex.name === 'AbortError') {
				this.handleRequestError(provider, token, ex, scope);
			} else if (Logger.isDebugging) {
				this.ctx.hooks?.ui?.onError?.(`GitLab request failed: ${ex.message}`);
			}

			throw ex;
		}
	}

	private handleRequestError(
		provider: Provider | undefined,
		token: TokenWithInfo,
		ex: ProviderFetchError | (Error & { name: 'AbortError' }),
		scope: ScopedLogger | undefined,
	): void {
		if (ex.name === 'AbortError' || !(ex instanceof ProviderFetchError)) throw new CancellationError(ex);

		const { accessToken, ...tokenInfo } = token;
		switch (ex.status) {
			case 404: // Not found
			case 410: // Gone
			case 422: // Unprocessable Entity
				throw new RequestNotFoundError(ex);
			// case 429: //Too Many Requests
			case 401: // Unauthorized
				throw new AuthenticationError(tokenInfo, AuthenticationErrorReason.Unauthorized, ex);
			case 403: // Forbidden
				if (ex.message.includes('rate limit exceeded')) {
					let resetAt: number | undefined;

					const reset = ex.response?.headers?.get('x-ratelimit-reset');
					if (reset != null) {
						resetAt = parseInt(reset, 10);
						if (Number.isNaN(resetAt)) {
							resetAt = undefined;
						}
					}

					throw new RequestRateLimitError(ex, accessToken, resetAt);
				}
				throw new AuthenticationError(tokenInfo, AuthenticationErrorReason.Forbidden, ex);
			case 500: // Internal Server Error
				scope?.error(ex);
				if (ex.response != null) {
					provider?.trackRequestException();
					this.ctx.hooks?.ui?.onRequestFailed?.(
						`${provider?.name ?? 'GitLab'} failed to respond and might be experiencing issues.${
							provider == null || provider.id === 'gitlab'
								? ' Please visit the [GitLab status page](https://status.gitlab.com) for more information.'
								: ''
						}`,
					);
				}
				return;
			case 502: // Bad Gateway
				scope?.error(ex);
				// GitHub seems to return this status code for timeouts
				if (ex.message.includes('timeout')) {
					provider?.trackRequestException();
					this.ctx.hooks?.ui?.onRequestTimedOut?.(provider?.name ?? 'GitLab');
					return;
				}
				break;
			default:
				if (ex.status >= 400 && ex.status < 500) throw new RequestClientError(ex);
				break;
		}

		scope?.error(ex);
		if (Logger.isDebugging) {
			this.ctx.hooks?.ui?.onError?.(
				`GitLab request failed: ${(ex.response as any)?.errors?.[0]?.message ?? ex.message}`,
			);
		}
	}

	private handleException(ex: Error, provider: Provider, scope: ScopedLogger | undefined): Error {
		scope?.error(ex);
		// debugger;

		if (ex instanceof AuthenticationError) {
			void this.showAuthenticationErrorMessage(ex, provider);
		}
		return ex;
	}

	private async showAuthenticationErrorMessage(ex: AuthenticationError, provider: Provider) {
		if (ex.reason === AuthenticationErrorReason.Unauthorized || ex.reason === AuthenticationErrorReason.Forbidden) {
			const reauthenticate = await this.ctx.hooks?.onReauthenticationRequired?.(
				`${ex.message}. Would you like to try reauthenticating${
					ex.reason === AuthenticationErrorReason.Forbidden ? ' to provide additional access' : ''
				}?`,
			);

			if (reauthenticate) {
				await provider.reauthenticate();
				this.resetCaches();
			}
		} else {
			this.ctx.hooks?.ui?.onError?.(ex.message);
		}
	}
}
