import { deepCopy } from 'n8n-workflow';
import config from '@/config';
import { CredentialsService } from './credentials.service';
import { CredentialRequest, ListQuery } from '@/requests';
import { InternalHooks } from '@/InternalHooks';
import { Logger } from '@/Logger';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { NamingService } from '@/services/naming.service';
import { License } from '@/License';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { OwnershipService } from '@/services/ownership.service';
import { EnterpriseCredentialsService } from './credentials.service.ee';
import { Delete, Get, Licensed, Patch, Post, Put, RestController } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UserManagementMailer } from '@/UserManagement/email';
import * as Db from '@/Db';
import * as utils from '@/utils';
import { listQueryMiddleware } from '@/middlewares';

@RestController('/credentials')
export class CredentialsController {
	constructor(
		private readonly credentialsService: CredentialsService,
		private readonly enterpriseCredentialsService: EnterpriseCredentialsService,
		private readonly credentialsRepository: CredentialsRepository,
		private readonly namingService: NamingService,
		private readonly license: License,
		private readonly logger: Logger,
		private readonly ownershipService: OwnershipService,
		private readonly internalHooks: InternalHooks,
		private readonly userManagementMailer: UserManagementMailer,
	) {}

	@Get('/', { middlewares: listQueryMiddleware })
	async getMany(req: ListQuery.Request) {
		return await this.credentialsService.getMany(req.user, {
			listQueryOptions: req.listQueryOptions,
		});
	}

	@Get('/new')
	async generateUniqueName(req: CredentialRequest.NewName) {
		const requestedName = req.query.name ?? config.getEnv('credentials.defaultName');

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

	@Get('/:id')
	async getOne(req: CredentialRequest.Get) {
		if (this.license.isSharingEnabled()) {
			const { id: credentialId } = req.params;
			const includeDecryptedData = req.query.includeData === 'true';

			let credential = await this.credentialsRepository.findOne({
				where: { id: credentialId },
				relations: ['shared', 'shared.user'],
			});

			if (!credential) {
				throw new NotFoundError(
					'Could not load the credential. If you think this is an error, ask the owner to share it with you again',
				);
			}

			const userSharing = credential.shared?.find((shared) => shared.user.id === req.user.id);

			if (!userSharing && !req.user.hasGlobalScope('credential:read')) {
				throw new UnauthorizedError('Forbidden.');
			}

			credential = this.ownershipService.addOwnedByAndSharedWith(credential);

			// Below, if `userSharing` does not exist, it means this credential is being
			// fetched by the instance owner or an admin. In this case, they get the full data
			if (!includeDecryptedData || userSharing?.role === 'credential:user') {
				const { data: _, ...rest } = credential;
				return { ...rest };
			}

			const { data: _, ...rest } = credential;

			const decryptedData = this.credentialsService.redact(
				this.credentialsService.decrypt(credential),
				credential,
			);

			return { data: decryptedData, ...rest };
		}

		// non-enterprise

		const { id: credentialId } = req.params;
		const includeDecryptedData = req.query.includeData === 'true';

		const sharing = await this.credentialsService.getSharing(
			req.user,
			credentialId,
			{ allowGlobalScope: true, globalScope: 'credential:read' },
			['credentials'],
		);

		if (!sharing) {
			throw new NotFoundError(`Credential with ID "${credentialId}" could not be found.`);
		}

		const { credentials: credential } = sharing;

		const { data: _, ...rest } = credential;

		if (!includeDecryptedData) {
			return { ...rest };
		}

		const decryptedData = this.credentialsService.redact(
			this.credentialsService.decrypt(credential),
			credential,
		);

		return { data: decryptedData, ...rest };
	}

	@Post('/test')
	async testCredentials(req: CredentialRequest.Test) {
		if (this.license.isSharingEnabled()) {
			const { credentials } = req.body;

			const credentialId = credentials.id;
			const { ownsCredential } = await this.enterpriseCredentialsService.isOwned(
				req.user,
				credentialId,
			);

			const sharing = await this.enterpriseCredentialsService.getSharing(req.user, credentialId, {
				allowGlobalScope: true,
				globalScope: 'credential:read',
			});
			if (!ownsCredential) {
				if (!sharing) {
					throw new UnauthorizedError('Forbidden');
				}

				const decryptedData = this.credentialsService.decrypt(sharing.credentials);
				Object.assign(credentials, { data: decryptedData });
			}

			const mergedCredentials = deepCopy(credentials);
			if (mergedCredentials.data && sharing?.credentials) {
				const decryptedData = this.credentialsService.decrypt(sharing.credentials);
				mergedCredentials.data = this.credentialsService.unredact(
					mergedCredentials.data,
					decryptedData,
				);
			}

			return await this.credentialsService.test(req.user, mergedCredentials);
		}

		// non-enterprise

		const { credentials } = req.body;

		const sharing = await this.credentialsService.getSharing(req.user, credentials.id, {
			allowGlobalScope: true,
			globalScope: 'credential:read',
		});

		const mergedCredentials = deepCopy(credentials);
		if (mergedCredentials.data && sharing?.credentials) {
			const decryptedData = this.credentialsService.decrypt(sharing.credentials);
			mergedCredentials.data = this.credentialsService.unredact(
				mergedCredentials.data,
				decryptedData,
			);
		}

		return await this.credentialsService.test(req.user, mergedCredentials);
	}

	@Post('/')
	async createCredentials(req: CredentialRequest.Create) {
		const newCredential = await this.credentialsService.prepareCreateData(req.body);

		const encryptedData = this.credentialsService.createEncryptedData(null, newCredential);
		const credential = await this.credentialsService.save(newCredential, encryptedData, req.user);

		void this.internalHooks.onUserCreatedCredentials({
			user: req.user,
			credential_name: newCredential.name,
			credential_type: credential.type,
			credential_id: credential.id,
			public_api: false,
		});

		return credential;
	}

	@Patch('/:id')
	async updateCredentials(req: CredentialRequest.Update) {
		const { id: credentialId } = req.params;

		const sharing = await this.credentialsService.getSharing(
			req.user,
			credentialId,
			{
				allowGlobalScope: true,
				globalScope: 'credential:update',
			},
			['credentials'],
		);

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

		if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:update')) {
			this.logger.info('Attempt to update credential blocked due to lack of permissions', {
				credentialId,
				userId: req.user.id,
			});
			throw new UnauthorizedError('You can only update credentials owned by you');
		}

		const { credentials: credential } = sharing;

		const decryptedData = this.credentialsService.decrypt(credential);
		const preparedCredentialData = await this.credentialsService.prepareUpdateData(
			req.body,
			decryptedData,
		);
		const newCredentialData = this.credentialsService.createEncryptedData(
			credentialId,
			preparedCredentialData,
		);

		const responseData = await this.credentialsService.update(credentialId, newCredentialData);

		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: _, ...rest } = responseData;

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

		return { ...rest };
	}

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

		const sharing = await this.credentialsService.getSharing(
			req.user,
			credentialId,
			{
				allowGlobalScope: true,
				globalScope: 'credential:delete',
			},
			['credentials'],
		);

		if (!sharing) {
			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',
			);
		}

		if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:delete')) {
			this.logger.info('Attempt to delete credential blocked due to lack of permissions', {
				credentialId,
				userId: req.user.id,
			});
			throw new UnauthorizedError('You can only remove credentials owned by you');
		}

		const { credentials: credential } = sharing;

		await this.credentialsService.delete(credential);

		return true;
	}

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

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

		const isOwnedRes = await this.enterpriseCredentialsService.isOwned(req.user, credentialId);
		const { ownsCredential } = isOwnedRes;
		let { credential } = isOwnedRes;
		if (!ownsCredential || !credential) {
			credential = undefined;
			// Allow owners/admins to share
			if (req.user.hasGlobalScope('credential:share')) {
				const sharedRes = await this.enterpriseCredentialsService.getSharing(
					req.user,
					credentialId,
					{
						allowGlobalScope: true,
						globalScope: 'credential:share',
					},
				);
				credential = sharedRes?.credentials;
			}
			if (!credential) {
				throw new UnauthorizedError('Forbidden');
			}
		}

		const ownerIds = (
			await this.enterpriseCredentialsService.getSharings(
				Db.getConnection().createEntityManager(),
				credentialId,
				['shared'],
			)
		)
			.filter((e) => e.role === 'credential:owner')
			.map((e) => e.userId);

		let amountRemoved: number | null = null;
		let newShareeIds: string[] = [];
		await Db.transaction(async (trx) => {
			// remove all sharings that are not supposed to exist anymore
			const { affected } = await this.credentialsRepository.pruneSharings(trx, credentialId, [
				...ownerIds,
				...shareWithIds,
			]);
			if (affected) amountRemoved = affected;

			const sharings = await this.enterpriseCredentialsService.getSharings(trx, credentialId);

			// extract the new sharings that need to be added
			newShareeIds = utils.rightDiff(
				[sharings, (sharing) => sharing.userId],
				[shareWithIds, (shareeId) => shareeId],
			);

			if (newShareeIds.length) {
				await this.enterpriseCredentialsService.share(trx, credential, newShareeIds);
			}
		});

		void this.internalHooks.onUserSharedCredentials({
			user: req.user,
			credential_name: credential.name,
			credential_type: credential.type,
			credential_id: credential.id,
			user_id_sharer: req.user.id,
			user_ids_sharees_added: newShareeIds,
			sharees_removed: amountRemoved,
		});

		await this.userManagementMailer.notifyCredentialsShared({
			sharer: req.user,
			newShareeIds,
			credentialsName: credential.name,
		});
	}
}
