/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */

import { Logger } from '@n8n/backend-common';
import type {
	ClientOAuth2Options,
	ClientOAuth2RequestObject,
	ClientOAuth2Token,
	ClientOAuth2TokenData,
	OAuth2CredentialData,
} from '@n8n/client-oauth2';
import { ClientOAuth2 } from '@n8n/client-oauth2';
import { Container } from '@n8n/di';
import type { AxiosError, AxiosHeaders, AxiosRequestConfig } from 'axios';
import axios from 'axios';
import crypto, { createHmac } from 'crypto';
import type FormData from 'form-data';
import { IncomingMessage } from 'http';
import { type AgentOptions } from 'https';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import merge from 'lodash/merge';
import pick from 'lodash/pick';
import {
	NodeApiError,
	NodeOperationError,
	NodeSslError,
	isObjectEmpty,
	ExecutionBaseError,
	jsonParse,
	ApplicationError,
	sleep,
} from 'n8n-workflow';
import type {
	GenericValue,
	IAdditionalCredentialOptions,
	IAllExecuteFunctions,
	ICredentialDataDecryptedObject,
	IDataObject,
	IExecuteData,
	IExecuteFunctions,
	IHttpRequestOptions,
	IN8nHttpFullResponse,
	IN8nHttpResponse,
	INode,
	INodeExecutionData,
	IOAuth2Options,
	IRequestOptions,
	IRunExecutionData,
	IWorkflowDataProxyAdditionalKeys,
	IWorkflowExecuteAdditionalData,
	Logger as WorkflowLogger,
	NodeParameterValueType,
	PaginationOptions,
	RequestHelperFunctions,
	Workflow,
	WorkflowExecuteMode,
} from 'n8n-workflow';
import type { Token } from 'oauth-1.0a';
import clientOAuth1 from 'oauth-1.0a';
import { stringify } from 'qs';
import { Readable } from 'stream';

import type { SsrfBridge } from '@/execution-engine';
import { callEvalMockHandler, normalizeLegacyRequest } from '@/execution-engine/eval-mock-helpers';
import type { IResponseError } from '@/interfaces';

import { binaryToString } from './binary-helper-functions';
import { applyDefaultOutboundUserAgent } from './outbound-user-agent';
import { parseIncomingMessage } from './parse-incoming-message';
// Imported for side effects: sets axios defaults and registers the request interceptor
import './request-helpers/axios-config';
import {
	buildTargetUrl,
	createFormDataObject,
	digestAuthAxiosConfig,
	generateContentLengthHeader,
	getBeforeRedirectFn,
	getHostFromRequestObject,
	isFormDataInstance,
	isIgnoreStatusErrorConfig,
	searchForHeader,
	setAxiosAgents,
	tryParseUrl,
} from './request-helpers';
import { throwIfDomainNotAllowed } from './request-helpers/axios-utils';

export async function invokeAxios(
	axiosConfig: AxiosRequestConfig,
	authOptions: IRequestOptions['auth'] = {},
) {
	try {
		return await axios(axiosConfig);
	} catch (error) {
		if (authOptions.sendImmediately !== false || !(error instanceof axios.AxiosError)) throw error;
		// for digest-auth
		const { response } = error;
		if (response?.status !== 401 || !response.headers['www-authenticate']?.includes('nonce')) {
			throw error;
		}
		const { auth } = axiosConfig;
		delete axiosConfig.auth;
		axiosConfig = digestAuthAxiosConfig(axiosConfig, response, auth);
		return await axios(axiosConfig);
	}
}

/**
 * This function is a temporary implementation that translates all http requests
 * done via the request library to axios directly.
 * We are not using n8n's interface as it would an unnecessary step,
 * considering the `request` helper has been be deprecated and should be removed.
 * @deprecated This is only used by legacy request helpers, that are also deprecated
 */
// eslint-disable-next-line complexity
export async function parseRequestObject(requestObject: IRequestOptions, ssrfBridge?: SsrfBridge) {
	const axiosConfig: AxiosRequestConfig = {};

	if (requestObject.headers !== undefined) {
		axiosConfig.headers = requestObject.headers as AxiosHeaders;
	}

	// Let's start parsing the hardest part, which is the request body.
	// The process here is as following?
	// - Check if we have a `content-type` header. If this was set,
	//   we will follow
	// - Check if the `form` property was set. If yes, then it's x-www-form-urlencoded
	// - Check if the `formData` property exists. If yes, then it's multipart/form-data
	// - Lastly, we should have a regular `body` that is probably a JSON.

	const contentTypeHeaderKeyName =
		axiosConfig.headers &&
		Object.keys(axiosConfig.headers).find(
			(headerName) => headerName.toLowerCase() === 'content-type',
		);
	const contentType =
		contentTypeHeaderKeyName &&
		(axiosConfig.headers?.[contentTypeHeaderKeyName] as string | undefined);
	if (contentType === 'application/x-www-form-urlencoded' && requestObject.formData === undefined) {
		// there are nodes incorrectly created, informing the content type header
		// and also using formData. Request lib takes precedence for the formData.
		// We will do the same.
		// Merge body and form properties.
		if (typeof requestObject.body === 'string') {
			axiosConfig.data = requestObject.body;
		} else {
			const allData = Object.assign(requestObject.body || {}, requestObject.form || {}) as Record<
				string,
				string
			>;
			if (requestObject.useQuerystring === true) {
				axiosConfig.data = stringify(allData, { arrayFormat: 'repeat' });
			} else {
				axiosConfig.data = stringify(allData);
			}
		}
	} else if (contentType?.includes('multipart/form-data')) {
		if (requestObject.formData !== undefined && isFormDataInstance(requestObject.formData)) {
			axiosConfig.data = requestObject.formData;
		} else {
			const allData: Partial<FormData> = {
				...(requestObject.body as object | undefined),
				...(requestObject.formData as object | undefined),
			};

			axiosConfig.data = createFormDataObject(allData);
		}
		// replace the existing header with a new one that
		// contains the boundary property.
		delete axiosConfig.headers?.[contentTypeHeaderKeyName!];

		const headers = axiosConfig.data.getHeaders();

		axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
		await generateContentLengthHeader(axiosConfig);
	} else {
		// When using the `form` property it means the content should be x-www-form-urlencoded.
		if (requestObject.form !== undefined && requestObject.body === undefined) {
			// If we have only form
			axiosConfig.data =
				typeof requestObject.form === 'string'
					? stringify(requestObject.form, { format: 'RFC3986' })
					: stringify(requestObject.form).toString();
			if (axiosConfig.headers !== undefined) {
				const headerName = searchForHeader(axiosConfig, 'content-type');
				if (headerName) {
					delete axiosConfig.headers[headerName];
				}
				axiosConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded';
			} else {
				axiosConfig.headers = {
					'Content-Type': 'application/x-www-form-urlencoded',
				};
			}
		} else if (requestObject.formData !== undefined) {
			// remove any "content-type" that might exist.
			if (axiosConfig.headers !== undefined) {
				const headers = Object.keys(axiosConfig.headers);
				headers.forEach((header) => {
					if (header.toLowerCase() === 'content-type') {
						delete axiosConfig.headers?.[header];
					}
				});
			}

			if (isFormDataInstance(requestObject.formData)) {
				axiosConfig.data = requestObject.formData;
			} else {
				axiosConfig.data = createFormDataObject(requestObject.formData as Record<string, unknown>);
			}
			// Mix in headers as FormData creates the boundary.

			const headers = axiosConfig.data.getHeaders();

			axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
			await generateContentLengthHeader(axiosConfig);
		} else if (requestObject.body !== undefined) {
			// If we have body and possibly form
			if (requestObject.form !== undefined && requestObject.body) {
				// merge both objects when exist.

				requestObject.body = Object.assign(requestObject.body, requestObject.form);
			}
			axiosConfig.data = requestObject.body as FormData | GenericValue | GenericValue[];
		}
	}

	if (requestObject.uri !== undefined) {
		axiosConfig.url = requestObject.uri?.toString();
	}

	if (requestObject.url !== undefined) {
		axiosConfig.url = requestObject.url?.toString();
	}

	if (requestObject.baseURL !== undefined) {
		axiosConfig.baseURL = requestObject.baseURL?.toString();
	}

	if (requestObject.method !== undefined) {
		axiosConfig.method = requestObject.method;
	}

	if (requestObject.qs !== undefined && Object.keys(requestObject.qs as object).length > 0) {
		axiosConfig.params = requestObject.qs;
	}

	function hasArrayFormatOptions(
		arg: IRequestOptions,
	): arg is Required<Pick<IRequestOptions, 'qsStringifyOptions'>> {
		if (
			typeof arg.qsStringifyOptions === 'object' &&
			arg.qsStringifyOptions !== null &&
			!Array.isArray(arg.qsStringifyOptions) &&
			'arrayFormat' in arg.qsStringifyOptions
		) {
			return true;
		}

		return false;
	}

	if (
		requestObject.useQuerystring === true ||
		(hasArrayFormatOptions(requestObject) &&
			requestObject.qsStringifyOptions.arrayFormat === 'repeat')
	) {
		axiosConfig.paramsSerializer = (params) => {
			return stringify(params, { arrayFormat: 'repeat' });
		};
	} else if (requestObject.useQuerystring === false) {
		axiosConfig.paramsSerializer = (params) => {
			return stringify(params, { arrayFormat: 'indices' });
		};
	}

	if (
		hasArrayFormatOptions(requestObject) &&
		requestObject.qsStringifyOptions.arrayFormat === 'brackets'
	) {
		axiosConfig.paramsSerializer = (params) => {
			return stringify(params, { arrayFormat: 'brackets' });
		};
	}

	if (requestObject.auth !== undefined) {
		// Check support for sendImmediately
		if (requestObject.auth.bearer !== undefined) {
			axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
				Authorization: `Bearer ${requestObject.auth.bearer}`,
			});
		} else {
			const authObj = requestObject.auth;
			// Request accepts both user/username and pass/password
			axiosConfig.auth = {
				username: (authObj.user || authObj.username) as string,

				password: (authObj.password || authObj.pass) as string,
			};
		}
	}

	// Only set header if we have a body, otherwise it may fail
	if (requestObject.json === true) {
		// Add application/json headers - do not set charset as it breaks a lot of stuff
		// only add if no other accept headers was sent.
		const acceptHeaderExists =
			axiosConfig.headers === undefined
				? false
				: Object.keys(axiosConfig.headers)
						.map((headerKey) => headerKey.toLowerCase())
						.includes('accept');
		if (!acceptHeaderExists) {
			axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
				Accept: 'application/json',
			});
		}
	}
	if (requestObject.json === false || requestObject.json === undefined) {
		// Prevent json parsing

		axiosConfig.transformResponse = (res) => res;
	}

	// Axios will follow redirects by default, so we simply tell it otherwise if needed.
	const { method } = requestObject;
	if (
		(requestObject.followRedirect !== false &&
			(!method || method === 'GET' || method === 'HEAD')) ||
		requestObject.followAllRedirects
	) {
		axiosConfig.maxRedirects = requestObject.maxRedirects;
	} else {
		axiosConfig.maxRedirects = 0;
	}

	const host = getHostFromRequestObject(requestObject);
	const agentOptions: AgentOptions = { ...requestObject.agentOptions };
	if (host) {
		agentOptions.servername = host;
	}
	if (requestObject.rejectUnauthorized === false) {
		agentOptions.rejectUnauthorized = false;
		agentOptions.secureOptions = crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT;
	}

	if (requestObject.timeout !== undefined) {
		axiosConfig.timeout = requestObject.timeout;
	}

	const secureLookup = ssrfBridge?.createSecureLookup();
	setAxiosAgents(axiosConfig, agentOptions, requestObject.proxy, secureLookup);

	axiosConfig.beforeRedirect = getBeforeRedirectFn(
		agentOptions,
		axiosConfig,
		requestObject.proxy,
		requestObject.sendCredentialsOnCrossOriginRedirect ?? true,
		requestObject.allowedDomains,
		ssrfBridge,
	);

	if (requestObject.useStream) {
		axiosConfig.responseType = 'stream';
	} else if (requestObject.encoding === null) {
		// When downloading files, return an arrayBuffer.
		axiosConfig.responseType = 'arraybuffer';
	}

	// If we don't set an accept header
	// Axios forces "application/json, text/plan, */*"
	// Which causes some nodes like NextCloud to break
	// as the service returns XML unless requested otherwise.
	const allHeaders = axiosConfig.headers ? Object.keys(axiosConfig.headers) : [];
	if (!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'accept')) {
		axiosConfig.headers = Object.assign(axiosConfig.headers || {}, { accept: '*/*' });
	}
	if (
		requestObject.json !== false &&
		axiosConfig.data !== undefined &&
		axiosConfig.data !== '' &&
		!(axiosConfig.data instanceof Buffer) &&
		!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'content-type')
	) {
		// Use default header for application/json
		// If we don't specify this here, axios will add
		// application/json; charset=utf-8
		// and this breaks a lot of stuff

		axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
			'content-type': 'application/json',
		});
	}

	if (requestObject.simple === false) {
		axiosConfig.validateStatus = () => true;
	}

	applyDefaultOutboundUserAgent(axiosConfig);

	/**
	 * Missing properties:
	 * encoding (need testing)
	 * gzip (ignored - default already works)
	 * resolveWithFullResponse (implemented elsewhere)
	 */
	return axiosConfig;
}

/**
 * @deprecated This is only used by legacy request helpers, that are also deprecated
 */
export async function proxyRequestToAxios(
	workflow: Workflow | undefined,
	additionalData: IWorkflowExecuteAdditionalData | undefined,
	node: INode | undefined,
	uriOrObject: string | IRequestOptions,
	options?: IRequestOptions,
): Promise<any> {
	let axiosConfig: AxiosRequestConfig = {
		maxBodyLength: Infinity,
		// -1 is the Axios sentinel for "no limit". Infinity also means no limit but
		// Axios 1.15.1+ treats any value > -1 as a finite cap, wrapping stream responses
		// in Readable.from() even when the limit is Infinity. That breaks the downstream
		// `instanceof IncomingMessage` checks in parseIncomingMessage / prepareBinaryData.
		maxContentLength: -1,
	};
	let configObject: IRequestOptions;
	if (typeof uriOrObject === 'string') {
		configObject = { uri: uriOrObject, ...options };
	} else {
		configObject = uriOrObject ?? {};
	}

	const ssrfBridge = additionalData?.ssrfBridge;
	const url = resolveLegacyRequestUrl(configObject);
	await validateUrlSsrf(url, ssrfBridge);

	axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject, ssrfBridge));
	throwIfDomainNotAllowed(axiosConfig, configObject.allowedDomains);

	try {
		const response = await invokeAxios(axiosConfig, configObject.auth);
		let body = response.data;
		if (body instanceof IncomingMessage && axiosConfig.responseType === 'stream') {
			parseIncomingMessage(body);
		} else if (body === '') {
			body = axiosConfig.responseType === 'arraybuffer' ? Buffer.alloc(0) : undefined;
		}
		await additionalData?.hooks?.runHook('nodeFetchedData', [workflow?.id, node]);
		return configObject.resolveWithFullResponse
			? {
					body,
					headers: { ...response.headers },
					statusCode: response.status,
					statusMessage: response.statusText,
					request: response.request,
				}
			: body;
	} catch (error) {
		const { config, response } = error;

		// Axios hydrates the original error with more data. We extract them.
		// https://github.com/axios/axios/blob/master/lib/core/enhanceError.js
		// Note: `code` is ignored as it's an expected part of the errorData.
		if (error.isAxiosError) {
			error.config = error.request = undefined;
			error.options = pick(config ?? {}, ['url', 'method', 'data', 'headers']);
			if (response) {
				Container.get(Logger).debug('Request proxied to Axios failed', { status: response.status });
				let responseData = response.data;

				if (Buffer.isBuffer(responseData) || responseData instanceof Readable) {
					responseData = await binaryToString(responseData);
				}

				if (configObject.simple === false) {
					if (configObject.resolveWithFullResponse) {
						return {
							body: responseData,
							headers: response.headers,
							statusCode: response.status,
							statusMessage: response.statusText,
						};
					} else {
						return responseData;
					}
				}

				error.message = `${response.status as number} - ${JSON.stringify(responseData)}`;
				throw Object.assign(error, {
					statusCode: response.status,
					/**
					 * Axios adds `status` when serializing, causing `status` to be available only to the client.
					 * Hence we add it explicitly to allow the backend to use it when resolving expressions.
					 */
					status: response.status,
					error: responseData,
					response: pick(response, ['headers', 'status', 'statusText']),
				});
			} else if ('rejectUnauthorized' in configObject && error.code?.includes('CERT')) {
				throw new NodeSslError(error);
			}
		}

		throw error;
	}
}

export function convertN8nRequestToAxios(
	n8nRequest: IHttpRequestOptions,
	ssrfBridge?: SsrfBridge,
): AxiosRequestConfig {
	// Destructure properties with the same name first.
	const { headers, method, timeout, auth, proxy, url } = n8nRequest;

	const axiosRequest: AxiosRequestConfig = {
		headers: headers ?? {},
		method,
		timeout,
		auth,
		url,
		maxBodyLength: Infinity,
		maxContentLength: Infinity,
	} as AxiosRequestConfig;

	axiosRequest.params = n8nRequest.qs;

	if (n8nRequest.abortSignal) {
		axiosRequest.signal = n8nRequest.abortSignal;
	}

	if (n8nRequest.baseURL !== undefined) {
		axiosRequest.baseURL = n8nRequest.baseURL;
	}

	if (n8nRequest.disableFollowRedirect === true) {
		axiosRequest.maxRedirects = 0;
	}

	if (n8nRequest.encoding !== undefined) {
		axiosRequest.responseType = n8nRequest.encoding;
	}

	const host = getHostFromRequestObject(n8nRequest);
	const agentOptions: AgentOptions = { ...n8nRequest.agentOptions };
	if (host) {
		agentOptions.servername = host;
	}
	if (n8nRequest.skipSslCertificateValidation === true) {
		agentOptions.rejectUnauthorized = false;
	}
	const secureLookup = ssrfBridge?.createSecureLookup();
	setAxiosAgents(axiosRequest, agentOptions, proxy, secureLookup);

	axiosRequest.beforeRedirect = getBeforeRedirectFn(
		agentOptions,
		axiosRequest,
		n8nRequest.proxy,
		n8nRequest.sendCredentialsOnCrossOriginRedirect ?? true,
		n8nRequest.allowedDomains,
		ssrfBridge,
	);

	if (n8nRequest.arrayFormat !== undefined) {
		axiosRequest.paramsSerializer = (params) => {
			return stringify(params, { arrayFormat: n8nRequest.arrayFormat });
		};
	}

	const { body } = n8nRequest;
	if (body) {
		// Let's add some useful header standards here.
		const existingContentTypeHeaderKey = searchForHeader(axiosRequest, 'content-type');
		if (existingContentTypeHeaderKey === undefined) {
			axiosRequest.headers = axiosRequest.headers || {};
			// We are only setting content type headers if the user did
			// not set it already manually. We're not overriding, even if it's wrong.
			if (isFormDataInstance(body)) {
				axiosRequest.headers = {
					...axiosRequest.headers,
					...body.getHeaders(),
				};
			} else if (body instanceof URLSearchParams) {
				axiosRequest.headers['Content-Type'] = 'application/x-www-form-urlencoded';
			}
		} else if (
			axiosRequest.headers?.[existingContentTypeHeaderKey] === 'application/x-www-form-urlencoded'
		) {
			axiosRequest.data = new URLSearchParams(n8nRequest.body as Record<string, string>);
		}
		// if there is a body and it's empty (does not have properties),
		// make sure not to send anything in it as some services fail when
		// sending GET request with empty body.
		if (typeof body === 'string' || (typeof body === 'object' && !isObjectEmpty(body))) {
			axiosRequest.data = body;
		}
	}

	if (n8nRequest.json) {
		const key = searchForHeader(axiosRequest, 'accept');
		// If key exists, then the user has set both accept
		// header and the json flag. Header should take precedence.
		if (!key) {
			axiosRequest.headers = {
				...axiosRequest.headers,
				Accept: 'application/json',
			};
		}
	}

	applyDefaultOutboundUserAgent(axiosRequest);

	if (n8nRequest.ignoreHttpStatusErrors) {
		const ignoreHttpStatusErrors = n8nRequest.ignoreHttpStatusErrors;
		if (isIgnoreStatusErrorConfig(ignoreHttpStatusErrors)) {
			axiosRequest.validateStatus = (status) => {
				return !ignoreHttpStatusErrors.except.includes(status);
			};
		} else {
			axiosRequest.validateStatus = () => true;
		}
	}

	return axiosRequest;
}

/** Validates a URL against SSRF protection rules. Throws UserError if blocked. */
async function validateUrlSsrf(url: string | undefined, ssrfBridge?: SsrfBridge): Promise<void> {
	if (!ssrfBridge || !url) return;

	const parsed = tryParseUrl(url);
	if (!parsed) return;

	const result = await ssrfBridge.validateUrl(parsed);
	if (!result.ok) {
		throw result.error;
	}
}

function resolveLegacyRequestUrl(requestObject: IRequestOptions): string | undefined {
	const rawUrl = requestObject.uri?.toString() ?? requestObject.url?.toString();
	const baseURL = requestObject.baseURL?.toString();
	return buildTargetUrl(rawUrl, baseURL) ?? rawUrl;
}

const NoBodyHttpMethods = ['GET', 'HEAD', 'OPTIONS'];

/** Remove empty request body on GET, HEAD, and OPTIONS requests */
export const removeEmptyBody = (requestOptions: IHttpRequestOptions | IRequestOptions) => {
	const method = requestOptions.method || 'GET';
	if (NoBodyHttpMethods.includes(method) && isEmpty(requestOptions.body)) {
		delete requestOptions.body;
	}
};

export async function httpRequest(
	requestOptions: IHttpRequestOptions,
	ssrfBridge?: SsrfBridge,
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
	removeEmptyBody(requestOptions);

	const url = buildTargetUrl(requestOptions.url, requestOptions.baseURL) ?? requestOptions.url;
	await validateUrlSsrf(url, ssrfBridge);

	const axiosRequest = convertN8nRequestToAxios(requestOptions, ssrfBridge);
	if (
		axiosRequest.data === undefined ||
		(axiosRequest.method !== undefined && axiosRequest.method.toUpperCase() === 'GET')
	) {
		delete axiosRequest.data;
	}

	throwIfDomainNotAllowed(axiosRequest, requestOptions.allowedDomains);

	const result = await invokeAxios(axiosRequest, requestOptions.auth);

	if (requestOptions.returnFullResponse) {
		return {
			body: result.data,
			headers: result.headers,
			statusCode: result.status,
			statusMessage: result.statusText,
		};
	}

	return result.data;
}

export function applyPaginationRequestData(
	requestData: IRequestOptions,
	paginationRequestData: PaginationOptions['request'],
): IRequestOptions {
	const preparedPaginationData: Partial<IRequestOptions> = {
		...paginationRequestData,
		uri: paginationRequestData.url,
	};

	if ('formData' in requestData) {
		preparedPaginationData.formData = paginationRequestData.body;
		delete preparedPaginationData.body;
	} else if ('form' in requestData) {
		preparedPaginationData.form = paginationRequestData.body;
		delete preparedPaginationData.body;
	}

	return merge({}, requestData, preparedPaginationData);
}

function createOAuth2Client(credentials: OAuth2CredentialData): ClientOAuth2 {
	// Split and trim scopes; empty scope tokens are not RFC 6749-compliant and may be rejected by authorization servers
	const scopes = credentials.scope
		?.split(' ')
		.map((s) => s.trim())
		.filter(Boolean);
	return new ClientOAuth2({
		clientId: credentials.clientId,
		clientSecret: credentials.clientSecret,
		accessTokenUri: credentials.accessTokenUrl,
		scopes: scopes?.length ? scopes : undefined,
		ignoreSSLIssues: credentials.ignoreSSLIssues,
		authentication: credentials.authentication ?? 'header',
		...(credentials.additionalBodyProperties && {
			additionalBodyProperties: jsonParse(credentials.additionalBodyProperties, {
				fallbackValue: {},
			}),
		}),
	});
}

function buildSigningToken(
	client: ClientOAuth2,
	tokenData: ClientOAuth2TokenData,
	oAuth2Options?: IOAuth2Options,
): ClientOAuth2Token {
	const accessToken = get(tokenData, oAuth2Options?.property as string) || tokenData.accessToken;
	const refreshToken = tokenData.refreshToken;
	return client.createToken(
		{
			...tokenData,
			...(accessToken ? { access_token: accessToken } : {}),
			...(refreshToken ? { refresh_token: refreshToken } : {}),
		},
		oAuth2Options?.tokenType || tokenData.tokenType,
	);
}

interface RefreshOAuth2TokenContext {
	credentials: OAuth2CredentialData;
	token: ClientOAuth2Token;
	credentialsType: string;
	node: INode;
	additionalData: IWorkflowExecuteAdditionalData;
	oAuth2Options?: IOAuth2Options;
	logger: WorkflowLogger;
	helpers: IAllExecuteFunctions['helpers'];
}

async function decryptOAuth2TokenDataIfConfigured<T extends IDataObject | undefined>(
	additionalData: IWorkflowExecuteAdditionalData,
	tokenData: T,
	jweEnabled: boolean,
): Promise<T | IDataObject> {
	if (!jweEnabled) return tokenData;
	const proxy = additionalData['oauth-jwe']?.oauthJweProxyProvider;
	if (!proxy || !tokenData) return tokenData;
	return await proxy.decryptOAuth2TokenData(tokenData);
}

async function refreshOrFetchToken(ctx: RefreshOAuth2TokenContext): Promise<ClientOAuth2Token> {
	const {
		credentials,
		token,
		credentialsType,
		node,
		additionalData,
		oAuth2Options,
		logger,
		helpers,
	} = ctx;
	const tokenRefreshOptions: IDataObject = {};
	if (oAuth2Options?.includeCredentialsOnRefreshOnBody) {
		const body: IDataObject = {
			client_id: credentials.clientId,
			...(credentials.grantType === 'authorizationCode' && {
				client_secret: credentials.clientSecret as string,
			}),
		};
		tokenRefreshOptions.body = body;
		tokenRefreshOptions.headers = { Authorization: '' };
	}

	logger.debug(
		`OAuth2 token for "${credentialsType}" used by node "${node.name}" expired. Revalidating.`,
	);

	let newToken;
	if (credentials.grantType === 'clientCredentials') {
		newToken = await token.client.credentials.getToken();
	} else {
		newToken = await token.refresh(tokenRefreshOptions as unknown as ClientOAuth2Options);
	}

	logger.debug(
		`OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`,
	);

	const refreshedTokenData = await decryptOAuth2TokenDataIfConfigured(
		additionalData,
		newToken.data,
		credentials.jweEnabled === true,
	);

	credentials.oauthTokenData = refreshedTokenData as typeof credentials.oauthTokenData;

	// Apply preAuthentication so custom credential types extending oAuth2Api can transform
	// refreshed token data (e.g. extracting a claim from a decrypted JWE/JWT) before signing.
	// runPreAuthentication runs on every refresh without persisting — the transformed
	// oauthTokenData is persisted by the updateCredentialsOauthTokenData call below.
	const preAuthData = await additionalData.credentialsHelper.runPreAuthentication(
		{ helpers },
		credentials as unknown as ICredentialDataDecryptedObject,
		credentialsType,
	);
	let signingToken = newToken;
	if (preAuthData) {
		Object.assign(credentials, preAuthData);
		signingToken = buildSigningToken(
			token.client,
			credentials.oauthTokenData as ClientOAuth2TokenData,
			oAuth2Options,
		);
	}

	if (!node.credentials?.[credentialsType]) {
		throw new ApplicationError('Node does not have credential type', {
			extra: { nodeName: node.name, credentialType: credentialsType },
		});
	}

	const nodeCredentials = node.credentials[credentialsType];
	await additionalData.credentialsHelper.updateCredentialsOauthTokenData(
		nodeCredentials,
		credentialsType,
		credentials as unknown as ICredentialDataDecryptedObject,
		additionalData,
	);

	return signingToken;
}

function resolveTokenExpiredStatusCode(
	oAuth2Options?: IOAuth2Options,
	credentials?: OAuth2CredentialData,
): number {
	return credentials?.tokenExpiredStatusCode ?? oAuth2Options?.tokenExpiredStatusCode ?? 401;
}

/** @deprecated make these requests using httpRequestWithAuthentication */
export async function requestOAuth2(
	this: IAllExecuteFunctions,
	credentialsType: string,
	requestOptions: IHttpRequestOptions | IRequestOptions,
	node: INode,
	additionalData: IWorkflowExecuteAdditionalData,
	oAuth2Options?: IOAuth2Options,
	isN8nRequest = false,
) {
	removeEmptyBody(requestOptions);

	const credentials = (await this.getCredentials(
		credentialsType,
	)) as unknown as OAuth2CredentialData;

	// Only the OAuth2 with authorization code grant needs connection
	if (credentials.grantType === 'authorizationCode' && credentials.oauthTokenData === undefined) {
		throw new ApplicationError('OAuth credentials not connected');
	}

	const oAuthClient = createOAuth2Client(credentials);

	let oauthTokenData = credentials.oauthTokenData as ClientOAuth2TokenData;
	// if it's the first time using the credentials, get the access token and save it into the DB.
	if (
		credentials.grantType === 'clientCredentials' &&
		(oauthTokenData === undefined ||
			Object.keys(oauthTokenData).length === 0 ||
			oauthTokenData.access_token === '') // stub
	) {
		let tokenResult: Awaited<ReturnType<typeof oAuthClient.credentials.getToken>>;
		try {
			tokenResult = await oAuthClient.credentials.getToken();
		} catch (error) {
			const message = error instanceof Error ? error.message : String(error);
			throw new ApplicationError(`Failed to acquire OAuth2 access token: ${message}`, {
				cause: error,
			});
		}
		const { data } = tokenResult;
		// Find the credentials
		if (!node.credentials?.[credentialsType]) {
			throw new ApplicationError('Node does not have credential type', {
				extra: { nodeName: node.name },
				tags: { credentialType: credentialsType },
			});
		}

		const nodeCredentials = node.credentials[credentialsType];
		const initialTokenData = (await decryptOAuth2TokenDataIfConfigured(
			additionalData,
			data,
			credentials.jweEnabled === true,
		)) as ClientOAuth2TokenData;
		credentials.oauthTokenData = initialTokenData;

		// Save the refreshed token
		await additionalData.credentialsHelper.updateCredentialsOauthTokenData(
			nodeCredentials,
			credentialsType,
			credentials as unknown as ICredentialDataDecryptedObject,
			additionalData,
		);

		oauthTokenData = initialTokenData;
	}

	// Apply preAuthentication for custom OAuth2 credential types extending oAuth2Api.
	// Enables transforming token data on every request (e.g. extracting a claim from a
	// decrypted JWE/JWT) as a pure in-memory transform — no DB write per request.
	const preAuthData = await additionalData.credentialsHelper.runPreAuthentication(
		{ helpers: this.helpers },
		credentials as unknown as ICredentialDataDecryptedObject,
		credentialsType,
	);
	if (preAuthData) {
		Object.assign(credentials, preAuthData);
		oauthTokenData = credentials.oauthTokenData as ClientOAuth2TokenData;
	}

	const token = buildSigningToken(oAuthClient, oauthTokenData, oAuth2Options);

	(requestOptions as IRequestOptions).rejectUnauthorized = !credentials.ignoreSSLIssues;

	// Signs the request by adding authorization headers or query parameters depending
	// on the token-type used.
	const newRequestOptions = token.sign(requestOptions as ClientOAuth2RequestObject);
	const newRequestHeaders = (newRequestOptions.headers = newRequestOptions.headers ?? {});
	// If keep bearer is false remove the it from the authorization header
	if (oAuth2Options?.keepBearer === false && typeof newRequestHeaders.Authorization === 'string') {
		newRequestHeaders.Authorization = newRequestHeaders.Authorization.split(' ')[1];
	}
	if (oAuth2Options?.keyToIncludeInAccessTokenHeader) {
		Object.assign(newRequestHeaders, {
			[oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken,
		});
	}
	const tokenExpiredStatusCode = resolveTokenExpiredStatusCode(oAuth2Options, credentials);
	const shouldSkipTokenRefresh = oAuth2Options?.skipTokenRefresh === true;

	const refreshCtx: RefreshOAuth2TokenContext = {
		credentials,
		token,
		credentialsType,
		node,
		additionalData,
		oAuth2Options,
		logger: this.logger,
		helpers: this.helpers,
	};

	const retryWithNewToken = async (
		makeRequest: (opts: ClientOAuth2RequestObject) => Promise<any>,
	) => {
		const newToken = await refreshOrFetchToken(refreshCtx);
		const refreshedRequestOptions = newToken.sign(requestOptions as ClientOAuth2RequestObject);
		refreshedRequestOptions.headers = refreshedRequestOptions.headers ?? {};
		if (oAuth2Options?.keyToIncludeInAccessTokenHeader) {
			Object.assign(refreshedRequestOptions.headers, {
				[oAuth2Options.keyToIncludeInAccessTokenHeader]: newToken.accessToken,
			});
		}
		return await makeRequest(refreshedRequestOptions);
	};

	if (isN8nRequest) {
		return await this.helpers.httpRequest(newRequestOptions).catch(async (error: AxiosError) => {
			if (!shouldSkipTokenRefresh && error.response?.status === tokenExpiredStatusCode) {
				return await retryWithNewToken(async (opts) => await this.helpers.httpRequest(opts));
			}
			throw error;
		});
	}

	return await this.helpers
		.request(newRequestOptions as IRequestOptions)
		.then((response) => {
			const requestOptions = newRequestOptions as any;
			if (
				!shouldSkipTokenRefresh &&
				requestOptions.resolveWithFullResponse === true &&
				requestOptions.simple === false &&
				response.statusCode === tokenExpiredStatusCode
			) {
				throw response;
			}
			return response;
		})
		.catch(async (error: IResponseError) => {
			if (!shouldSkipTokenRefresh && error.statusCode === tokenExpiredStatusCode) {
				return await retryWithNewToken(
					async (opts) => await this.helpers.request(opts as IRequestOptions),
				);
			}
			throw error;
		});
}

/** @deprecated make these requests using httpRequestWithAuthentication */
export async function requestOAuth1(
	this: IAllExecuteFunctions,
	credentialsType: string,
	requestOptions: IHttpRequestOptions | IRequestOptions,
	isN8nRequest = false,
) {
	removeEmptyBody(requestOptions);

	const credentials = await this.getCredentials(credentialsType);

	if (credentials === undefined) {
		throw new ApplicationError('No credentials were returned');
	}

	if (credentials.oauthTokenData === undefined) {
		throw new ApplicationError('OAuth credentials not connected');
	}

	const oauth = new clientOAuth1({
		consumer: {
			key: credentials.consumerKey as string,
			secret: credentials.consumerSecret as string,
		},
		signature_method: credentials.signatureMethod as string,
		hash_function(base, key) {
			let algorithm: string;
			switch (credentials.signatureMethod) {
				case 'HMAC-SHA256':
					algorithm = 'sha256';
					break;
				case 'HMAC-SHA512':
					algorithm = 'sha512';
					break;
				default:
					algorithm = 'sha1';
					break;
			}
			return createHmac(algorithm, key).update(base).digest('base64');
		},
	});

	const oauthTokenData = credentials.oauthTokenData as IDataObject;

	const token: Token = {
		key: oauthTokenData.oauth_token as string,
		secret: oauthTokenData.oauth_token_secret as string,
	};

	// @ts-expect-error @TECH_DEBT: Remove request library
	requestOptions.data = { ...requestOptions.qs, ...requestOptions.form };

	// Fixes issue that OAuth1 library only works with "url" property and not with "uri"
	if ('uri' in requestOptions && !requestOptions.url) {
		requestOptions.url = requestOptions.uri;
		delete requestOptions.uri;
	}

	requestOptions.headers = oauth.toHeader(
		oauth.authorize(requestOptions as unknown as clientOAuth1.RequestOptions, token),
	) as unknown as Record<string, string>;
	if (isN8nRequest) {
		return await this.helpers.httpRequest(requestOptions as IHttpRequestOptions);
	}

	return await this.helpers
		.request(requestOptions as IRequestOptions)
		.catch(async (error: IResponseError) => {
			// Unknown error so simply throw it
			throw error;
		});
}

export async function refreshOAuth2Token(
	this: IAllExecuteFunctions,
	credentialsType: string,
	node: INode,
	additionalData: IWorkflowExecuteAdditionalData,
	oAuth2Options?: IOAuth2Options,
) {
	const credentials = (await this.getCredentials(
		credentialsType,
	)) as unknown as OAuth2CredentialData;
	if (credentials.grantType === 'authorizationCode' && credentials.oauthTokenData === undefined) {
		throw new ApplicationError('OAuth credentials not connected');
	}

	const oAuthClient = createOAuth2Client(credentials);
	const oauthTokenData = credentials.oauthTokenData as ClientOAuth2TokenData;
	const token = buildSigningToken(oAuthClient, oauthTokenData, oAuth2Options);

	const newToken = await refreshOrFetchToken({
		credentials,
		token,
		credentialsType,
		node,
		additionalData,
		oAuth2Options,
		logger: this.logger,
		helpers: this.helpers,
	});

	return newToken.data;
}

export async function httpRequestWithAuthentication(
	this: IAllExecuteFunctions,
	credentialsType: string,
	requestOptions: IHttpRequestOptions,
	workflow: Workflow,
	node: INode,
	additionalData: IWorkflowExecuteAdditionalData,
	additionalCredentialOptions?: IAdditionalCredentialOptions,
) {
	removeEmptyBody(requestOptions);

	// Cancel this request on execution cancellation
	if ('getExecutionCancelSignal' in this) {
		requestOptions.abortSignal = this.getExecutionCancelSignal();
	}

	let credentialsDecrypted: ICredentialDataDecryptedObject | undefined;

	// Eval LLM mock: intercept before credential auth and OAuth signing
	if (additionalData.evalLlmMockHandler) {
		const evalMockResponse = await callEvalMockHandler(
			additionalData.evalLlmMockHandler,
			requestOptions,
			node,
			requestOptions.returnFullResponse,
		);
		if (evalMockResponse !== undefined) return evalMockResponse;
	}

	try {
		const parentTypes = additionalData.credentialsHelper.getParentTypes(credentialsType);

		if (parentTypes.includes('oAuth1Api')) {
			return await requestOAuth1.call(this, credentialsType, requestOptions, true);
		}
		if (parentTypes.includes('oAuth2Api')) {
			return await requestOAuth2.call(
				this,
				credentialsType,
				requestOptions,
				node,
				additionalData,
				additionalCredentialOptions?.oauth2,
				true,
			);
		}

		if (additionalCredentialOptions?.credentialsDecrypted) {
			credentialsDecrypted = additionalCredentialOptions.credentialsDecrypted.data;
		} else {
			credentialsDecrypted =
				await this.getCredentials<ICredentialDataDecryptedObject>(credentialsType);
		}

		if (credentialsDecrypted === undefined) {
			throw new NodeOperationError(
				node,
				`Node "${node.name}" does not have any credentials of type "${credentialsType}" set`,
				{ level: 'warning' },
			);
		}

		const data = await additionalData.credentialsHelper.preAuthentication(
			{ helpers: this.helpers },
			credentialsDecrypted,
			credentialsType,
			node,
			false,
		);

		if (data) {
			// make the updated property in the credentials
			// available to the authenticate method
			Object.assign(credentialsDecrypted, data);
		}

		requestOptions = await additionalData.credentialsHelper.authenticate(
			credentialsDecrypted,
			credentialsType,
			requestOptions,
			workflow,
			node,
		);
		return await httpRequest(requestOptions, additionalData.ssrfBridge);
	} catch (error) {
		// if there is a pre authorization method defined and
		// the method failed due to unauthorized request
		if (
			error.response?.status === 401 &&
			additionalData.credentialsHelper.preAuthentication !== undefined
		) {
			try {
				if (credentialsDecrypted !== undefined) {
					// try to refresh the credentials
					const data = await additionalData.credentialsHelper.preAuthentication(
						{ helpers: this.helpers },
						credentialsDecrypted,
						credentialsType,
						node,
						true,
					);

					if (data) {
						// make the updated property in the credentials
						// available to the authenticate method
						Object.assign(credentialsDecrypted, data);
					}

					requestOptions = await additionalData.credentialsHelper.authenticate(
						credentialsDecrypted,
						credentialsType,
						requestOptions,
						workflow,
						node,
					);
				}
				return await httpRequest(requestOptions, additionalData.ssrfBridge);
			} catch (error) {
				throw new NodeApiError(this.getNode(), error);
			}
		}

		throw new NodeApiError(this.getNode(), error);
	}
}

/** @deprecated use httpRequestWithAuthentication */
export async function requestWithAuthentication(
	this: IAllExecuteFunctions,
	credentialsType: string,
	requestOptions: IRequestOptions,
	workflow: Workflow,
	node: INode,
	additionalData: IWorkflowExecuteAdditionalData,
	additionalCredentialOptions?: IAdditionalCredentialOptions,
	itemIndex?: number,
) {
	removeEmptyBody(requestOptions);

	let credentialsDecrypted: ICredentialDataDecryptedObject | undefined;

	// Eval LLM mock: intercept before credential auth and OAuth signing (legacy path)
	if (additionalData.evalLlmMockHandler) {
		const evalMockResponse = await callEvalMockHandler(
			additionalData.evalLlmMockHandler,
			normalizeLegacyRequest(requestOptions),
			node,
			requestOptions.resolveWithFullResponse,
			'legacy',
		);
		if (evalMockResponse !== undefined) return evalMockResponse;
	}

	try {
		const parentTypes = additionalData.credentialsHelper.getParentTypes(credentialsType);

		if (credentialsType === 'oAuth1Api' || parentTypes.includes('oAuth1Api')) {
			return await requestOAuth1.call(this, credentialsType, requestOptions, false);
		}
		if (credentialsType === 'oAuth2Api' || parentTypes.includes('oAuth2Api')) {
			return await requestOAuth2.call(
				this,
				credentialsType,
				requestOptions,
				node,
				additionalData,
				additionalCredentialOptions?.oauth2,
				false,
			);
		}

		if (additionalCredentialOptions?.credentialsDecrypted) {
			credentialsDecrypted = additionalCredentialOptions.credentialsDecrypted.data;
		} else {
			credentialsDecrypted = await this.getCredentials<ICredentialDataDecryptedObject>(
				credentialsType,
				itemIndex,
			);
		}

		if (credentialsDecrypted === undefined) {
			throw new NodeOperationError(
				node,
				`Node "${node.name}" does not have any credentials of type "${credentialsType}" set`,
				{ level: 'warning' },
			);
		}

		const data = await additionalData.credentialsHelper.preAuthentication(
			{ helpers: this.helpers },
			credentialsDecrypted,
			credentialsType,
			node,
			false,
		);

		if (data) {
			// make the updated property in the credentials
			// available to the authenticate method
			Object.assign(credentialsDecrypted, data);
		}

		requestOptions = (await additionalData.credentialsHelper.authenticate(
			credentialsDecrypted,
			credentialsType,
			requestOptions as IHttpRequestOptions,
			workflow,
			node,
		)) as IRequestOptions;
		return await proxyRequestToAxios(workflow, additionalData, node, requestOptions);
	} catch (error) {
		try {
			if (credentialsDecrypted !== undefined) {
				// try to refresh the credentials
				const data = await additionalData.credentialsHelper.preAuthentication(
					{ helpers: this.helpers },
					credentialsDecrypted,
					credentialsType,
					node,
					true,
				);

				if (data) {
					// make the updated property in the credentials
					// available to the authenticate method
					Object.assign(credentialsDecrypted, data);
					requestOptions = (await additionalData.credentialsHelper.authenticate(
						credentialsDecrypted,
						credentialsType,
						requestOptions as IHttpRequestOptions,
						workflow,
						node,
					)) as IRequestOptions;
					return await proxyRequestToAxios(workflow, additionalData, node, requestOptions);
				}
			}
			throw error;
		} catch (error) {
			if (error instanceof ExecutionBaseError) throw error;

			throw new NodeApiError(this.getNode(), error);
		}
	}
}

export const getRequestHelperFunctions = (
	workflow: Workflow,
	node: INode,
	additionalData: IWorkflowExecuteAdditionalData,
	runExecutionData: IRunExecutionData | null = null,
	connectionInputData: INodeExecutionData[] = [],
): RequestHelperFunctions => {
	const getResolvedValue = (
		parameterValue: NodeParameterValueType,
		itemIndex: number,
		runIndex: number,
		executeData: IExecuteData,
		additionalKeys?: IWorkflowDataProxyAdditionalKeys,
		returnObjectAsString = false,
	): NodeParameterValueType => {
		const mode: WorkflowExecuteMode = 'internal';

		if (
			typeof parameterValue === 'object' ||
			(typeof parameterValue === 'string' && parameterValue.charAt(0) === '=')
		) {
			return workflow.expression.getParameterValue(
				parameterValue,
				runExecutionData,
				runIndex,
				itemIndex,
				node.name,
				connectionInputData,
				mode,
				additionalKeys ?? {},
				executeData,
				returnObjectAsString,
			);
		}

		return parameterValue;
	};

	// eslint-disable-next-line complexity
	async function requestWithAuthenticationPaginated(
		this: IExecuteFunctions,
		requestOptions: IRequestOptions,
		itemIndex: number,
		paginationOptions: PaginationOptions,
		credentialsType?: string,
		additionalCredentialOptions?: IAdditionalCredentialOptions,
	): Promise<any[]> {
		const responseData = [];
		if (!requestOptions.qs) {
			requestOptions.qs = {};
		}
		requestOptions.resolveWithFullResponse = true;
		requestOptions.simple = false;

		let tempResponseData: IN8nHttpFullResponse;
		let makeAdditionalRequest: boolean;
		let paginateRequestData: PaginationOptions['request'];

		const runIndex = 0;

		const additionalKeys: IWorkflowDataProxyAdditionalKeys = {
			$request: requestOptions,
			$response: {} as IN8nHttpFullResponse,
			$version: node.typeVersion,
			$pageCount: 0,
		};

		const executeData: IExecuteData = {
			data: {},
			node,
			source: null,
		};

		const hashData = {
			identicalCount: 0,
			previousLength: 0,
			previousHash: '',
		};
		do {
			paginateRequestData = getResolvedValue(
				paginationOptions.request as unknown as NodeParameterValueType,
				itemIndex,
				runIndex,
				executeData,
				additionalKeys,
				false,
			) as object as PaginationOptions['request'];

			const tempRequestOptions = applyPaginationRequestData(requestOptions, paginateRequestData);

			if (!tryParseUrl(tempRequestOptions.uri as string)) {
				throw new NodeOperationError(node, `'${paginateRequestData.url}' is not a valid URL.`, {
					itemIndex,
					runIndex,
					type: 'invalid_url',
				});
			}

			if (credentialsType) {
				tempResponseData = await this.helpers.requestWithAuthentication.call(
					this,
					credentialsType,
					tempRequestOptions,
					additionalCredentialOptions,
				);
			} else {
				tempResponseData = await this.helpers.request(tempRequestOptions);
			}

			const newResponse: IN8nHttpFullResponse = Object.assign(
				{
					body: {},
					headers: {},
					statusCode: 0,
				},
				pick(tempResponseData, ['body', 'headers', 'statusCode']),
			);

			let contentBody: Exclude<IN8nHttpResponse, Buffer>;

			if (newResponse.body instanceof Readable && paginationOptions.binaryResult !== true) {
				// Keep the original string version that we can use it to hash if needed
				contentBody = await binaryToString(newResponse.body as Buffer | Readable);

				const responseContentType = newResponse.headers['content-type']?.toString() ?? '';
				if (responseContentType.includes('application/json')) {
					newResponse.body = jsonParse(contentBody, { fallbackValue: {} });
				} else {
					newResponse.body = contentBody;
				}
				tempResponseData.__bodyResolved = true;
				tempResponseData.body = newResponse.body;
			} else {
				contentBody = newResponse.body;
			}

			if (paginationOptions.binaryResult !== true || tempResponseData.headers.etag) {
				// If the data is not binary (and so not a stream), or an etag is present,
				// we check via etag or hash if identical data is received

				let contentLength = 0;
				if ('content-length' in tempResponseData.headers) {
					contentLength = parseInt(tempResponseData.headers['content-length'] as string) || 0;
				}

				if (hashData.previousLength === contentLength) {
					let hash: string;
					if (tempResponseData.headers.etag) {
						// If an etag is provided, we use it as "hash"
						hash = tempResponseData.headers.etag as string;
					} else {
						// If there is no etag, we calculate a hash from the data in the body
						if (typeof contentBody !== 'string') {
							contentBody = JSON.stringify(contentBody);
						}
						hash = crypto.createHash('md5').update(contentBody).digest('base64');
					}

					if (hashData.previousHash === hash) {
						hashData.identicalCount += 1;
						if (hashData.identicalCount > 2) {
							// Length was identical 5x and hash 3x
							throw new NodeOperationError(
								node,
								'The returned response was identical 5x, so requests got stopped',
								{
									itemIndex,
									description:
										'Check if "Pagination Completed When" has been configured correctly.',
								},
							);
						}
					} else {
						hashData.identicalCount = 0;
					}
					hashData.previousHash = hash;
				} else {
					hashData.identicalCount = 0;
				}
				hashData.previousLength = contentLength;
			}

			responseData.push(tempResponseData);

			additionalKeys.$response = newResponse;
			additionalKeys.$pageCount = (additionalKeys.$pageCount ?? 0) + 1;

			const maxRequests = getResolvedValue(
				paginationOptions.maxRequests,
				itemIndex,
				runIndex,
				executeData,
				additionalKeys,
				false,
			) as number;

			if (maxRequests && additionalKeys.$pageCount >= maxRequests) {
				break;
			}

			makeAdditionalRequest = getResolvedValue(
				paginationOptions.continue,
				itemIndex,
				runIndex,
				executeData,
				additionalKeys,
				false,
			) as boolean;

			if (makeAdditionalRequest) {
				if (paginationOptions.requestInterval) {
					const requestInterval = getResolvedValue(
						paginationOptions.requestInterval,
						itemIndex,
						runIndex,
						executeData,
						additionalKeys,
						false,
					) as number;

					await sleep(requestInterval);
				}
				if (tempResponseData.statusCode < 200 || tempResponseData.statusCode >= 300) {
					// We have it configured to let all requests pass no matter the response code
					// via "requestOptions.simple = false" to not by default fail if it is for example
					// configured to stop on 404 response codes. For that reason we have to throw here
					// now an error manually if the response code is not a success one.
					let data = tempResponseData.body;
					if (data instanceof Readable && paginationOptions.binaryResult !== true) {
						data = await binaryToString(data as Buffer | Readable);
					} else if (typeof data === 'object') {
						data = JSON.stringify(data);
					}

					throw Object.assign(new Error(`${tempResponseData.statusCode} - "${data?.toString()}"`), {
						statusCode: tempResponseData.statusCode,
						error: data,
						isAxiosError: true,
						response: {
							headers: tempResponseData.headers,
							status: tempResponseData.statusCode,
							statusText: tempResponseData.statusMessage,
						},
					});
				}
			}
		} while (makeAdditionalRequest);

		return responseData;
	}

	// Eval LLM mock handler: extract once for use in direct helpers below
	const evalLlmMock = additionalData.evalLlmMockHandler;

	return {
		httpRequest: async (requestOptions: IHttpRequestOptions) => {
			if (evalLlmMock) {
				const evalMockResponse = await callEvalMockHandler(
					evalLlmMock,
					requestOptions,
					node,
					requestOptions.returnFullResponse,
				);
				if (evalMockResponse !== undefined) return evalMockResponse;
			}
			if (additionalData.otel?.injectTraceHeaders) {
				requestOptions.headers ??= {};
				additionalData.otel.injectTraceHeaders(
					additionalData.executionId!,
					node.name,
					requestOptions.headers as Record<string, string>,
				);
			}
			return await httpRequest(requestOptions, additionalData.ssrfBridge);
		},
		requestWithAuthenticationPaginated,
		async httpRequestWithAuthentication(
			this,
			credentialsType,
			requestOptions,
			additionalCredentialOptions,
		): Promise<any> {
			return await httpRequestWithAuthentication.call(
				this,
				credentialsType,
				requestOptions,
				workflow,
				node,
				additionalData,
				additionalCredentialOptions,
			);
		},

		async refreshOAuth2Token(
			this: IAllExecuteFunctions,
			credentialsType: string,
			oAuth2Options?: IOAuth2Options,
		) {
			return await refreshOAuth2Token.call(
				this,
				credentialsType,
				node,
				additionalData,
				oAuth2Options,
			);
		},

		request: async (uriOrObject, options) => {
			if (evalLlmMock) {
				const wantsFull = typeof uriOrObject !== 'string' && uriOrObject.resolveWithFullResponse;
				const evalMockResponse = await callEvalMockHandler(
					evalLlmMock,
					normalizeLegacyRequest(uriOrObject, options),
					node,
					wantsFull,
					'legacy',
				);
				if (evalMockResponse !== undefined) return evalMockResponse;
			}
			if (additionalData.otel?.injectTraceHeaders) {
				const target = typeof uriOrObject === 'string' ? (options ??= {}) : uriOrObject;
				target.headers ??= {};
				additionalData.otel.injectTraceHeaders(
					additionalData.executionId!,
					node.name,
					target.headers as Record<string, string>,
				);
			}
			return await proxyRequestToAxios(workflow, additionalData, node, uriOrObject, options);
		},

		async requestWithAuthentication(
			this,
			credentialsType,
			requestOptions,
			additionalCredentialOptions,
			itemIndex,
		): Promise<any> {
			return await requestWithAuthentication.call(
				this,
				credentialsType,
				requestOptions,
				workflow,
				node,
				additionalData,
				additionalCredentialOptions,
				itemIndex,
			);
		},

		async requestOAuth1(
			this: IAllExecuteFunctions,
			credentialsType: string,
			requestOptions: IRequestOptions,
		): Promise<any> {
			if (evalLlmMock) {
				const evalMockResponse = await callEvalMockHandler(
					evalLlmMock,
					normalizeLegacyRequest(requestOptions),
					node,
					requestOptions.resolveWithFullResponse,
					'legacy',
				);
				if (evalMockResponse !== undefined) return evalMockResponse;
			}
			return await requestOAuth1.call(this, credentialsType, requestOptions);
		},

		async requestOAuth2(
			this: IAllExecuteFunctions,
			credentialsType: string,
			requestOptions: IRequestOptions,
			oAuth2Options?: IOAuth2Options,
		): Promise<any> {
			if (evalLlmMock) {
				const evalMockResponse = await callEvalMockHandler(
					evalLlmMock,
					normalizeLegacyRequest(requestOptions),
					node,
					requestOptions.resolveWithFullResponse,
					'legacy',
				);
				if (evalMockResponse !== undefined) return evalMockResponse;
			}
			return await requestOAuth2.call(
				this,
				credentialsType,
				requestOptions,
				node,
				additionalData,
				oAuth2Options,
			);
		},
	};
};
