import { Logger } from '@n8n/backend-common';
import type { ClientOAuth2Options, OAuth2CredentialData } from '@n8n/client-oauth2';
import { ClientOAuth2, resolveClientAuthOptions } from '@n8n/client-oauth2';
import { Get, RestController } from '@n8n/decorators';
import { Response } from 'express';
import omit from 'lodash/omit';
import set from 'lodash/set';
import split from 'lodash/split';
import type { ICredentialDataDecryptedObject, IDataObject } from 'n8n-workflow';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import { jsonParse } from 'n8n-workflow';

import { CredentialsOverwrites } from '@/credentials-overwrites';
import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks';
import { OAuthJweServiceProxy } from '@/oauth/oauth-jwe-service.proxy';
import { OauthService, OauthVersion } from '@/oauth/oauth.service';
import { OAuthRequest } from '@/requests';

@RestController('/oauth2-credential')
export class OAuth2CredentialController {
	constructor(
		private readonly oauthService: OauthService,
		private readonly logger: Logger,
		private readonly externalHooks: ExternalHooks,
		private readonly oauthJweServiceProxy: OAuthJweServiceProxy,
		private readonly eventService: EventService,
		private readonly credentialsOverwrites: CredentialsOverwrites,
	) {}

	/** Get Authorization url */
	@Get('/auth')
	async getAuthUri(req: OAuthRequest.OAuth2Credential.Auth, res: Response): Promise<string> {
		const credential = await this.oauthService.getCredentialForAuthFlow(req);
		const csrfData = await this.oauthService.buildCsrfStateData(credential, req);
		return await this.oauthService.generateAOauth2AuthUri(credential, csrfData, req, res);
	}

	/** Verify and store app code. Generate access tokens and store for respective credential */
	@Get('/callback', { usesTemplates: true, allowUnauthenticated: true })
	async handleCallback(req: OAuthRequest.OAuth2Credential.Callback, res: Response) {
		try {
			const { code, state: encodedState } = req.query;
			if (!code || !encodedState) {
				return this.oauthService.renderCallbackError(
					res,
					'Insufficient parameters for OAuth2 callback.',
					`Received following query parameters: ${JSON.stringify(req.query)}`,
				);
			}

			const [credential, decryptedDataOriginal, oauthCredentials, state, flowState] =
				await this.oauthService.resolveCredential<OAuth2CredentialData>(req);

			if (typeof state.resource === 'string') {
				oauthCredentials.resource = state.resource;
			}

			const oAuthOptions = this.convertCredentialToOptions(oauthCredentials);

			const isPkce = oauthCredentials.grantType === 'pkce' || oauthCredentials.usePkce === true;
			const isBodyAuth = oauthCredentials.authentication === 'body';

			const body: Record<string, string> = { ...(oAuthOptions.body ?? {}) };

			if (isPkce) {
				body.code_verifier = flowState.codeVerifier as string;
			}

			if (isBodyAuth) {
				body.client_id = oAuthOptions.clientId;
				if (oAuthOptions.clientSecret) {
					body.client_secret = oAuthOptions.clientSecret;
				}
				// Remove clientSecret so code-flow.ts won't also send it
				// via the Authorization header
				delete oAuthOptions.clientSecret;
			}

			let options: Partial<ClientOAuth2Options> = {};
			if (isPkce || isBodyAuth) {
				options = { body };
			}

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

			// The bridge is attached here rather than in the options object so it stays out of
			// the payload handed to external hooks.
			const oAuthObj = new ClientOAuth2({
				...oAuthOptions,
				ssrfBridge: this.oauthService.getSsrfBridge(),
			});

			const queryParameters = req.originalUrl.split('?').splice(1, 1).join('');

			const oauthToken = await oAuthObj.code.getToken(
				`${oAuthOptions.redirectUri as string}?${queryParameters}`,
				options,
			);

			if (Object.keys(req.query).length > 2) {
				set(oauthToken.data, 'callbackQueryString', omit(req.query, 'state', 'code'));
			}

			const rawTokenResponse = oauthToken.data as unknown as IDataObject;
			const tokenResponse: IDataObject =
				oauthCredentials.jweEnabled === true
					? await this.oauthJweServiceProxy.decryptOAuth2TokenData(rawTokenResponse)
					: rawTokenResponse;

			// Only overwrite supplied data as some providers do for example just return the
			// refresh_token on the very first request and not on subsequent ones.
			const { oauthTokenData: tokenData } = decryptedDataOriginal;
			const oauthTokenData: ICredentialDataDecryptedObject = {
				...(typeof tokenData === 'object' ? tokenData : {}),
				...tokenResponse,
			} as ICredentialDataDecryptedObject;

			const expiresInSeconds = Number(tokenResponse.expires_in);
			if (Number.isFinite(expiresInSeconds) && expiresInSeconds > 0) {
				oauthTokenData.n8n_expires_at = String(Date.now() + expiresInSeconds * 1000);
			}

			if (typeof state.resource === 'string') {
				oauthTokenData.resource = state.resource;
			}

			if (!state.origin || state.origin === 'static-credential') {
				await this.oauthService.encryptAndSaveData(credential, { oauthTokenData });

				this.logger.debug('OAuth2 callback successful for credential', {
					credentialId: credential.id,
				});

				return res.render('oauth-callback');
			}

			if (state.origin === 'dynamic-credential') {
				if (!state.credentialResolverId || typeof state.credentialResolverId !== 'string') {
					return this.oauthService.renderCallbackError(res, 'Credential resolver ID is required');
				}

				if (
					!state.authorizationHeader ||
					typeof state.authorizationHeader !== 'string' ||
					!state.authorizationHeader.startsWith('Bearer ')
				) {
					return this.oauthService.renderCallbackError(res, 'Authorization header is required');
				}

				await this.oauthService.saveDynamicCredential(
					credential,
					{ oauthTokenData },
					state.authorizationHeader.split('Bearer ')[1],
					state.credentialResolverId,
					(state.authMetadata as Record<string, unknown>) ?? {},
				);

				if (typeof state.userId === 'string') {
					this.eventService.emit('private-credential-user-connected', {
						user: { id: state.userId },
						credentialType: credential.type,
						credentialId: credential.id,
						supportsManagedAuth: this.credentialsOverwrites.supportsManagedAuth(credential.type),
						usesManagedAuth: this.credentialsOverwrites.usesManagedAuth(
							credential.type,
							decryptedDataOriginal,
						),
					});
				}

				return res.render('oauth-callback');
			}
		} catch (e) {
			const error = ensureError(e);
			this.logger.error('OAuth2 callback failed', { error, cause: error.cause });
			return this.oauthService.renderCallbackError(
				res,
				error.message,
				this.oauthService.extractCallbackErrorReason(error),
			);
		}
	}

	private convertCredentialToOptions(credential: OAuth2CredentialData): ClientOAuth2Options {
		const options: ClientOAuth2Options = {
			clientId: credential.clientId,
			...resolveClientAuthOptions(credential),
			accessTokenUri: credential.accessTokenUrl ?? '',
			authorizationUri: credential.authUrl ?? '',
			authentication: credential.authentication ?? 'header',
			redirectUri: `${this.oauthService.getBaseUrl(OauthVersion.V2)}/callback`,
			scopes: split(credential.scope ?? 'openid', ','),
			scopesSeparator: credential.scope?.includes(',') ? ',' : ' ',
			resource: credential.resource,
			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;
	}
}
