import {
	CreateCredentialDto,
	CredentialsGetManyRequestQuery,
	CredentialsGetOneRequestQuery,
	GenerateCredentialNameRequestQuery,
} from '@n8n/api-types';
import { LicenseState, Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import {
	SharedCredentials,
	ProjectRelationRepository,
	SharedCredentialsRepository,
	AuthenticatedRequest,
} from '@n8n/db';
import {
	Delete,
	Get,
	Licensed,
	Patch,
	Post,
	Put,
	RestController,
	ProjectScope,
	Body,
	Param,
	Query,
} from '@n8n/decorators';
import { hasGlobalScope, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { z } from 'zod';

import { CredentialConnectionStatusProxy } from './credential-connection-status-proxy';
import { CredentialsFinderService } from './credentials-finder.service';
import { CredentialsService } from './credentials.service';
import { EnterpriseCredentialsService } from './credentials.service.ee';
import { getExternalSecretExpressionPaths } from './external-secrets.utils';

import { CredentialNotFoundError } from '@/errors/credential-not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service';
import { listQueryMiddleware } from '@/middlewares';
import { userHasScopes } from '@/permissions.ee/check-access';
import { CredentialRequest } from '@/requests';
import { NamingService } from '@/services/naming.service';
import { UserManagementMailer } from '@/user-management/email';
import * as utils from '@/utils';

@RestController('/credentials')
export class CredentialsController {
	constructor(
		private readonly globalConfig: GlobalConfig,
		private readonly credentialsService: CredentialsService,
		private readonly enterpriseCredentialsService: EnterpriseCredentialsService,
		private readonly namingService: NamingService,
		private readonly licenseState: LicenseState,
		private readonly logger: Logger,
		private readonly userManagementMailer: UserManagementMailer,
		private readonly sharedCredentialsRepository: SharedCredentialsRepository,
		private readonly projectRelationRepository: ProjectRelationRepository,
		private readonly eventService: EventService,
		private readonly credentialsFinderService: CredentialsFinderService,
		private readonly connectionStatusProxy: CredentialConnectionStatusProxy,
	) {}

	@Get('/', { middlewares: listQueryMiddleware })
	async getMany(
		req: CredentialRequest.GetMany,
		_res: unknown,
		@Query query: CredentialsGetManyRequestQuery,
	) {
		const credentials = await this.credentialsService.getMany(req.user, {
			listQueryOptions: req.listQueryOptions,
			includeScopes: query.includeScopes,
			includeData: query.includeData,
			onlySharedWithMe: query.onlySharedWithMe,
			includeGlobal: query.includeGlobal,
			filters: {
				externalSecretsStore: query.externalSecretsStore,
			},
		});
		credentials.forEach((c) => {
			// @ts-expect-error: This is to emulate the old behavior of removing the shared
			// field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes`
			// though. So to avoid leaking the information we just delete it.
			delete c.shared;
		});
		return credentials;
	}

	@Get('/for-workflow')
	async getProjectCredentials(req: CredentialRequest.ForWorkflow) {
		const options = z
			.union([z.object({ workflowId: z.string() }), z.object({ projectId: z.string() })])
			.parse(req.query);
		return await this.credentialsService.getCredentialsAUserCanUseInAWorkflow(req.user, options);
	}

	@Get('/new')
	async generateUniqueName(
		_req: unknown,
		_res: unknown,
		@Query query: GenerateCredentialNameRequestQuery,
	) {
		const requestedName = query.name ?? this.globalConfig.credentials.defaultName;

		return {
			name: await this.namingService.getUniqueCredentialName(requestedName),
		};
	}

	@Get('/:credentialId')
	@ProjectScope('credential:read')
	async getOne(
		req: CredentialRequest.Get,
		_res: unknown,
		@Param('credentialId') credentialId: string,
		@Query query: CredentialsGetOneRequestQuery,
	) {
		const { shared, ...credential } = this.licenseState.isSharingLicensed()
			? await this.enterpriseCredentialsService.getOneForUser(
					req.user,
					credentialId,
					// TODO: editor-ui is always sending this, maybe we can just rely on the
					// the scopes and always decrypt the data if the user has the permissions
					// to do so.
					query.includeData,
				)
			: await this.credentialsService.getOne(req.user, credentialId, query.includeData);

		const scopes = await this.credentialsService.getCredentialScopes(
			req.user,
			req.params.credentialId,
		);

		return { ...credential, scopes };
	}

	// TODO: Write at least test cases for the failure paths.
	@Post('/test')
	async testCredentials(req: CredentialRequest.Test) {
		try {
			return await this.credentialsService.testWithCredentials(req.user, req.body.credentials);
		} catch (error) {
			if (error instanceof CredentialNotFoundError) {
				throw new ForbiddenError();
			}

			throw error;
		}
	}

	@Post('/')
	async createCredentials(
		req: AuthenticatedRequest,
		_: Response,
		@Body payload: CreateCredentialDto,
	) {
		const newCredential = await this.credentialsService.createUnmanagedCredential(
			payload,
			req.user,
		);

		const project = await this.sharedCredentialsRepository.findCredentialOwningProject(
			newCredential.id,
		);

		this.eventService.emit('credentials-created', {
			user: req.user,
			credentialType: newCredential.type,
			credentialId: newCredential.id,
			publicApi: false,
			projectId: project?.id,
			projectType: project?.type,
			uiContext: payload.uiContext,
			isDynamic: newCredential.isResolvable ?? false,
			usesExternalSecrets: getExternalSecretExpressionPaths(payload.data).length > 0,
			jweEnabled: payload.data.jweEnabled === true,
		});

		if (newCredential.isResolvable) {
			this.eventService.emit('private-credential-created', {
				user: req.user,
				credentialType: newCredential.type,
				credentialId: newCredential.id,
				projectId: project?.id,
				projectType: project?.type,
			});
		}

		return newCredential;
	}

	@Patch('/:credentialId')
	@ProjectScope('credential:update')
	async updateCredentials(req: CredentialRequest.Update) {
		const {
			body,
			user,
			params: { credentialId },
		} = req;

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

		if (!credential) {
			this.logger.info('Attempt to update credential blocked due to lack of permissions', {
				credentialId,
				userId: user.id,
			});
			throw new NotFoundError(
				'Credential to be updated not found. You can only update credentials owned by you',
			);
		}

		if (credential.isManaged) {
			throw new BadRequestError('Managed credentials cannot be updated');
		}

		// We never want to allow users to change the oauthTokenData
		delete body.data?.oauthTokenData;

		// eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare
		const isTogglingToPrivate = body.isResolvable === true && credential.isResolvable === false;
		// eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare
		const isTogglingToStatic = body.isResolvable === false && credential.isResolvable === true;

		// Dynamic credentials and sharing are mutually exclusive: a credential that
		// is already shared with other projects (or globally) can't become dynamic.
		// Every credential has exactly one `credential:owner` sharing row, so any other
		// row means it's shared — checking against the owner role keeps this robust if
		// new sharing roles are introduced.
		if (isTogglingToPrivate) {
			const isShared =
				credential.isGlobal ||
				(credential.shared ?? []).some((sc) => sc.role !== 'credential:owner');
			if (isShared) {
				throw new BadRequestError(
					'This credential is shared. Remove sharing before making it private.',
				);
			}
		}

		const preparedCredentialData = await this.credentialsService.prepareUpdateData(
			req.user,
			req.body,
			credential,
			{ clearOauthTokenData: isTogglingToPrivate },
		);

		const newCredentialData = await this.credentialsService.createEncryptedData({
			id: credential.id,
			name: preparedCredentialData.name,
			type: preparedCredentialData.type,
			data: preparedCredentialData.data as unknown as ICredentialDataDecryptedObject,
		});

		// Update isGlobal if provided in the payload and user has permission
		const isGlobal = body.isGlobal;
		if (isGlobal !== undefined && isGlobal !== credential.isGlobal) {
			if (!this.licenseState.isSharingLicensed()) {
				throw new ForbiddenError('You are not licensed for sharing credentials');
			}

			const canShareGlobally = hasGlobalScope(req.user, 'credential:shareGlobally');
			if (!canShareGlobally) {
				throw new ForbiddenError(
					'You do not have permission to change global sharing for credentials',
				);
			}

			// Global sharing is sharing too, so it can't be combined with dynamic credentials.
			if (isGlobal && (body.isResolvable ?? credential.isResolvable)) {
				throw new BadRequestError('Private credentials cannot be shared');
			}
			newCredentialData.isGlobal = isGlobal;
		}

		newCredentialData.isResolvable = body.isResolvable ?? credential.isResolvable;
		const responseData = await this.credentialsService.update(
			credentialId,
			newCredentialData,
			body.data
				? (preparedCredentialData.data as unknown as ICredentialDataDecryptedObject)
				: undefined,
			{ deleteUserEntries: isTogglingToStatic },
		);

		if (responseData === null) {
			throw new NotFoundError(`Credential ID "${credentialId}" could not be found to be updated.`);
		}

		// Remove the encrypted data as it is not needed in the frontend
		const { data, shared, ...rest } = responseData;

		this.logger.debug('Credential updated', { credentialId });

		this.eventService.emit('credentials-updated', {
			user: req.user,
			credentialType: credential.type,
			credentialId: credential.id,
			isDynamic: newCredentialData.isResolvable ?? false,
			usesExternalSecrets: getExternalSecretExpressionPaths(preparedCredentialData.data).length > 0,
			jweEnabled:
				(preparedCredentialData.data as unknown as ICredentialDataDecryptedObject).jweEnabled ===
				true,
		});

		const wasResolvable = Boolean(credential.isResolvable);
		const willBeResolvable = Boolean(newCredentialData.isResolvable);
		if (!wasResolvable && willBeResolvable) {
			this.eventService.emit('private-credential-toggled-to-private', {
				user: req.user,
				credentialType: credential.type,
				credentialId: credential.id,
			});
		} else if (wasResolvable && !willBeResolvable) {
			this.eventService.emit('private-credential-toggled-to-static', {
				user: req.user,
				credentialType: credential.type,
				credentialId: credential.id,
			});
		}

		const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id);

		return { ...rest, scopes };
	}

	@Delete('/:credentialId')
	@ProjectScope('credential:delete')
	async deleteCredentials(req: CredentialRequest.Delete) {
		const { credentialId } = req.params;

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

		if (!credential) {
			this.logger.info('Attempt to delete credential blocked due to lack of permissions', {
				credentialId,
				userId: req.user.id,
			});
			throw new NotFoundError(
				'Credential to be deleted not found. You can only removed credentials owned by you',
			);
		}

		await this.credentialsService.delete(req.user, credential.id);

		this.eventService.emit('credentials-deleted', {
			user: req.user,
			credentialType: credential.type,
			credentialId: credential.id,
		});

		if (credential.isResolvable) {
			this.eventService.emit('private-credential-deleted', {
				user: req.user,
				credentialType: credential.type,
				credentialId: credential.id,
			});
		}

		return true;
	}

	@Licensed('feat:sharing')
	@Put('/:credentialId/share')
	async shareCredentials(req: CredentialRequest.Share) {
		const { credentialId } = req.params;
		const { shareWithIds } = req.body;

		if (
			!Array.isArray(shareWithIds) ||
			!shareWithIds.every((userId) => typeof userId === 'string')
		) {
			throw new BadRequestError('Bad request');
		}

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

		if (!credential) {
			throw new ForbiddenError();
		}

		const currentProjectIds = credential.shared
			.filter((sc) => sc.role === 'credential:user')
			.map((sc) => sc.projectId);
		const newProjectIds = shareWithIds;

		const toShare = utils.rightDiff([currentProjectIds, (id) => id], [newProjectIds, (id) => id]);
		const toUnshare = utils.rightDiff([newProjectIds, (id) => id], [currentProjectIds, (id) => id]);

		// Dynamic credentials can't be shared: each user connects their own at run time.
		// Unsharing (cleanup of any pre-existing shares) stays allowed.
		if (credential.isResolvable && toShare.length > 0) {
			throw new BadRequestError('Private credentials cannot be shared');
		}

		if (toShare.length > 0) {
			const canShare = await userHasScopes(req.user, ['credential:share'], false, {
				credentialId,
			});
			if (!canShare) {
				throw new ForbiddenError();
			}
		}

		if (toUnshare.length > 0) {
			const canUnshare = await userHasScopes(req.user, ['credential:unshare'], false, {
				credentialId,
			});
			if (!canUnshare) {
				throw new ForbiddenError();
			}
		}

		const unsharedProjectMembers =
			toUnshare.length > 0
				? await this.projectRelationRepository.findBy({ projectId: In(toUnshare) })
				: [];
		const affectedUserIds = [...new Set(unsharedProjectMembers.map((pr) => pr.userId))];

		let amountRemoved: number | null = null;
		let newShareeIds: string[] = [];

		const { manager: dbManager } = this.sharedCredentialsRepository;
		await dbManager.transaction(async (trx) => {
			const deleteResult = await trx.delete(SharedCredentials, {
				credentialsId: credentialId,
				projectId: In(toUnshare),
			});
			await this.enterpriseCredentialsService.shareWithProjects(
				req.user,
				credential.id,
				toShare,
				trx,
			);

			if (deleteResult.affected) {
				amountRemoved = deleteResult.affected;
				await this.connectionStatusProxy.cleanupOrphanedEntriesForUsers(affectedUserIds, trx);
			}

			newShareeIds = toShare;
		});

		this.eventService.emit('credentials-shared', {
			user: req.user,
			credentialType: credential.type,
			credentialId: credential.id,
			userIdSharer: req.user.id,
			userIdsShareesAdded: newShareeIds,
			shareesRemoved: amountRemoved,
		});

		const projectsRelations = await this.projectRelationRepository.findBy({
			projectId: In(newShareeIds),
			role: { slug: PROJECT_OWNER_ROLE_SLUG },
		});

		await this.userManagementMailer.notifyCredentialsShared({
			sharer: req.user,
			newShareeIds: projectsRelations.map((pr) => pr.userId),
			credentialsName: credential.name,
		});
	}

	@Put('/:credentialId/transfer')
	@ProjectScope('credential:move')
	async transfer(req: CredentialRequest.Transfer) {
		const body = z.object({ destinationProjectId: z.string() }).parse(req.body);

		return await this.enterpriseCredentialsService.transferOne(
			req.user,
			req.params.credentialId,
			body.destinationProjectId,
		);
	}
}
