import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import type { AuthenticatedRequest, CredentialsEntity, ICredentialsDb } from '@n8n/db';
import { CredentialsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import Csrf from 'csrf';
import type { Request, Response } from 'express';
import { Credentials, Cipher } from 'n8n-core';
import type { ICredentialDataDecryptedObject, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import { jsonParse, UnexpectedError } from 'n8n-workflow';

import {
	GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE,
	RESPONSE_ERROR_MESSAGES,
} from '@/constants';
import { AuthService } from '@/auth/auth.service';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsHelper } from '@/credentials-helper';
import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { OAuthRequest } from '@/requests';
import { validateOAuthUrl } from '@/oauth/validate-oauth-url';
import { UrlService } from '@/services/url.service';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import {
	ClientOAuth2,
	type ClientOAuth2Options,
	type ClientOAuth2TokenData,
	type OAuth2AuthenticationMethod,
	type OAuth2CredentialData,
	type OAuth2GrantType,
} from '@n8n/client-oauth2';
import axios from 'axios';
import {
	oAuthAuthorizationServerMetadataSchema,
	dynamicClientRegistrationResponseSchema,
} from '@/controllers/oauth/oauth2-dynamic-client-registration.schema';
import pkceChallenge from 'pkce-challenge';
import * as qs from 'querystring';
import split from 'lodash/split';
import { ExternalHooks } from '@/external-hooks';
import type { AxiosRequestConfig } from 'axios';
import { createHmac } from 'crypto';
import type { RequestOptions } from 'oauth-1.0a';
import clientOAuth1 from 'oauth-1.0a';
import {
	algorithmMap,
	MAX_CSRF_AGE,
	OauthVersion,
	type CreateCsrfStateData,
	type CsrfState,
	type OAuth1CredentialData,
} from './types';
import { CredentialStoreMetadata } from '@/credentials/dynamic-credential-storage.interface';
import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy';
import { OAuthJweServiceProxy } from '@/oauth/oauth-jwe-service.proxy';

export function shouldSkipAuthOnOAuthCallback() {
	const value = process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK?.toLowerCase() ?? 'false';
	return value === 'true';
}

export const skipAuthOnOAuthCallback = shouldSkipAuthOnOAuthCallback();

export { OauthVersion, type OAuth1CredentialData, type CreateCsrfStateData, type CsrfState };

@Service()
export class OauthService {
	constructor(
		protected readonly logger: Logger,
		private readonly credentialsHelper: CredentialsHelper,
		private readonly credentialsRepository: CredentialsRepository,
		private readonly credentialsFinderService: CredentialsFinderService,
		private readonly urlService: UrlService,
		private readonly globalConfig: GlobalConfig,
		private readonly externalHooks: ExternalHooks,
		private readonly cipher: Cipher,
		private readonly dynamicCredentialsProxy: DynamicCredentialsProxy,
		private readonly authService: AuthService,
		private readonly oauthJweServiceProxy: OAuthJweServiceProxy,
	) {}

	private validateOAuthUrlOrThrow(url: string): void {
		try {
			validateOAuthUrl(url);
		} catch (e) {
			this.logger.error('Invalid OAuth URL', { url, error: e });
			throw e;
		}
	}

	getBaseUrl(oauthVersion: OauthVersion) {
		const restUrl = `${this.urlService.getInstanceBaseUrl()}/${this.globalConfig.endpoints.rest}`;
		return `${restUrl}/oauth${oauthVersion}-credential`;
	}

	async getCredentialForUpdate(
		req: OAuthRequest.OAuth1Credential.Auth | OAuthRequest.OAuth2Credential.Auth,
	): Promise<CredentialsEntity> {
		const { id: credentialId } = req.query;

		if (!credentialId) {
			throw new BadRequestError('Required credential ID is missing');
		}

		const credential = await this.credentialsFinderService.findCredentialForUser(
			credentialId,
			req.user,
			['credential:update'],
		);

		if (!credential) {
			this.logger.error(
				'OAuth credential authorization failed because the current user does not have the correct permissions',
				{ userId: req.user.id, credentialId },
			);
			throw new NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL);
		}

		return credential;
	}

	async buildCsrfStateData(
		credential: CredentialsEntity,
		req: OAuthRequest.OAuth1Credential.Auth | OAuthRequest.OAuth2Credential.Auth,
	): Promise<CreateCsrfStateData> {
		if (credential.isResolvable) {
			const resolverId = this.dynamicCredentialsProxy.getSystemResolverId();
			if (resolverId !== null) {
				const cookieToken = this.authService.getCookieToken(req as Request);
				if (cookieToken) {
					return {
						cid: credential.id,
						origin: 'dynamic-credential',
						userId: req.user.id,
						credentialResolverId: resolverId,
						authorizationHeader: `Bearer ${cookieToken}`,
						authMetadata: { source: 'manual-execution' },
					};
				}
			}
		}
		return {
			cid: credential.id,
			origin: 'static-credential',
			userId: req.user.id,
		};
	}

	protected async getAdditionalData() {
		return await WorkflowExecuteAdditionalData.getBase();
	}

	/**
	 * Allow decrypted data to evaluate expressions that include $secrets and apply overwrites
	 */
	protected async getDecryptedDataForAuthUri(
		credential: ICredentialsDb,
		additionalData: IWorkflowExecuteAdditionalData,
	) {
		return await this.getDecryptedData(credential, additionalData, false);
	}

	/**
	 * Do not apply overwrites here because that removes the CSRF state, and breaks the oauth flow
	 */
	protected async getDecryptedDataForCallback(
		credential: ICredentialsDb,
		additionalData: IWorkflowExecuteAdditionalData,
	) {
		return await this.getDecryptedData(credential, additionalData, true);
	}

	private async getDecryptedData(
		credential: ICredentialsDb,
		additionalData: IWorkflowExecuteAdditionalData,
		raw: boolean,
	) {
		return await this.credentialsHelper.getDecrypted(
			additionalData,
			credential,
			credential.type,
			'internal',
			undefined,
			raw,
		);
	}

	protected async applyDefaultsAndOverwrites<T>(
		credential: ICredentialsDb,
		decryptedData: ICredentialDataDecryptedObject,
		additionalData: IWorkflowExecuteAdditionalData,
	) {
		return (await this.credentialsHelper.applyDefaultsAndOverwrites(
			additionalData,
			decryptedData,
			credential.type,
			'internal',
			undefined,
			undefined,
		)) as unknown as T;
	}

	async encryptAndSaveData(
		credential: ICredentialsDb,
		toUpdate: ICredentialDataDecryptedObject,
		toDelete: string[] = [],
	) {
		if (toUpdate.oauthTokenData && typeof toUpdate.oauthTokenData === 'object') {
			const identifier = OauthService.extractAccountIdentifier(
				toUpdate.oauthTokenData as Record<string, unknown>,
			);
			if (identifier) {
				toUpdate.accountIdentifier = identifier;
			}
		}

		const credentials = new Credentials(credential, credential.type, credential.data);
		await credentials.updateData(toUpdate, toDelete);
		await this.credentialsRepository.update(credential.id, {
			...credentials.getDataToSave(),
			updatedAt: new Date(),
		});
	}

	static extractAccountIdentifier(tokenData: Record<string, unknown>): string | undefined {
		for (const key of ['email', 'login', 'username', 'user', 'account']) {
			if (typeof tokenData[key] === 'string' && tokenData[key]) {
				return tokenData[key];
			}
		}

		if (typeof tokenData.id_token === 'string') {
			const parts = tokenData.id_token.split('.');
			if (parts.length === 3) {
				try {
					const payload: Record<string, unknown> = JSON.parse(
						Buffer.from(parts[1], 'base64url').toString(),
					);
					if (typeof payload.email === 'string' && payload.email) {
						return payload.email;
					}
					if (typeof payload.preferred_username === 'string' && payload.preferred_username) {
						return payload.preferred_username;
					}
				} catch {}
			}
		}

		const authedUser = tokenData.authed_user;
		if (authedUser && typeof authedUser === 'object') {
			const user = authedUser as Record<string, unknown>;
			if (typeof user.id === 'string' && user.id) {
				return user.id;
			}
		}

		return undefined;
	}

	/** Get a credential without user check */
	protected async getCredentialWithoutUser(
		credentialId: string,
	): Promise<CredentialsEntity | null> {
		return await this.credentialsRepository.findOneBy({ id: credentialId });
	}

	async createCsrfState(data: CreateCsrfStateData): Promise<[string, string]> {
		const token = new Csrf();
		const csrfSecret = token.secretSync();
		const state: CsrfState = {
			token: token.create(csrfSecret),
			createdAt: Date.now(),
			data: await this.cipher.encryptV2(JSON.stringify(data)),
		};

		const base64State = Buffer.from(JSON.stringify(state)).toString('base64');
		return [csrfSecret, base64State];
	}

	protected async decodeCsrfState(
		encodedState: string,
		req: AuthenticatedRequest,
	): Promise<[CsrfState & CreateCsrfStateData, CredentialsEntity | null]> {
		const errorMessage = 'Invalid state format';
		const decodedState = Buffer.from(encodedState, 'base64').toString();
		const decoded = jsonParse<CsrfState>(decodedState, {
			errorMessage,
		});

		const decryptedState = jsonParse<CreateCsrfStateData>(
			await this.cipher.decryptV2(decoded.data),
			{
				errorMessage,
			},
		);

		if (typeof decryptedState.cid !== 'string' || typeof decoded.token !== 'string') {
			throw new UnexpectedError(errorMessage);
		}

		// Dynamic credentials: skip user-ownership check since the credential may be shared,
		// but validate userId when both the state and the caller carry an n8n user identity
		// (prevents CSRF-state reuse across users in the browser-initiated OAuth flow).
		// When the flow was initiated externally (e.g. via dynamic-credentials.controller),
		// the state has no userId and the check is skipped.
		if (decryptedState.origin === 'dynamic-credential') {
			if (
				req.user?.id !== undefined &&
				decryptedState.userId !== undefined &&
				decryptedState.userId !== req.user.id
			) {
				throw new AuthError('Unauthorized');
			}
			return [
				{ ...decoded, ...decryptedState },
				await this.getCredentialWithoutUser(decryptedState.cid),
			];
		}

		// Static credentials: skip user validation only when N8N_SKIP_AUTH_ON_OAUTH_CALLBACK is true (e.g. embed/iframe)
		if (skipAuthOnOAuthCallback) {
			return [
				{ ...decoded, ...decryptedState },
				await this.getCredentialWithoutUser(decryptedState.cid),
			];
		}

		if (req.user?.id === undefined || decryptedState.userId !== req.user.id) {
			throw new AuthError('Unauthorized');
		}

		const credential = await this.credentialsFinderService.findCredentialForUser(
			decryptedState.cid,
			req.user,
			['credential:update'],
		);

		return [{ ...decoded, ...decryptedState }, credential];
	}

	protected verifyCsrfState(
		decrypted: ICredentialDataDecryptedObject & { csrfSecret?: string },
		state: CsrfState,
	) {
		const token = new Csrf();

		return (
			Date.now() - state.createdAt <= MAX_CSRF_AGE &&
			decrypted.csrfSecret !== undefined &&
			token.verify(decrypted.csrfSecret, state.token)
		);
	}

	async resolveCredential<T>(
		req: OAuthRequest.OAuth1Credential.Callback | OAuthRequest.OAuth2Credential.Callback,
	): Promise<
		[CredentialsEntity, ICredentialDataDecryptedObject, T, CsrfState & CreateCsrfStateData]
	> {
		const { state: encodedState } = req.query;
		const [state, credential] = await this.decodeCsrfState(encodedState, req);
		if (!credential) {
			throw new NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL);
		}

		const additionalData = await this.getAdditionalData();
		const decryptedDataOriginal = await this.getDecryptedDataForCallback(
			credential,
			additionalData,
		);

		const oauthCredentials = await this.applyDefaultsAndOverwrites<T>(
			credential,
			decryptedDataOriginal,
			additionalData,
		);

		if (!this.verifyCsrfState(decryptedDataOriginal, state)) {
			throw new UnexpectedError('The OAuth callback state is invalid!');
		}

		return [credential, decryptedDataOriginal, oauthCredentials, state];
	}

	renderCallbackError(res: Response, message: string, reason?: string) {
		res.render('oauth-error-callback', { error: { message, reason } });
	}

	async getOAuthCredentials<T>(credential: CredentialsEntity): Promise<T> {
		const additionalData = await this.getAdditionalData();
		const decryptedDataOriginal = await this.getDecryptedDataForAuthUri(credential, additionalData);

		// At some point in the past we saved hidden scopes to credentials (but shouldn't)
		// Delete scope before applying defaults to make sure new scopes are present on reconnect
		// Skip the cleanup when the credential exposes scope as user-editable (directly or via
		// inheritance) so that manually entered scopes survive reconnects.
		// For managed credentials we always strip the scope so that the pre-registered default
		// scope on n8n's OAuth app is used, regardless of credential type.
		const userCanEditScope =
			GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE.includes(credential.type) ||
			this.hasEditableScopeProperty(credential.type);

		if (
			decryptedDataOriginal?.scope &&
			credential.type.includes('OAuth2') &&
			(credential.isManaged || !userCanEditScope)
		) {
			delete decryptedDataOriginal.scope;
		}

		const oauthCredentials = await this.applyDefaultsAndOverwrites<T>(
			credential,
			decryptedDataOriginal,
			additionalData,
		);

		return oauthCredentials;
	}

	/**
	 * Refresh the OAuth2 token stored on a credential by id, persist the refreshed token data,
	 * and return the new auth headers to inject into outbound requests.
	 */
	async refreshOAuth2CredentialById(
		credentialId: string,
		projectId: string,
	): Promise<Record<string, string> | null> {
		const credential = await this.credentialsRepository.findOne({
			where: { id: credentialId },
			relations: { shared: true },
		});
		if (!credential) return null;

		const isAccessible =
			credential.isGlobal || (credential.shared ?? []).some((s) => s.projectId === projectId);
		if (!isAccessible) return null;

		const oauthCredentials = await this.getOAuthCredentials<OAuth2CredentialData>(credential);
		const oauthTokenData = oauthCredentials.oauthTokenData as ClientOAuth2TokenData | undefined;
		if (!oauthTokenData) return null;

		const scopes = oauthCredentials.scope
			?.split(' ')
			.map((s) => s.trim())
			.filter(Boolean);

		const oAuthClient = new ClientOAuth2({
			clientId: oauthCredentials.clientId,
			clientSecret: oauthCredentials.clientSecret,
			accessTokenUri: oauthCredentials.accessTokenUrl,
			scopes: scopes?.length ? scopes : undefined,
			ignoreSSLIssues: oauthCredentials.ignoreSSLIssues,
			authentication: oauthCredentials.authentication ?? 'header',
		});

		const token = oAuthClient.createToken(
			{
				...oauthTokenData,
				...(oauthTokenData.access_token ? { access_token: oauthTokenData.access_token } : {}),
				...(oauthTokenData.refresh_token ? { refresh_token: oauthTokenData.refresh_token } : {}),
			},
			oauthTokenData.token_type,
		);

		let refreshed;
		try {
			refreshed =
				oauthCredentials.grantType === 'clientCredentials'
					? await token.client.credentials.getToken()
					: await token.refresh();
		} catch (error) {
			this.logger.warn('Failed to refresh OAuth2 token for credential', {
				credentialId,
				error: error instanceof Error ? error.message : String(error),
			});
			return null;
		}

		try {
			await this.encryptAndSaveData(credential, { oauthTokenData: refreshed.data });
		} catch (error) {
			this.logger.warn('Refreshed OAuth2 token but failed to persist new token data', {
				credentialId,
				error: error instanceof Error ? error.message : String(error),
			});
		}

		return { Authorization: `Bearer ${refreshed.accessToken}` };
	}

	/**
	 * Checks whether the credential type (after merging inherited properties) exposes
	 * a user-editable `scope` property. A property is considered editable when it is
	 * defined and its `type` is not `'hidden'`.
	 */
	private hasEditableScopeProperty(credentialType: string): boolean {
		try {
			const properties = this.credentialsHelper.getCredentialsProperties(credentialType);
			const scopeProperty = properties.find((property) => property.name === 'scope');
			return scopeProperty !== undefined && scopeProperty.type !== 'hidden';
		} catch {
			return false;
		}
	}

	async generateAOauth2AuthUri(
		credential: CredentialsEntity,
		csrfData: CreateCsrfStateData,
	): Promise<string> {
		const oauthCredentials: OAuth2CredentialData =
			await this.getOAuthCredentials<OAuth2CredentialData>(credential);

		const toUpdate: ICredentialDataDecryptedObject = {};

		if (oauthCredentials.useDynamicClientRegistration && oauthCredentials.serverUrl) {
			// Validate serverUrl to prevent SSRF attacks before any HTTP requests
			this.validateOAuthUrlOrThrow(oauthCredentials.serverUrl);

			// Step 1: Discover Protected Resource Metadata (RFC 9728 / MCP)
			// Try to discover the authorization server URL from protected resource metadata
			let authorizationServerUrl: string;

			try {
				const protectedResourceMetadata = await this.discoverProtectedResourceMetadata(
					oauthCredentials.serverUrl,
				);

				// Use first authorization server from the list
				// MCP spec allows multiple; we use the first one
				authorizationServerUrl = protectedResourceMetadata.authorization_servers[0];

				// Validate authorization server URL to prevent SSRF attacks
				this.validateOAuthUrlOrThrow(authorizationServerUrl);

				this.logger.debug('Protected resource discovery succeeded', {
					resourceUrl: oauthCredentials.serverUrl,
					authorizationServerUrl,
				});
			} catch (error) {
				// Re-throw security validation errors immediately (don't fall back)
				if (error instanceof BadRequestError && (error as Error).message.includes('OAuth url')) {
					throw error;
				}

				// Fallback: If protected resource discovery fails,
				// assume serverUrl IS the authorization server (backwards compatibility)
				this.logger.debug(
					'Protected resource discovery failed, assuming serverUrl is authorization server',
					{
						serverUrl: oauthCredentials.serverUrl,
						error: (error as Error).message,
					},
				);
				authorizationServerUrl = oauthCredentials.serverUrl;
			}

			// Step 2: Discover Authorization Server Metadata (RFC 8414 / OpenID Connect)
			const issuerUrl = new URL(authorizationServerUrl);
			const pathComponent = issuerUrl.pathname.replace(/\/$/, ''); // Remove trailing slash

			// Build discovery URLs in priority order per MCP specification
			// If the path already contains /.well-known/, skip path-insertion variants to avoid
			// double well-known paths (e.g. /.well-known/openid-configuration/.well-known/openid-configuration)
			const pathIsWellKnown = pathComponent.startsWith('/.well-known');
			const discoveryUrls =
				pathComponent && !pathIsWellKnown
					? [
							// 1. RFC 8414: OAuth 2.0 Authorization Server Metadata (path insertion)
							`${issuerUrl.origin}/.well-known/oauth-authorization-server${pathComponent}`,
							// 2. OpenID Connect Discovery 1.0 (path insertion)
							`${issuerUrl.origin}/.well-known/openid-configuration${pathComponent}`,
							// 3. OpenID Connect Discovery 1.0 (path appending)
							`${authorizationServerUrl}/.well-known/openid-configuration`,
							// 4. RFC 8414 origin-only fallback (matches MCP TypeScript SDK behavior)
							`${issuerUrl.origin}/.well-known/oauth-authorization-server`,
						]
					: [
							// For root-level issuers or already-well-known paths
							`${issuerUrl.origin}/.well-known/oauth-authorization-server`,
							`${issuerUrl.origin}/.well-known/openid-configuration`,
						];

			let data: unknown;
			let lastError: Error | undefined;

			// Try each discovery URL until one succeeds
			for (const url of discoveryUrls) {
				try {
					// Validate each URL before making request (defense-in-depth)
					this.validateOAuthUrlOrThrow(url);

					const response = await axios.get<unknown>(url, {
						validateStatus: (status) => status === 200,
					});
					data = response.data;
					break; // Success - exit loop
				} catch (error) {
					lastError = error as Error;
					// Continue to next URL
				}
			}

			if (!data) {
				throw new BadRequestError(
					`Failed to discover OAuth2 authorization server metadata. Tried: ${discoveryUrls.join(', ')}. Last error: ${lastError?.message}`,
				);
			}

			// Validate the metadata response
			const metadataValidation = oAuthAuthorizationServerMetadataSchema.safeParse(data);
			if (!metadataValidation.success) {
				throw new BadRequestError(
					`Invalid OAuth2 server metadata: ${metadataValidation.error.issues.map((e) => e.message).join(', ')}`,
				);
			}

			const { authorization_endpoint, token_endpoint, registration_endpoint, scopes_supported } =
				metadataValidation.data;
			oauthCredentials.authUrl = authorization_endpoint;
			oauthCredentials.accessTokenUrl = token_endpoint;
			toUpdate.authUrl = authorization_endpoint;
			toUpdate.accessTokenUrl = token_endpoint;
			const scope = scopes_supported ? scopes_supported.join(' ') : undefined;
			if (scope) {
				oauthCredentials.scope = scope;
				toUpdate.scope = scope;
			}

			const { grantType, authentication } = this.selectGrantTypeAndAuthenticationMethod(
				metadataValidation.data.grant_types_supported ?? ['authorization_code', 'implicit'],
				metadataValidation.data.token_endpoint_auth_methods_supported ?? ['client_secret_basic'],
				metadataValidation.data.code_challenge_methods_supported ?? [],
			);
			oauthCredentials.grantType = grantType;
			toUpdate.grantType = grantType;
			if (authentication) {
				oauthCredentials.authentication = authentication;
				toUpdate.authentication = authentication;
			}

			const { grant_types, token_endpoint_auth_method } = this.mapGrantTypeAndAuthenticationMethod(
				grantType,
				authentication,
			);
			const registerPayload = {
				redirect_uris: [`${this.getBaseUrl(OauthVersion.V2)}/callback`],
				token_endpoint_auth_method,
				grant_types,
				response_types: ['code'],
				client_name: 'n8n',
				client_uri: 'https://n8n.io/',
				scope,
				...(oauthCredentials.jweEnabled === true
					? await this.oauthJweServiceProxy.getDcrJweFields(oauthCredentials.inlineJwks === true)
					: {}),
			};

			await this.externalHooks.run('oauth2.dynamicClientRegistration', [registerPayload]);

			const { data: registerResult } = await axios.post<unknown>(
				registration_endpoint,
				registerPayload,
			);
			const registrationValidation =
				dynamicClientRegistrationResponseSchema.safeParse(registerResult);
			if (!registrationValidation.success) {
				throw new BadRequestError(
					`Invalid client registration response: ${registrationValidation.error.issues.map((e) => e.message).join(', ')}`,
				);
			}

			const { client_id, client_secret } = registrationValidation.data;
			oauthCredentials.clientId = client_id;
			toUpdate.clientId = client_id;
			if (client_secret) {
				oauthCredentials.clientSecret = client_secret;
				toUpdate.clientSecret = client_secret;
			}
		}

		this.validateOAuthUrlOrThrow(oauthCredentials.authUrl ?? '');
		this.validateOAuthUrlOrThrow(oauthCredentials.accessTokenUrl ?? '');

		// Generate a CSRF prevention token and send it as an OAuth2 state string
		const [csrfSecret, state] = await this.createCsrfState(csrfData);

		const oAuthOptions = {
			...this.convertCredentialToOptions(oauthCredentials),
			state,
		};

		if (oauthCredentials.authQueryParameters) {
			oAuthOptions.query = qs.parse(oauthCredentials.authQueryParameters);
		}

		await this.externalHooks.run('oauth2.authenticate', [oAuthOptions]);

		toUpdate.csrfSecret = csrfSecret;
		if (oauthCredentials.grantType === 'pkce') {
			const { code_verifier, code_challenge } = await pkceChallenge();
			oAuthOptions.query = {
				...oAuthOptions.query,
				code_challenge,
				code_challenge_method: 'S256',
			};
			toUpdate.codeVerifier = code_verifier;
		}

		await this.encryptAndSaveData(credential, toUpdate);

		const oAuthObj = new ClientOAuth2(oAuthOptions);
		const returnUri = oAuthObj.code.getUri();

		this.logger.debug('OAuth2 authorization url created for credential', {
			csrfData,
			credentialId: credential.id,
		});

		return returnUri.toString();
	}

	async generateAOauth1AuthUri(
		credential: CredentialsEntity,
		csrfData: CreateCsrfStateData,
	): Promise<string> {
		const oauthCredentials: OAuth1CredentialData =
			await this.getOAuthCredentials<OAuth1CredentialData>(credential);

		this.validateOAuthUrlOrThrow(oauthCredentials.authUrl ?? '');
		this.validateOAuthUrlOrThrow(oauthCredentials.requestTokenUrl ?? '');
		this.validateOAuthUrlOrThrow(oauthCredentials.accessTokenUrl ?? '');

		const [csrfSecret, state] = await this.createCsrfState(csrfData);

		const signatureMethod = oauthCredentials.signatureMethod;

		const oAuthOptions: clientOAuth1.Options = {
			consumer: {
				key: oauthCredentials.consumerKey,
				secret: oauthCredentials.consumerSecret,
			},
			signature_method: signatureMethod,

			hash_function(base, key) {
				const algorithm = algorithmMap[signatureMethod] ?? 'sha1';
				return createHmac(algorithm, key).update(base).digest('base64');
			},
		};

		const oauthRequestData = {
			oauth_callback: `${this.getBaseUrl(OauthVersion.V1)}/callback?state=${state}`,
		};

		await this.externalHooks.run('oauth1.authenticate', [oAuthOptions, oauthRequestData]);

		const oauth = new clientOAuth1(oAuthOptions);

		const options: RequestOptions = {
			method: 'POST',
			url: oauthCredentials.requestTokenUrl,
			data: oauthRequestData,
		};

		const data = oauth.toHeader(oauth.authorize(options));

		const axiosConfig: AxiosRequestConfig = {
			method: options.method,
			url: options.url,
			headers: {
				...data,
			},
		};

		const { data: response } = await axios.request(axiosConfig);

		// Response comes as x-www-form-urlencoded string so convert it to JSON
		if (typeof response !== 'string') {
			throw new BadRequestError(
				'Expected string response from OAuth1 request token endpoint, but received invalid response type',
			);
		}

		const paramsParser = new URLSearchParams(response);
		const responseJson = Object.fromEntries(paramsParser.entries());

		if (!responseJson.oauth_token) {
			throw new BadRequestError(
				'OAuth1 request token response is missing required oauth_token parameter',
			);
		}

		const returnUri = `${oauthCredentials.authUrl}?oauth_token=${responseJson.oauth_token}`;

		await this.encryptAndSaveData(credential, { csrfSecret }, []);

		this.logger.debug('OAuth1 authorization url created for credential', {
			csrfData,
			credentialId: credential.id,
		});

		return returnUri;
	}

	private convertCredentialToOptions(credential: OAuth2CredentialData): ClientOAuth2Options {
		const options: ClientOAuth2Options = {
			clientId: credential.clientId,
			clientSecret: credential.clientSecret ?? '',
			accessTokenUri: credential.accessTokenUrl ?? '',
			authorizationUri: credential.authUrl ?? '',
			authentication: credential.authentication ?? 'header',
			redirectUri: `${this.getBaseUrl(OauthVersion.V2)}/callback`,
			scopes: split(credential.scope ?? 'openid', ','),
			scopesSeparator: credential.scope?.includes(',') ? ',' : ' ',
			ignoreSSLIssues: credential.ignoreSSLIssues ?? false,
		};

		if (
			credential.additionalBodyProperties &&
			typeof credential.additionalBodyProperties === 'string'
		) {
			const parsedBody = jsonParse<Record<string, string>>(credential.additionalBodyProperties);

			if (parsedBody) {
				options.body = parsedBody;
			}
		}

		return options;
	}

	/**
	 * Discovers OAuth 2.0 Protected Resource Metadata per RFC 9728.
	 * This is the first step in MCP-compliant OAuth discovery.
	 * Returns the authorization_servers array from the metadata.
	 */
	private async discoverProtectedResourceMetadata(
		resourceUrl: string,
	): Promise<{ authorization_servers: string[] }> {
		// Validate input to prevent SSRF (defense-in-depth)
		this.validateOAuthUrlOrThrow(resourceUrl);

		const url = new URL(resourceUrl);
		const pathComponent = url.pathname.replace(/\/$/, ''); // Remove trailing slash

		// Try discovery URLs per MCP spec and RFC 9728
		const discoveryUrls = pathComponent
			? [
					// Path-specific first (e.g., https://mcp.notion.com/.well-known/oauth-protected-resource/mcp)
					`${url.origin}/.well-known/oauth-protected-resource${pathComponent}`,
					// Root fallback (e.g., https://mcp.notion.com/.well-known/oauth-protected-resource)
					`${url.origin}/.well-known/oauth-protected-resource`,
				]
			: [
					// Root only for root-level URLs
					`${url.origin}/.well-known/oauth-protected-resource`,
				];

		for (const discoveryUrl of discoveryUrls) {
			try {
				// Validate each URL before making request (defense-in-depth)
				this.validateOAuthUrlOrThrow(discoveryUrl);

				const { data } = await axios.get(discoveryUrl, {
					validateStatus: (status) => status === 200,
				});

				// Validate has authorization_servers field per RFC 9728
				if (
					data &&
					Array.isArray(data.authorization_servers) &&
					data.authorization_servers.length > 0
				) {
					return data as { authorization_servers: string[] };
				}
			} catch (error) {
				// Continue to next URL
			}
		}

		throw new BadRequestError(
			`Failed to discover protected resource metadata. Tried: ${discoveryUrls.join(', ')}`,
		);
	}

	private selectGrantTypeAndAuthenticationMethod(
		grantTypes: string[],
		tokenEndpointAuthMethods: string[],
		codeChallengeMethods: string[],
	): { grantType: OAuth2GrantType; authentication?: OAuth2AuthenticationMethod } {
		if (grantTypes.includes('authorization_code')) {
			if (codeChallengeMethods.includes('S256')) {
				return { grantType: 'pkce' };
			}

			if (tokenEndpointAuthMethods.includes('client_secret_basic')) {
				return { grantType: 'authorizationCode', authentication: 'header' };
			}

			if (tokenEndpointAuthMethods.includes('client_secret_post')) {
				return { grantType: 'authorizationCode', authentication: 'body' };
			}
		}

		if (grantTypes.includes('client_credentials')) {
			if (tokenEndpointAuthMethods.includes('client_secret_basic')) {
				return { grantType: 'clientCredentials', authentication: 'header' };
			}

			if (tokenEndpointAuthMethods.includes('client_secret_post')) {
				return { grantType: 'clientCredentials', authentication: 'body' };
			}
		}

		throw new BadRequestError('No supported grant type and authentication method found');
	}

	private mapGrantTypeAndAuthenticationMethod(
		grantType: OAuth2GrantType,
		authentication?: OAuth2AuthenticationMethod,
	) {
		if (grantType === 'pkce') {
			return {
				grant_types: ['authorization_code', 'refresh_token'],
				token_endpoint_auth_method: 'none',
			};
		}

		const tokenEndpointAuthMethod =
			authentication === 'header' ? 'client_secret_basic' : 'client_secret_post';
		if (grantType === 'authorizationCode') {
			return {
				grant_types: ['authorization_code', 'refresh_token'],
				token_endpoint_auth_method: tokenEndpointAuthMethod,
			};
		}

		return {
			grant_types: ['client_credentials'],
			token_endpoint_auth_method: tokenEndpointAuthMethod,
		};
	}

	async saveDynamicCredential(
		credential: CredentialsEntity,
		oauthTokenData: ICredentialDataDecryptedObject,
		authHeader: string,
		credentialResolverId: string,
		authMetadata: Record<string, unknown> = {},
	) {
		const credentials = new Credentials(credential, credential.type, credential.data);
		await credentials.updateData(oauthTokenData, ['csrfSecret']);

		const credentialStoreMetadata: CredentialStoreMetadata = {
			id: credential.id,
			name: credential.name,
			type: credential.type,
			isResolvable: credential.isResolvable,
			resolverId: credentialResolverId,
		};

		await this.dynamicCredentialsProxy.storeIfNeeded(
			credentialStoreMetadata,
			oauthTokenData,
			{ version: 1, identity: authHeader, metadata: authMetadata },
			await credentials.getData(),
			{ credentialResolverId },
		);
	}
}
