import type { SharedCredentials, User } from '@n8n/db';
import {
	CredentialsEntity,
	CredentialsRepository,
	chunkIds,
	SharedCredentialsRepository,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import type { CredentialSharingRole, ProjectRole, Scope } from '@n8n/permissions';
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
import { In } from '@n8n/typeorm';

import { RoleService } from '@/services/role.service';

@Service()
export class CredentialsFinderService {
	constructor(
		private readonly sharedCredentialsRepository: SharedCredentialsRepository,
		private readonly credentialsRepository: CredentialsRepository,
		private readonly roleService: RoleService,
	) {}

	/**
	 * Fetches global credentials from the database.
	 */
	private async fetchGlobalCredentials(trx?: EntityManager): Promise<CredentialsEntity[]> {
		const em = trx ?? this.credentialsRepository.manager;
		return await em.find(CredentialsEntity, {
			where: { isGlobal: true, usageScope: 'project' },
			relations: { shared: true },
		});
	}

	private isExactScope(scopes: Scope[], scope: Scope): boolean {
		return scopes.length === 1 && scopes[0] === scope;
	}

	/**
	 * Checks if the scopes allow read-only access to global credentials.
	 * Global credentials can be accessed with credential:read scope only.
	 */
	hasGlobalReadOnlyAccess(scopes: Scope[]): boolean {
		return this.isExactScope(scopes, 'credential:read');
	}

	/**
	 * Checks if the scopes allow connect access to global credentials. Only
	 * end-user (resolvable) global credentials grant this — the caller is
	 * still responsible for checking `isResolvable` on the credential itself.
	 */
	hasGlobalConnectAccess(scopes: Scope[]): boolean {
		return this.isExactScope(scopes, 'credential:connect');
	}

	/**
	 * Finds a global credential by ID if it exists.
	 */
	async findGlobalCredentialById(
		credentialId: string,
		relations?: { shared: { project: boolean } },
	): Promise<CredentialsEntity | null> {
		return await this.credentialsRepository.findOne({
			where: {
				id: credentialId,
				isGlobal: true,
				usageScope: 'project',
			},
			relations,
		});
	}

	async findById(
		credentialId: string,
		options: {
			includeInstanceCredentials?: boolean;
			includeSharedProject?: boolean;
		} = {},
	): Promise<CredentialsEntity | null> {
		return await this.credentialsRepository.findOne({
			where: {
				id: credentialId,
				usageScope: options.includeInstanceCredentials ? In(['project', 'instance']) : 'project',
			},
			relations: options.includeSharedProject ? { shared: { project: true } } : undefined,
		});
	}

	/**
	 * Merges global credentials with the provided credentials list,
	 * deduplicating based on credential ID.
	 */
	private mergeAndDeduplicateCredentials<T extends { id: string }>(
		credentials: T[],
		globalCredentials: CredentialsEntity[],
		mapGlobalCredential: (cred: CredentialsEntity) => T | null,
	): T[] {
		const credentialIds = new Set(credentials.map((c) => c.id));
		const newGlobalCreds = globalCredentials
			.filter((gc) => !credentialIds.has(gc.id))
			.map(mapGlobalCredential)
			.filter((mapped): mapped is T => mapped !== null);

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

	/**
	 * Find all credentials that the user has access to taking the scopes into
	 * account.
	 *
	 * This also returns `credentials.shared` which is useful for constructing
	 * all scopes the user has for the credential using `RoleService.addScopes`.
	 **/
	async findCredentialsForUser(user: User, scopes: Scope[]) {
		let where: FindOptionsWhere<CredentialsEntity> = {
			isGlobal: false,
			usageScope: 'project',
		};

		if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
			const [projectRoles, credentialRoles] = await Promise.all([
				this.roleService.rolesWithScope('project', scopes),
				this.roleService.rolesWithScope('credential', scopes),
			]);
			where = {
				...where,
				shared: {
					role: In(credentialRoles),
					project: {
						projectRelations: {
							role: In(projectRoles),
							userId: user.id,
						},
					},
				},
			};
		}

		const credentials = await this.credentialsRepository.find({
			where,
			relations: { shared: true },
		});

		// Include global credentials only if the user has read-only access
		if (this.hasGlobalReadOnlyAccess(scopes)) {
			const globalCredentials = await this.fetchGlobalCredentials();
			return [...credentials, ...globalCredentials];
		}

		return credentials;
	}

	/** Get a credential if it has been shared with a user */
	async findCredentialForUser(
		credentialsId: string,
		user: User,
		scopes: Scope[],
		options: { includeInstanceCredentials?: boolean } = {},
	): Promise<CredentialsEntity | null> {
		if (options.includeInstanceCredentials && hasGlobalScope(user, 'credential:manageInstance')) {
			const instanceCredential = await this.credentialsRepository.findOneBy({
				id: credentialsId,
				usageScope: 'instance',
			});
			if (instanceCredential) return instanceCredential;
		}

		let where: FindOptionsWhere<SharedCredentials> = { credentialsId };

		if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
			const [projectRoles, credentialRoles] = await Promise.all([
				this.roleService.rolesWithScope('project', scopes),
				this.roleService.rolesWithScope('credential', scopes),
			]);
			where = {
				...where,
				role: In(credentialRoles),
				project: {
					projectRelations: {
						role: In(projectRoles),
						userId: user.id,
					},
				},
			};
		}

		const sharedCredential = await this.sharedCredentialsRepository.findOne({
			where,
			// TODO: write a small relations merger and use that one here
			relations: {
				credentials: {
					shared: { project: true },
				},
			},
		});

		if (sharedCredential) {
			if (sharedCredential.credentials.usageScope !== 'project') return null;
			return sharedCredential.credentials;
		}

		// Check for global credentials with read-only access
		if (this.hasGlobalReadOnlyAccess(scopes)) {
			return await this.findGlobalCredentialById(credentialsId, {
				shared: { project: true },
			});
		}

		// End-user credentials shared globally still let the recipient connect
		// their own account; static global credentials do not.
		if (this.hasGlobalConnectAccess(scopes)) {
			const globalCredential = await this.findGlobalCredentialById(credentialsId, {
				shared: { project: true },
			});
			return globalCredential?.isResolvable ? globalCredential : null;
		}

		return null;
	}

	/** Get all credentials shared to a user */
	async findAllCredentialsForUser(
		user: User,
		scopes: Scope[],
		trx?: EntityManager,
		options?: { includeGlobalCredentials?: boolean },
	) {
		let where: FindOptionsWhere<SharedCredentials> = {
			credentials: { usageScope: 'project' },
		};

		if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
			const [projectRoles, credentialRoles] = await Promise.all([
				this.roleService.rolesWithScope('project', scopes),
				this.roleService.rolesWithScope('credential', scopes),
			]);
			where = {
				...where,
				role: In(credentialRoles),
				project: {
					projectRelations: {
						role: In(projectRoles),
						userId: user.id,
					},
				},
			};
		}

		const sharedCredential = await this.sharedCredentialsRepository.findCredentialsWithOptions(
			where,
			trx,
		);

		let sharedCredentialsList = sharedCredential.map((sc) => ({
			...sc.credentials,
			projectId: sc.projectId,
		}));

		// Include global credentials if flag is set
		if (options?.includeGlobalCredentials) {
			const globalCredentials = await this.fetchGlobalCredentials(trx);
			sharedCredentialsList = this.mergeAndDeduplicateCredentials(
				sharedCredentialsList,
				globalCredentials,
				(globalCred) => {
					// For global credentials, use the owner's project ID
					const ownerSharing = globalCred.shared?.find((s) => s.role === 'credential:owner');
					const projectId = ownerSharing?.projectId;
					if (projectId) {
						return { ...globalCred, projectId };
					}
					// Skip credentials without a valid project ID
					return null;
				},
			);
		}

		return sharedCredentialsList;
	}

	/**
	 * Given a list of credential IDs, return only those the user can access with the given scopes.
	 */
	async findCredentialIdsWithScopeForUser(
		credentialIds: string[],
		user: User,
		scopes: Scope[],
	): Promise<Set<string>> {
		if (credentialIds.length === 0) return new Set();

		let where: FindOptionsWhere<SharedCredentials> = {
			credentialsId: In(credentialIds),
			credentials: { usageScope: 'project' },
		};

		if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
			const [projectRoles, credentialRoles] = await Promise.all([
				this.roleService.rolesWithScope('project', scopes),
				this.roleService.rolesWithScope('credential', scopes),
			]);
			where = {
				...where,
				role: In(credentialRoles),
				project: {
					projectRelations: {
						role: In(projectRoles),
						userId: user.id,
					},
				},
			};
		}

		const result = new Set<string>();
		for (const chunk of chunkIds(credentialIds)) {
			const sharedCredentials = await this.sharedCredentialsRepository.find({
				select: { credentialsId: true },
				where: { ...where, credentialsId: In(chunk) },
			});
			for (const sharedCredential of sharedCredentials) {
				result.add(sharedCredential.credentialsId);
			}
		}

		// Also include global credentials if scopes allow read-only access
		if (this.hasGlobalReadOnlyAccess(scopes)) {
			for (const chunk of chunkIds(credentialIds)) {
				const globalCreds = await this.credentialsRepository.find({
					where: { id: In(chunk), isGlobal: true, usageScope: 'project' },
					select: ['id'],
				});
				for (const gc of globalCreds) result.add(gc.id);
			}
		} else if (this.hasGlobalConnectAccess(scopes)) {
			// Only end-user (resolvable) global credentials grant connect access.
			for (const chunk of chunkIds(credentialIds)) {
				const globalCreds = await this.credentialsRepository.find({
					where: {
						id: In(chunk),
						isGlobal: true,
						usageScope: 'project',
						isResolvable: true,
					},
					select: ['id'],
				});
				for (const gc of globalCreds) result.add(gc.id);
			}
		}

		return result;
	}

	async getCredentialIdsByUserAndRole(
		userIds: string[],
		options:
			| { scopes: Scope[] }
			| { projectRoles: ProjectRole[]; credentialRoles: CredentialSharingRole[] },
		trx?: EntityManager,
	) {
		const projectRoles =
			'scopes' in options
				? await this.roleService.rolesWithScope('project', options.scopes)
				: options.projectRoles;
		const credentialRoles =
			'scopes' in options
				? await this.roleService.rolesWithScope('credential', options.scopes)
				: options.credentialRoles;

		const sharings = await this.sharedCredentialsRepository.findCredentialsByRoles(
			userIds,
			projectRoles,
			credentialRoles,
			trx,
		);

		return sharings.map((s) => s.credentialsId);
	}
}
