import type { CreateCredentialDto } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import {
	Project,
	CredentialsEntity,
	SharedCredentials,
	CredentialsRepository,
	ProjectRepository,
	SharedCredentialsRepository,
	UserRepository,
} from '@n8n/db';
import type { ListQueryDb, SlimProject, User, ICredentialsDb, ScopesField } from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope, PROJECT_OWNER_ROLE_SLUG, type Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import {
	In,
	type EntityManager,
	type FindOptionsRelations,
	type FindOptionsWhere,
} from '@n8n/typeorm';
import { CredentialDataError, Credentials, ErrorReporter } from 'n8n-core';
import type {
	ICredentialDataDecryptedObject,
	ICredentialsDecrypted,
	ICredentialType,
	IDataObject,
	INodeProperties,
	INodePropertyCollection,
} from 'n8n-workflow';
import {
	CREDENTIAL_BLANKING_VALUE,
	CREDENTIAL_EMPTY_VALUE,
	deepCopy,
	displayParameter,
	isExpression,
	isINodePropertyCollection,
	jsonParse,
	jsonStringify,
	NodeHelpers,
} from 'n8n-workflow';

import { CredentialTypes } from '@/credential-types';
import { createCredentialsFromCredentialsEntity, CredentialsHelper } from '@/credentials-helper';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { CredentialNotFoundError } from '@/errors/credential-not-found.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ExternalHooks } from '@/external-hooks';
import { validateEntity } from '@/generic-helpers';
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee';
import { validateOAuthUrl } from '@/oauth/validate-oauth-url';
import { userHasScopes } from '@/permissions.ee/check-access';
import type { CredentialRequest, ListQuery } from '@/requests';
import { CredentialsTester } from '@/services/credentials-tester.service';
import { OwnershipService } from '@/services/ownership.service';
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';

import { CredentialConnectionStatusProxy } from './credential-connection-status-proxy';
import {
	CredentialDependencyService,
	type CredentialDependencyFilter,
} from './credential-dependency.service';
import { CredentialsFinderService } from './credentials-finder.service';
import {
	validateAccessToReferencedSecretProviders,
	validateExternalSecretsPermissions,
} from './validation';

/** Sentinel placed at every leaf of a redacted httpCustomAuth JSON shape */
const CUSTOM_AUTH_JSON_REDACTED_VALUE = '***';

export type CredentialsGetSharedOptions =
	| { allowGlobalScope: true; globalScope: Scope }
	| { allowGlobalScope: false };

type PrepareUpdateDataOptions = {
	/**
	 * When true, the existing oauthTokenData is NOT carried forward into the
	 * updated credential blob. Used on Static→Private toggle.
	 */
	clearOauthTokenData?: boolean;
};

type UpdateOptions = {
	/** When true, delete all per-user entries for this credential (Private→Static toggle). */
	deleteUserEntries?: boolean;
};

type CreateCredentialOptions = CreateCredentialDto & {
	isManaged: boolean;
};

type GetManyCredentialsOptions = {
	listQueryOptions: ListQuery.Options;
	includeGlobal: boolean;
	includeData: boolean;
	onlySharedWithMe: boolean;
	filters?: {
		dependency?: CredentialDependencyFilter;
	};
};

type WorkflowCredentialResult = {
	id: string;
	name: string;
	type: string;
	createdAt: string;
	updatedAt: string;
	scopes: Scope[];
	isManaged: boolean;
	isGlobal: boolean;
	isResolvable: boolean;
	homeProject: SlimProject | null;
	sharedWithProjects: SlimProject[];
	currentUserHasAccess: boolean;
	connectedByMe?: boolean;
};

@Service()
export class CredentialsService {
	constructor(
		private readonly credentialsRepository: CredentialsRepository,
		private readonly credentialDependencyService: CredentialDependencyService,
		private readonly sharedCredentialsRepository: SharedCredentialsRepository,
		private readonly ownershipService: OwnershipService,
		private readonly logger: Logger,
		private readonly errorReporter: ErrorReporter,
		private readonly credentialsTester: CredentialsTester,
		private readonly externalHooks: ExternalHooks,
		private readonly credentialTypes: CredentialTypes,
		private readonly projectRepository: ProjectRepository,
		private readonly projectService: ProjectService,
		private readonly roleService: RoleService,
		private readonly userRepository: UserRepository,
		private readonly credentialsFinderService: CredentialsFinderService,
		private readonly credentialsHelper: CredentialsHelper,
		private readonly externalSecretsConfig: ExternalSecretsConfig,
		private readonly externalSecretsProviderAccessCheckService: SecretsProviderAccessCheckService,
		private readonly connectionStatusProxy: CredentialConnectionStatusProxy,
	) {}

	/**
	 * Sets `connectedByMe` on every resolvable credential in `credentials`,
	 * using a single bulk lookup against the per-user storage. Static
	 * (non-resolvable) credentials are left untouched.
	 *
	 * Mutates in place; callers may pass entities, decrypted DTOs, or plain
	 * object literals — any shape that carries `id` and `isResolvable`.
	 */
	async countConnectedUsers(credentialId: string): Promise<number> {
		return await this.connectionStatusProxy.countConnectedUsers(credentialId);
	}

	async populateConnectedByMe<T extends { id: string; isResolvable?: boolean }>(
		credentials: T[],
		user: User,
	): Promise<void> {
		const resolvable = credentials.filter((c) => c.isResolvable === true);
		if (resolvable.length === 0) return;

		const connected = await this.connectionStatusProxy.findConnectedCredentialIds(
			user.id,
			resolvable.map((c) => c.id),
		);

		for (const c of resolvable) {
			(c as T & { connectedByMe?: boolean }).connectedByMe = connected.has(c.id);
		}
	}

	private async addGlobalCredentials(
		credentials: CredentialsEntity[],
		includeData: boolean,
		dependencyFilter?: CredentialDependencyFilter,
		type?: string,
	): Promise<CredentialsEntity[]> {
		const globalCredentials = await this.credentialsRepository.findAllGlobalCredentials({
			includeData,
			...(type ? { type } : {}),
			filters: { dependency: dependencyFilter },
		});

		// Merge and deduplicate based on credential ID
		const credentialIds = new Set(credentials.map((c) => c.id));
		const newGlobalCreds = globalCredentials.filter((gc) => !credentialIds.has(gc.id));

		return [...credentials, ...newGlobalCreds];
	}

	/**
	 * Read the credential `type` filter from listQueryOptions before any repo
	 * call mutates it (toFindManyOptions wraps it in a Like(...) in place).
	 */
	private extractTypeFilter(listQueryOptions: ListQuery.Options): string | undefined {
		const filterType = listQueryOptions.filter?.type;
		return typeof filterType === 'string' && filterType !== '' ? filterType : undefined;
	}

	async getMany(
		user: User,
		options: {
			listQueryOptions?: ListQuery.Options;
			includeScopes?: boolean;
			includeData: true;
			onlySharedWithMe?: boolean;
			includeGlobal?: boolean;
			filters?: {
				externalSecretsStore?: string;
			};
		},
	): Promise<Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>>>;
	async getMany(
		user: User,
		options?: {
			listQueryOptions?: ListQuery.Options;
			includeScopes?: boolean;
			includeData?: boolean;
			onlySharedWithMe?: boolean;
			includeGlobal?: boolean;
			filters?: {
				externalSecretsStore?: string;
			};
		},
	): Promise<CredentialsEntity[]>;
	async getMany(
		user: User,
		{
			listQueryOptions = {},
			includeScopes = false,
			includeData = false,
			onlySharedWithMe = false,
			includeGlobal = false,
			filters = {},
		}: {
			listQueryOptions?: ListQuery.Options;
			includeScopes?: boolean;
			includeData?: boolean;
			onlySharedWithMe?: boolean;
			includeGlobal?: boolean;
			filters?: {
				externalSecretsStore?: string;
			};
		} = {},
	): Promise<Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>> | CredentialsEntity[]> {
		const { externalSecretsStore } = filters;
		const returnAll = hasGlobalScope(user, 'credential:list');
		const isDefaultSelect = !listQueryOptions.select;
		const dependencyFilter = externalSecretsStore
			? await this.credentialDependencyService.resolveExternalSecretsStoreDependencyFilter(
					externalSecretsStore,
				)
			: undefined;

		if (externalSecretsStore && !dependencyFilter) {
			return [];
		}

		// Auto-enable includeScopes when includeData is requested
		if (includeData) {
			includeScopes = true;
		}

		let credentials: CredentialsEntity[];

		if (returnAll) {
			credentials = await this.getManyForAdminUser(user, {
				listQueryOptions,
				includeGlobal,
				includeData,
				onlySharedWithMe,
				filters: { dependency: dependencyFilter },
			});
		} else {
			credentials = await this.getManyForMemberUser(user, {
				listQueryOptions,
				includeGlobal,
				includeData,
				onlySharedWithMe,
				filters: { dependency: dependencyFilter },
			});
		}

		return await this.enrichCredentials(
			credentials,
			user,
			isDefaultSelect,
			includeScopes,
			includeData,
			listQueryOptions,
			onlySharedWithMe,
		);
	}

	private async getManyForAdminUser(
		user: User,
		{
			listQueryOptions,
			includeGlobal,
			includeData,
			onlySharedWithMe,
			filters,
		}: GetManyCredentialsOptions,
	): Promise<CredentialsEntity[]> {
		const { dependency: dependencyFilter } = filters ?? {};
		const typeFilter = this.extractTypeFilter(listQueryOptions);

		// If onlySharedWithMe or dependency filtering is requested, use subquery approach.
		if (onlySharedWithMe || dependencyFilter) {
			const sharingOptions = {
				...(onlySharedWithMe ? { onlySharedWithMe: true } : {}),
			};
			const { credentials } = await this.credentialsRepository.getManyAndCountWithSharingSubquery(
				user,
				sharingOptions,
				{
					...listQueryOptions,
					...(includeData ? { includeData: true } : {}),
					filters: {
						dependency: dependencyFilter,
					},
				},
			);

			if (includeGlobal) {
				return await this.addGlobalCredentials(
					credentials,
					includeData,
					dependencyFilter,
					typeFilter,
				);
			}

			return credentials;
		}

		await this.applyPersonalProjectFilter(listQueryOptions);

		let credentials = await this.credentialsRepository.findMany({
			...listQueryOptions,
			...(includeData ? { includeData: true } : {}),
		});

		if (includeGlobal) {
			credentials = await this.addGlobalCredentials(
				credentials,
				includeData,
				dependencyFilter,
				typeFilter,
			);
		}

		return credentials;
	}

	private async getManyForMemberUser(
		user: User,
		{
			listQueryOptions,
			includeGlobal,
			includeData,
			onlySharedWithMe,
			filters,
		}: GetManyCredentialsOptions,
	): Promise<CredentialsEntity[]> {
		const { dependency: dependencyFilter } = filters ?? {};
		const typeFilter = this.extractTypeFilter(listQueryOptions);

		let isPersonalProject = false;
		let personalProjectOwnerId: string | null = null;

		// Check if filtering by personal project
		if (listQueryOptions.filter?.projectId) {
			const project = await this.projectRepository.findOneBy({
				id: listQueryOptions.filter.projectId as string,
			});
			if (!project) {
				return [];
			}
			isPersonalProject = project.type === 'personal';
			personalProjectOwnerId = project.creatorId;
		}

		// Prepare sharing options for the subquery
		const sharingOptions: {
			scopes?: Scope[];
			projectRoles?: string[];
			credentialRoles?: string[];
			isPersonalProject?: boolean;
			personalProjectOwnerId?: string;
			onlySharedWithMe?: boolean;
		} = {};

		if (isPersonalProject && personalProjectOwnerId) {
			// Prevent users from accessing another user's personal project credentials
			if (personalProjectOwnerId !== user.id && !hasGlobalScope(user, 'credential:read')) {
				return [];
			}
			sharingOptions.isPersonalProject = true;
			sharingOptions.personalProjectOwnerId = personalProjectOwnerId;
		} else if (onlySharedWithMe) {
			sharingOptions.onlySharedWithMe = true;
		} else {
			// Get roles from scopes
			const projectRoles = await this.roleService.rolesWithScope('project', ['credential:read']);
			const credentialRoles = await this.roleService.rolesWithScope('credential', [
				'credential:read',
			]);
			sharingOptions.scopes = ['credential:read'];
			sharingOptions.projectRoles = projectRoles;
			sharingOptions.credentialRoles = credentialRoles;
		}

		// Use the new subquery-based repository method
		const { credentials } = await this.credentialsRepository.getManyAndCountWithSharingSubquery(
			user,
			sharingOptions,
			{
				...listQueryOptions,
				...(includeData ? { includeData: true } : {}),
				filters: {
					dependency: dependencyFilter,
				},
			},
		);

		if (includeGlobal) {
			return await this.addGlobalCredentials(
				credentials,
				includeData,
				dependencyFilter,
				typeFilter,
			);
		}

		return credentials;
	}

	private async applyPersonalProjectFilter(listQueryOptions: ListQuery.Options): Promise<void> {
		const projectId =
			typeof listQueryOptions.filter?.projectId === 'string'
				? listQueryOptions.filter.projectId
				: undefined;

		if (!projectId) {
			return;
		}

		let project: Project | undefined;
		try {
			project = await this.projectService.getProject(projectId);
		} catch {}

		if (project?.type === 'personal') {
			listQueryOptions.filter = {
				...listQueryOptions.filter,
				withRole: 'credential:owner',
			};
		}
	}

	private async enrichCredentials(
		credentials: CredentialsEntity[],
		user: User,
		isDefaultSelect: boolean,
		includeScopes: boolean,
		includeData: true,
		listQueryOptions: ListQuery.Options,
		onlySharedWithMe: boolean,
	): Promise<Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>>>;
	private async enrichCredentials(
		credentials: CredentialsEntity[],
		user: User,
		isDefaultSelect: boolean,
		includeScopes: boolean,
		includeData: boolean,
		listQueryOptions: ListQuery.Options,
		onlySharedWithMe: boolean,
	): Promise<CredentialsEntity[]>;
	private async enrichCredentials(
		credentials: CredentialsEntity[],
		user: User,
		isDefaultSelect: boolean,
		includeScopes: boolean,
		includeData: boolean,
		listQueryOptions: ListQuery.Options,
		onlySharedWithMe: boolean,
	): Promise<Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>> | CredentialsEntity[]> {
		if (isDefaultSelect) {
			// Since we're filtering using project ID as part of the relation,
			// we end up filtering out all the other relations, meaning that if
			// it's shared to a project, it won't be able to find the home project.
			// To solve this, we have to get all the relation now, even though
			// we're deleting them later.
			credentials = await this.populateSharedRelations(
				credentials,
				listQueryOptions,
				onlySharedWithMe,
			);
		}

		if (includeScopes) {
			credentials = await this.addScopesToCredentials(credentials, user);
		}

		if (includeData) {
			const decrypted = await this.addDecryptedDataToCredentials(credentials);
			await this.populateConnectedByMe(decrypted, user);
			return decrypted;
		}

		await this.populateConnectedByMe(credentials, user);
		return credentials;
	}

	private async populateSharedRelations(
		credentials: CredentialsEntity[],
		listQueryOptions: ListQuery.Options,
		onlySharedWithMe: boolean,
	): Promise<CredentialsEntity[]> {
		const needsRelations =
			listQueryOptions.filter?.shared &&
			typeof listQueryOptions.filter.shared === 'object' &&
			'projectId' in listQueryOptions.filter.shared
				? listQueryOptions.filter.shared.projectId
				: onlySharedWithMe;

		if (needsRelations) {
			const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials(
				credentials.map((c) => c.id),
			);
			credentials.forEach((c) => {
				c.shared = relations.filter((r) => r.credentialsId === c.id);
			});
		}

		return credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c));
	}

	private async addScopesToCredentials(
		credentials: CredentialsEntity[],
		user: User,
	): Promise<CredentialsEntity[]> {
		const projectRelations = await this.projectService.getProjectRelationsForUser(user);
		return credentials.map((c) => this.roleService.addScopes(c, user, projectRelations));
	}

	private async addDecryptedDataToCredentials(
		credentials: CredentialsEntity[],
	): Promise<Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>>> {
		return await Promise.all(
			credentials.map(async (c: CredentialsEntity & ScopesField) => {
				const data = c.scopes.includes('credential:update') ? await this.decrypt(c) : undefined;

				// We never want to expose the oauthTokenData to the frontend, but it
				// expects it to check if the credential is already connected.
				if (data?.oauthTokenData) {
					data.oauthTokenData = true;
				}

				return {
					...c,
					data,
				} satisfies ICredentialsDecrypted<ICredentialDataDecryptedObject>;
			}),
		);
	}

	/**
	 * Returns credentials that are both accessible to the user AND accessible to the project.
	 * A credential shared with the project but not with the requesting user would be excluded.
	 * @param user The user making the request
	 * @param options.workflowId The workflow that is being edited
	 * @param options.projectId The project owning the workflow This is useful
	 * for workflows that have not been saved yet.
	 */
	async getCredentialsAUserCanUseInAWorkflow(
		user: User,
		options: { workflowId: string } | { projectId: string },
	): Promise<WorkflowCredentialResult[]> {
		// necessary to get the scopes
		const projectRelations = await this.projectService.getProjectRelationsForUser(user);

		// get all credentials the user has access to (including global credentials)
		const allCredentials = await this.credentialsFinderService.findCredentialsForUser(user, [
			'credential:read',
		]);

		// get all credentials the workflow or project has access to
		const allCredentialsForWorkflow =
			'workflowId' in options
				? (await this.findAllCredentialIdsForWorkflow(options.workflowId)).map((c) => c.id)
				: (await this.findAllCredentialIdsForProject(options.projectId)).map((c) => c.id);

		// the intersection of both is all credentials the user can use in this
		// workflow or project
		const intersection = allCredentials.filter(
			(c) => allCredentialsForWorkflow.includes(c.id) || c.isGlobal,
		);

		if (intersection.length > 0) {
			const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials(
				intersection.map((c) => c.id),
			);
			intersection.forEach((c) => {
				c.shared = relations.filter((r) => r.credentialsId === c.id);
			});
		}

		const enriched = intersection
			.map((c) => this.ownershipService.addOwnedByAndSharedWith(c))
			.map((c) => this.roleService.addScopes(c, user, projectRelations)) as Array<
			ListQueryDb.Credentials.WithOwnedByAndSharedWith & ScopesField
		>;

		const result = enriched.map((c) => ({
			id: c.id,
			name: c.name,
			type: c.type,
			createdAt: c.createdAt.toISOString(),
			updatedAt: c.updatedAt.toISOString(),
			scopes: c.scopes,
			isManaged: c.isManaged,
			isGlobal: c.isGlobal,
			isResolvable: c.isResolvable,
			homeProject: c.homeProject,
			sharedWithProjects: c.sharedWithProjects,
			currentUserHasAccess: true,
		}));

		await this.populateConnectedByMe(result, user);
		return result;
	}

	async findAllGlobalCredentialIds(includeData: boolean = false): Promise<CredentialsEntity[]> {
		const globalCredentials = await this.credentialsRepository.findAllGlobalCredentials({
			includeData,
		});
		return globalCredentials;
	}

	async findAllCredentialIdsForWorkflow(workflowId: string): Promise<CredentialsEntity[]> {
		// If the workflow is owned by a personal project and the owner of the
		// project has global read permissions it can use all personal credentials.
		const user = await this.userRepository.findPersonalOwnerForWorkflow(workflowId);
		if (user && hasGlobalScope(user, 'credential:read')) {
			return await this.credentialsRepository.findAllPersonalCredentials();
		}

		// Otherwise the workflow can only use credentials from projects it's part
		// of.
		return await this.credentialsRepository.findAllCredentialsForWorkflow(workflowId);
	}

	async findAllCredentialIdsForProject(projectId: string): Promise<CredentialsEntity[]> {
		// If this is a personal project and the owner of the project has global
		// read permissions then all workflows in that project can use all
		// credentials of all personal projects.
		const user = await this.userRepository.findPersonalOwnerForProject(projectId);
		if (user && hasGlobalScope(user, 'credential:read')) {
			return await this.credentialsRepository.findAllPersonalCredentials();
		}

		// Otherwise only the credentials in this project can be used.
		return await this.credentialsRepository.findAllCredentialsForProject(projectId);
	}

	/**
	 * Retrieve the sharing that matches a user and a credential.
	 */
	// TODO: move to SharedCredentialsService
	async getSharing(
		user: User,
		credentialId: string,
		globalScopes: Scope[],
		relations: FindOptionsRelations<SharedCredentials> = { credentials: true },
	): Promise<SharedCredentials | null> {
		let where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };

		if (!hasGlobalScope(user, globalScopes, { mode: 'allOf' })) {
			where = {
				...where,
				role: 'credential:owner',
				project: {
					projectRelations: {
						role: { slug: PROJECT_OWNER_ROLE_SLUG },
						userId: user.id,
					},
				},
			};
		}

		return await this.sharedCredentialsRepository.findOne({
			where,
			relations,
		});
	}

	async prepareUpdateData(
		user: User,
		data: CredentialRequest.CredentialProperties,
		existingCredential: CredentialsEntity,
		options?: PrepareUpdateDataOptions,
	): Promise<CredentialsEntity> {
		const decryptedData = await this.decrypt(existingCredential, true);

		// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain -- credential will always have an owner
		const projectOwningCredential = existingCredential.shared?.find(
			(shared) => shared.role === 'credential:owner',
		)!;

		await validateExternalSecretsPermissions({
			user,
			projectId: projectOwningCredential.projectId,
			dataToSave: data.data,
			decryptedExistingData: decryptedData,
		});

		if (this.externalSecretsConfig.externalSecretsForProjects && data.data) {
			await validateAccessToReferencedSecretProviders(
				projectOwningCredential.projectId,
				data.data,
				this.externalSecretsProviderAccessCheckService,
				'update',
			);
		}

		const mergedData = deepCopy(data);
		if (mergedData.data) {
			mergedData.data = this.unredact(
				mergedData.data,
				decryptedData,
				this.getCredentialTypeProperties(existingCredential.type),
			);
		}

		// This saves us a merge but requires some type casting. These
		// types are compatible for this case.
		const updateData = this.credentialsRepository.create(mergedData as ICredentialsDb);

		await validateEntity(updateData);

		// Do not overwrite the oauth data else data like the access or refresh token would get lost
		// every time anybody changes anything on the credentials even if it is just the name.
		// Exception: when toggling to private (Static→Private), the shared token must be cleared.
		if (decryptedData.oauthTokenData && !options?.clearOauthTokenData) {
			// @ts-ignore
			updateData.data.oauthTokenData = decryptedData.oauthTokenData;
		}

		this.validateOAuthCredentialUrls(
			updateData.type,
			updateData.data as unknown as ICredentialDataDecryptedObject,
		);
		return updateData;
	}

	async createEncryptedData(credential: {
		id: string | null;
		name: string;
		type: string;
		data: ICredentialDataDecryptedObject;
	}): Promise<ICredentialsDb> {
		const credentials = new Credentials(
			{ id: credential.id, name: credential.name },
			credential.type,
		);

		await credentials.setData(credential.data);

		const newCredentialData = credentials.getDataToSave() as ICredentialsDb;

		// Add special database related data
		newCredentialData.updatedAt = new Date();

		return newCredentialData;
	}

	/**
	 * Decrypts the credentials data and redacts the content by default.
	 *
	 * If `includeRawData` is set to true it will not redact the data.
	 */
	async decrypt(credential: CredentialsEntity, includeRawData = false) {
		const coreCredential = createCredentialsFromCredentialsEntity(credential);
		try {
			const data = await coreCredential.getData();
			if (includeRawData) {
				return data;
			}
			return this.redact(data, credential);
		} catch (error) {
			if (error instanceof CredentialDataError) {
				this.errorReporter.error(error, {
					level: 'error',
					extra: { credentialId: credential.id },
					tags: { credentialType: credential.type },
				});
				return {};
			}
			throw error;
		}
	}

	async update(
		credentialId: string,
		newCredentialData: ICredentialsDb,
		decryptedCredentialData?: ICredentialDataDecryptedObject,
		options?: UpdateOptions,
	) {
		await this.externalHooks.run('credentials.update', [newCredentialData]);

		return await this.credentialsRepository.manager.transaction(async (transactionManager) => {
			// Update the credentials in DB
			await transactionManager.update(CredentialsEntity, credentialId, newCredentialData);

			if (options?.deleteUserEntries) {
				await this.connectionStatusProxy.deleteAllUserEntries(credentialId, transactionManager);
			}

			if (decryptedCredentialData) {
				await this.credentialDependencyService.syncExternalSecretProviderDependenciesForCredential({
					credentialId,
					decryptedCredentialData,
					entityManager: transactionManager,
				});
			}

			// We sadly get nothing back from "update". Neither if it updated a record
			// nor the new value. So query now the updated entry.
			return await transactionManager.findOneBy(CredentialsEntity, { id: credentialId });
		});
	}

	private async resolveOwningProjectIdForNewCredential(
		user: User,
		projectId: string | undefined,
		entityManager?: EntityManager,
	): Promise<string> {
		if (projectId !== undefined) {
			return projectId;
		}
		const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(
			user.id,
			entityManager,
		);
		// Chat users are not allowed to create credentials even within their personal project,
		// so even though we found the project ensure it gets found via expected scope too.
		return personalProject.id;
	}

	async save(
		credential: CredentialsEntity,
		encryptedData: ICredentialsDb,
		user: User,
		projectId: string,
		decryptedCredentialData?: ICredentialDataDecryptedObject,
	) {
		// To avoid side effects
		const newCredential = new CredentialsEntity();
		Object.assign(newCredential, credential, encryptedData);

		await this.externalHooks.run('credentials.create', [encryptedData]);

		const { manager: dbManager } = this.credentialsRepository;
		const result = await dbManager.transaction(async (transactionManager) => {
			const project = await this.projectService.getProjectWithScope(
				user,
				projectId,
				['credential:create'],
				transactionManager,
			);

			if (project === null) {
				if (!(await transactionManager.existsBy(Project, { id: projectId }))) {
					throw new NotFoundError('Project not found');
				}
				throw new ForbiddenError(
					"You don't have the permissions to save the credential in this project.",
				);
			}

			const savedCredential = await transactionManager.save<CredentialsEntity>(newCredential);

			savedCredential.data = newCredential.data;

			const newSharedCredential = this.sharedCredentialsRepository.create({
				role: 'credential:owner',
				credentials: savedCredential,
				projectId: project.id,
			});

			await transactionManager.save<SharedCredentials>(newSharedCredential);

			if (decryptedCredentialData) {
				await this.credentialDependencyService.upsertExternalSecretProviderDependenciesForCredential(
					{
						credentialId: savedCredential.id,
						decryptedCredentialData,
						entityManager: transactionManager,
					},
				);
			}

			return savedCredential;
		});
		this.logger.debug('New credential created', {
			credentialId: newCredential.id,
			ownerId: user.id,
		});
		return result;
	}

	/**
	 * Deletes a credential.
	 *
	 * If the user does not have permission to delete the credential this does
	 * nothing and returns void.
	 */
	async delete(user: User, credentialId: string) {
		await this.externalHooks.run('credentials.delete', [credentialId]);

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

		if (!credential) {
			return;
		}

		await this.credentialsRepository.remove(credential);
	}

	async test(userId: User['id'], credentials: ICredentialsDecrypted) {
		return await this.credentialsTester.testCredentials(userId, credentials.type, credentials);
	}

	async testById(userId: User['id'], credentialId: string) {
		const storedCredential = await this.credentialsFinderService.findCredentialById(credentialId);

		if (!storedCredential) {
			throw new CredentialNotFoundError(credentialId);
		}

		const credentials = await this.prepareCredentialsForTest({ storedCredential });
		return await this.test(userId, credentials);
	}

	async testWithCredentials(user: User, credentials: ICredentialsDecrypted) {
		const storedCredential = await this.credentialsFinderService.findCredentialForUser(
			credentials.id,
			user,
			['credential:read'],
		);

		if (!storedCredential) {
			throw new CredentialNotFoundError(credentials.id);
		}

		const mergedCredentials = await this.prepareCredentialsForTest({
			storedCredential,
			user,
			credentialsToTest: credentials,
		});

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

	// Take data and replace all sensitive values with a sentinel value.
	// This will replace password fields and oauth data.
	redact(data: ICredentialDataDecryptedObject, credential: CredentialsEntity) {
		const copiedData = deepCopy(data);

		let credType: ICredentialType;
		try {
			credType = this.credentialTypes.getByName(credential.type);
		} catch {
			// This _should_ only happen when testing. If it does happen in
			// production it means it's either a mangled credential or a
			// credential for a removed community node. Either way, there's
			// no way to know what to redact.
			return data;
		}

		const getExtendedProps = (type: ICredentialType) => {
			const props: INodeProperties[] = [];
			for (const e of type.extends ?? []) {
				const extendsType = this.credentialTypes.getByName(e);
				const extendedProps = getExtendedProps(extendsType);
				NodeHelpers.mergeNodeProperties(props, extendedProps);
			}
			NodeHelpers.mergeNodeProperties(props, type.properties);
			return props;
		};
		const properties = getExtendedProps(credType);
		return this.redactValues(copiedData, properties);
	}

	private redactValues(
		data: ICredentialDataDecryptedObject,
		props: INodeProperties[],
	): ICredentialDataDecryptedObject {
		for (const dataKey of Object.keys(data)) {
			// The frontend only cares that this value isn't falsy.
			if (dataKey === 'oauthTokenData' || dataKey === 'csrfSecret') {
				if (data[dataKey].toString().length > 0) {
					data[dataKey] = CREDENTIAL_BLANKING_VALUE;
				} else {
					data[dataKey] = CREDENTIAL_EMPTY_VALUE;
				}
				continue;
			}

			const prop = props.find((v) => v.name === dataKey);
			if (!prop) {
				continue;
			}

			if (prop.type === 'fixedCollection' && prop.options?.length) {
				const dataObject = data[dataKey] as IDataObject;
				for (const option of prop.options) {
					if (isINodePropertyCollection(option)) {
						this.redactCollectionOption(dataObject, option);
					}
				}
			}

			if (
				prop.typeOptions?.password &&
				(!data[dataKey].toString().startsWith('={{') || prop.noDataExpression)
			) {
				if (data[dataKey].toString().length > 0) {
					data[dataKey] = CREDENTIAL_BLANKING_VALUE;
				} else {
					data[dataKey] = CREDENTIAL_EMPTY_VALUE;
				}
				continue;
			}

			if (prop.typeOptions?.redactJsonLeaves) {
				const jsonStr = String(data[dataKey] ?? '');
				if (!jsonStr) {
					data[dataKey] = CREDENTIAL_EMPTY_VALUE;
				} else {
					try {
						const parsed = jsonParse<unknown>(jsonStr);
						data[dataKey] = JSON.stringify(this.redactJsonLeaves(parsed), null, 2);
					} catch {
						// Not parseable (e.g. an expression) — leave as-is
					}
				}
			}
		}

		return data;
	}

	private redactCollectionOption(data: IDataObject, option: INodePropertyCollection) {
		const collectionValuesKey = option.name;
		const values = data?.[collectionValuesKey];
		if (Array.isArray(values)) {
			for (let i = 0; i < values.length; i++) {
				values[i] = this.redactValues(values[i] as ICredentialDataDecryptedObject, option.values);
			}
		} else if (typeof values === 'object' && values !== null) {
			data[collectionValuesKey] = this.redactValues(
				values as ICredentialDataDecryptedObject,
				option.values,
			);
		}
	}

	private redactJsonLeaves(obj: unknown): unknown {
		if (Array.isArray(obj)) return obj.map((item) => this.redactJsonLeaves(item));
		if (typeof obj === 'object' && obj !== null) {
			return Object.fromEntries(
				Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
					k,
					this.redactJsonLeaves(v),
				]),
			);
		}
		return CUSTOM_AUTH_JSON_REDACTED_VALUE;
	}

	private mergeRedactedJsonLeaves(newVal: unknown, savedVal: unknown): unknown {
		if (newVal === CUSTOM_AUTH_JSON_REDACTED_VALUE) return savedVal;
		if (Array.isArray(newVal) && Array.isArray(savedVal)) {
			return newVal.map((item, i) => this.mergeRedactedJsonLeaves(item, savedVal[i]));
		}
		// Type mismatch involving arrays: user made a structural change — preserve new value as-is
		if (Array.isArray(newVal) || Array.isArray(savedVal)) {
			return newVal;
		}
		if (
			typeof newVal === 'object' &&
			newVal !== null &&
			typeof savedVal === 'object' &&
			savedVal !== null
		) {
			return Object.fromEntries(
				Object.entries(newVal as Record<string, unknown>).map(([k, v]) => [
					k,
					this.mergeRedactedJsonLeaves(v, (savedVal as Record<string, unknown>)[k]),
				]),
			);
		}
		return newVal;
	}

	private unredactRestoreValues(unmerged: any, replacement: any) {
		// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
		for (const [key, value] of Object.entries(unmerged)) {
			if (value === CREDENTIAL_BLANKING_VALUE || value === CREDENTIAL_EMPTY_VALUE) {
				// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
				unmerged[key] = replacement[key];
			} else if (
				typeof value === 'object' &&
				value !== null &&
				key in replacement &&
				// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
				typeof replacement[key] === 'object' &&
				// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
				replacement[key] !== null
			) {
				// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
				this.unredactRestoreValues(value, replacement[key]);
			}
		}
	}

	getCredentialTypeProperties(credentialType: string): INodeProperties[] {
		try {
			return this.credentialTypes.getByName(credentialType).properties;
		} catch {
			return [];
		}
	}

	// Take redacted data (e.g. from the frontend) and merge it with saved data to produce a
	// fully unredacted version ready for saving or testing.
	unredact(
		redactedData: ICredentialDataDecryptedObject,
		savedData: ICredentialDataDecryptedObject,
		props: INodeProperties[] = [],
	) {
		// Replace field-level blank sentinels (CREDENTIAL_BLANKING_VALUE / CREDENTIAL_EMPTY_VALUE)
		const mergedData = deepCopy(redactedData);
		this.unredactRestoreValues(mergedData, savedData);

		// Merge leaf-level *** sentinels for fields marked with typeOptions.redactJsonLeaves
		for (const prop of props) {
			if (!prop.typeOptions?.redactJsonLeaves) continue;
			if (!(prop.name in mergedData) || !(prop.name in savedData)) continue;
			try {
				const newJson: unknown = jsonParse(String(mergedData[prop.name]));
				const savedJson: unknown = jsonParse(String(savedData[prop.name]));
				mergedData[prop.name] = jsonStringify(this.mergeRedactedJsonLeaves(newJson, savedJson));
			} catch {
				// Not parseable JSON — leave as-is
			}
		}

		return mergedData;
	}

	async getOne(user: User, credentialId: string, includeDecryptedData: boolean) {
		let sharing: SharedCredentials | null = null;
		let decryptedData: ICredentialDataDecryptedObject | null = null;

		sharing = includeDecryptedData
			? // Try to get the credential with `credential:update` scope, which
				// are required for decrypting the data.
				await this.getSharing(user, credentialId, [
					'credential:read',
					// TODO: Enable this once the scope exists and has been added to the
					// global:owner role.
					// 'credential:decrypt',
				])
			: null;

		if (sharing) {
			// Decrypt the data if we found the credential with the `credential:update`
			// scope.
			decryptedData = await this.decrypt(sharing.credentials);
		} else {
			// Otherwise try to find them with only the `credential:read` scope. In
			// that case we return them without the decrypted data.
			sharing = await this.getSharing(user, credentialId, ['credential:read']);
		}

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

		const { credentials: credential } = sharing;

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

		const enriched: typeof rest & { connectedByMe?: boolean; connectedUserCount?: number } = rest;
		await this.populateConnectedByMe([enriched], user);

		if (credential.isResolvable) {
			enriched.connectedUserCount = await this.countConnectedUsers(credential.id);
		}

		if (decryptedData) {
			// We never want to expose the oauthTokenData to the frontend, but it
			// expects it to check if the credential is already connected.
			if (credential.isResolvable) {
				// For resolvable credentials, the "connected" signal lives in the
				// per-user storage — mirror that into the existing oauthTokenData
				// flag the frontend banner already reads.
				if (enriched.connectedByMe) {
					decryptedData.oauthTokenData = true;
				} else {
					delete decryptedData.oauthTokenData;
				}
			} else if (decryptedData?.oauthTokenData) {
				decryptedData.oauthTokenData = true;
			}
			return { data: decryptedData, ...enriched };
		}
		return { ...enriched };
	}

	async getCredentialScopes(user: User, credentialId: string): Promise<Scope[]> {
		const userProjectRelations = await this.projectService.getProjectRelationsForUser(user);
		const projectIds = [...new Set(userProjectRelations.map((pr) => pr.projectId))];
		// Postgres rejects `IN ()`; SQLite tolerates it. Skip the query when there is no project scope.
		if (projectIds.length === 0) {
			return this.roleService.combineResourceScopes('credential', user, [], userProjectRelations);
		}
		const shared = await this.sharedCredentialsRepository.find({
			where: {
				projectId: In(projectIds),
				credentialsId: credentialId,
			},
		});
		return this.roleService.combineResourceScopes('credential', user, shared, userProjectRelations);
	}

	/**
	 * Transfers all credentials owned by a project to another one.
	 * This has only been tested for personal projects. It may need to be amended
	 * for team projects.
	 **/
	async transferAll(fromProjectId: string, toProjectId: string, trx?: EntityManager) {
		trx = trx ?? this.credentialsRepository.manager;

		// Get all shared credentials for both projects.
		const allSharedCredentials = await trx.findBy(SharedCredentials, {
			projectId: In([fromProjectId, toProjectId]),
		});

		const sharedCredentialsOfFromProject = allSharedCredentials.filter(
			(sc) => sc.projectId === fromProjectId,
		);

		// For all credentials that the from-project owns transfer the ownership
		// to the to-project.
		// This will override whatever relationship the to-project already has to
		// the resources at the moment.
		const ownedCredentialIds = sharedCredentialsOfFromProject
			.filter((sc) => sc.role === 'credential:owner')
			.map((sc) => sc.credentialsId);

		await this.sharedCredentialsRepository.makeOwner(ownedCredentialIds, toProjectId, trx);

		// Delete the relationship to the from-project.
		await this.sharedCredentialsRepository.deleteByIds(ownedCredentialIds, fromProjectId, trx);

		// Transfer relationships that are not `credential:owner`.
		// This will NOT override whatever relationship the to-project already has
		// to the resource at the moment.
		const sharedCredentialIdsOfTransferee = allSharedCredentials
			.filter((sc) => sc.projectId === toProjectId)
			.map((sc) => sc.credentialsId);

		// All resources that are shared with the from-project, but not with the
		// to-project.
		const sharedCredentialsToTransfer = sharedCredentialsOfFromProject.filter(
			(sc) =>
				sc.role !== 'credential:owner' &&
				!sharedCredentialIdsOfTransferee.includes(sc.credentialsId),
		);

		await trx.insert(
			SharedCredentials,
			sharedCredentialsToTransfer.map((sc) => ({
				credentialsId: sc.credentialsId,
				projectId: toProjectId,
				role: sc.role,
			})),
		);
	}

	async replaceCredentialContentsForSharee(
		user: User,
		credential: CredentialsEntity,
		decryptedData: ICredentialDataDecryptedObject,
		mergedCredentials: ICredentialsDecrypted,
	) {
		// We may want to change this to 'credential:decrypt' if that gets added, but this
		// works for now. The only time we wouldn't want to do this is if the user
		// could actually be testing the credential before saving it, so this should cover
		// the cases we need it for.
		if (
			!(await userHasScopes(user, ['credential:update'], false, { credentialId: credential.id }))
		) {
			mergedCredentials.data = decryptedData;
		}
	}

	/**
	 * Create a new credential in user's account and return it along the scopes
	 * If a projectId is send, then it also binds the credential to that specific project
	 */
	async createUnmanagedCredential(dto: CreateCredentialDto, user: User) {
		return await this.createCredential({ ...dto, isManaged: false }, user);
	}

	/**
	 * Used to check credential data for creating a new credential.
	 * TODO: consider refactoring enable using this for both creating and updating, right now only used for creation
	 * (likely only affects the validateExternalSecretsPermissions call)
	 */
	async checkCredentialData(
		type: string,
		data: ICredentialDataDecryptedObject,
		user: User,
		projectId: string,
	): Promise<void> {
		// check mandatory fields are present
		const credentialProperties = this.credentialsHelper.getCredentialsProperties(type);
		for (const property of credentialProperties) {
			/*
			 * displayOpyions is possibly undefined, which causes displayParameter to default `true`, which is expected behavior for UI
			 * however, missing `displayOptions` should not default to true for validation purposes as credentials can be overridden
			 * without displayOptions being explicitly set on every credential definition
			 */
			if (
				property.required &&
				property.displayOptions !== undefined &&
				displayParameter(data, property, null, null)
			) {
				// Check if value is present in data, if not, check if default value exists
				const value = data[property.name];
				const hasDefault =
					property.default !== undefined && property.default !== null && property.default !== '';
				if ((value === undefined || value === null || value === '') && !hasDefault) {
					throw new BadRequestError(
						`The field "${property.name}" is mandatory for credentials of type "${type}"`,
					);
				}
			}
		}
		await validateExternalSecretsPermissions({ user, projectId, dataToSave: data });
		this.validateOAuthCredentialUrls(type, data);
	}

	/**
	 * Validates that OAuth credential URL fields (authUrl, accessTokenUrl, etc.) use http/https only.
	 * No-op if the credential type is not OAuth1 or OAuth2 (including extended types).
	 */
	private validateOAuthCredentialUrls(type: string, data: ICredentialDataDecryptedObject) {
		const parentTypes = this.credentialTypes.getParentTypes(type) ?? [];
		const isOAuth2 = type === 'oAuth2Api' || parentTypes.includes('oAuth2Api');
		const isOAuth1 = type === 'oAuth1Api' || parentTypes.includes('oAuth1Api');
		if (isOAuth2) {
			const oauthUrlFields = ['authUrl', 'accessTokenUrl', 'serverUrl'] as const;
			for (const field of oauthUrlFields) {
				const value = data[field];
				if (typeof value === 'string' && value.trim() !== '' && !isExpression(value)) {
					validateOAuthUrl(value);
				}
			}
		}
		if (isOAuth1) {
			const oauthUrlFields = ['authUrl', 'requestTokenUrl', 'accessTokenUrl'] as const;
			for (const field of oauthUrlFields) {
				const value = data[field];
				if (typeof value === 'string' && value.trim() !== '' && !isExpression(value)) {
					validateOAuthUrl(value);
				}
			}
		}
	}

	/**
	 * Create a new managed credential in user's account and return it along the scopes.
	 * Managed credentials are managed by n8n and cannot be edited by the user.
	 */
	async createManagedCredential(dto: CreateCredentialDto, user: User) {
		return await this.createCredential({ ...dto, isManaged: true }, user);
	}

	private async createCredential(opts: CreateCredentialOptions, user: User) {
		const targetProjectId = await this.resolveOwningProjectIdForNewCredential(user, opts.projectId);

		await this.checkCredentialData(
			opts.type,
			opts.data as ICredentialDataDecryptedObject,
			user,
			targetProjectId,
		);
		if (this.externalSecretsConfig.externalSecretsForProjects) {
			await validateAccessToReferencedSecretProviders(
				targetProjectId,
				opts.data as ICredentialDataDecryptedObject,
				this.externalSecretsProviderAccessCheckService,
				'create',
			);
		}
		const encryptedCredential = await this.createEncryptedData({
			id: null,
			name: opts.name,
			type: opts.type,
			data: opts.data as ICredentialDataDecryptedObject,
		});

		// Set isGlobal if provided in the payload and user has permission
		const isGlobal = opts.isGlobal;
		if (isGlobal === true) {
			const canShareGlobally = hasGlobalScope(user, 'credential:shareGlobally');
			if (!canShareGlobally) {
				throw new ForbiddenError(
					'You do not have permission to create globally shared credentials',
				);
			}
			encryptedCredential.isGlobal = isGlobal;
		}

		const credentialEntity = this.credentialsRepository.create({
			...encryptedCredential,
			isManaged: opts.isManaged,
			isResolvable: opts.isResolvable ?? false,
		});

		const { shared, ...credential } = await this.save(
			credentialEntity,
			encryptedCredential,
			user,
			targetProjectId,
			opts.data as ICredentialDataDecryptedObject,
		);

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

		return { ...credential, scopes };
	}

	/**
	 * Build credentials payload ready to pass to credential testing.
	 *
	 * - If `credentialsToTest` is not provided, uses stored decrypted credential data.
	 * - If `credentialsToTest` is provided, normalizes it for testing:
	 *   - fills payload data for sharees when needed
	 *   - restores redacted values from stored decrypted data
	 */
	private async prepareCredentialsForTest({
		storedCredential,
		user,
		credentialsToTest,
	}: {
		storedCredential: CredentialsEntity;
		user?: User;
		credentialsToTest?: ICredentialsDecrypted;
	}): Promise<ICredentialsDecrypted> {
		const decryptedData = await this.decrypt(storedCredential, true);
		const mergedCredentials: ICredentialsDecrypted = credentialsToTest
			? deepCopy(credentialsToTest)
			: {
					id: storedCredential.id,
					name: storedCredential.name,
					type: storedCredential.type,
					data: decryptedData,
				};

		if (user && credentialsToTest) {
			await this.replaceCredentialContentsForSharee(
				user,
				storedCredential,
				decryptedData,
				mergedCredentials,
			);

			if (mergedCredentials.data) {
				mergedCredentials.data = this.unredact(
					mergedCredentials.data,
					decryptedData,
					this.getCredentialTypeProperties(storedCredential.type),
				);
			}
		}

		return mergedCredentials;
	}
}
