import type { SourceControlledFile } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import type {
	FindOptionsWhere,
	Folder,
	Project,
	TagEntity,
	User,
	Variables,
	WorkflowEntity,
} from '@n8n/db';
import {
	CredentialsRepository,
	FolderRepository,
	ProjectRelationRepository,
	ProjectRepository,
	SharedCredentialsRepository,
	SharedWorkflowRepository,
	TagRepository,
	UserRepository,
	VariablesRepository,
	WorkflowRepository,
	WorkflowTagMapping,
	WorkflowTagMappingRepository,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { PROJECT_ADMIN_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { In } from '@n8n/typeorm';
import { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity';
import glob from 'fast-glob';
import isEqual from 'lodash/isEqual';
import { Credentials, ErrorReporter, InstanceSettings } from 'n8n-core';
import type { AutoPublishMode } from 'n8n-workflow';
import {
	shouldAutoPublishWorkflow,
	ensureError,
	jsonParse,
	UnexpectedError,
	UserError,
} from 'n8n-workflow';
import { readFile as fsReadFile } from 'node:fs/promises';
import path from 'path';

import { CredentialsService } from '@/credentials/credentials.service';
import type { IWorkflowToImport } from '@/interfaces';
import { DataTableColumn } from '@/modules/data-table/data-table-column.entity';
import { DataTableColumnRepository } from '@/modules/data-table/data-table-column.repository';
import { DataTableDDLService } from '@/modules/data-table/data-table-ddl.service';
import { DataTableRepository } from '@/modules/data-table/data-table.repository';
import { isValidColumnName, isValidDataTableId } from '@/modules/data-table/utils/sql-utils';
import { RedactionEnforcementService } from '@/modules/redaction/redaction-enforcement.service';
import { isUniqueConstraintError } from '@/response-helper';
import { TagService } from '@/services/tag.service';
import { assertNever } from '@/utils';
import { validateWorkflowNodeGroups } from '@/workflow-helpers';
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import { WorkflowService } from '@/workflows/workflow.service';

import {
	SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
	SOURCE_CONTROL_DATATABLES_EXPORT_FOLDER,
	SOURCE_CONTROL_FOLDERS_EXPORT_FILE,
	SOURCE_CONTROL_GIT_FOLDER,
	SOURCE_CONTROL_PROJECT_EXPORT_FOLDER,
	SOURCE_CONTROL_TAGS_EXPORT_FILE,
	SOURCE_CONTROL_VARIABLES_EXPORT_FILE,
	SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
} from './constants';
import { SourceControlContextFactory } from './source-control-context.factory';
import {
	getCredentialExportPath,
	getDataTableExportPath,
	getProjectExportPath,
	getWorkflowExportPath,
	isValidDataTableColumnType,
	mergeRemoteCrendetialDataIntoLocalCredentialData,
	sanitizeCredentialData,
} from './source-control-helper.ee';
import { SourceControlScopedService } from './source-control-scoped.service';
import type {
	ExportableCredential,
	StatusExportableCredential,
} from './types/exportable-credential';
import type { ExportableDataTable, StatusExportableDataTable } from './types/exportable-data-table';
import type { ExportableFolder } from './types/exportable-folders';
import type { ExportableProject, ExportableProjectWithFileName } from './types/exportable-project';
import type { ExportableTags } from './types/exportable-tags';
import { ExportableVariable } from './types/exportable-variable';
import type {
	RemoteResourceOwner,
	StatusResourceOwner,
	TeamResourceOwner,
} from './types/resource-owner';
import { SourceControlContext } from './types/source-control-context';
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
import { VariablesService } from '../../environments.ee/variables/variables.service.ee';

const toStatusOwner = (project: Project | undefined): StatusResourceOwner | undefined => {
	if (project?.type) {
		return { type: project.type, projectId: project.id, projectName: project.name };
	}
	return undefined;
};

@Service()
export class SourceControlImportService {
	private gitFolder: string;

	private workflowExportFolder: string;

	private credentialExportFolder: string;

	private projectExportFolder: string;

	private dataTableExportFolder: string;

	constructor(
		private readonly logger: Logger,
		private readonly errorReporter: ErrorReporter,
		private readonly variablesService: VariablesService,
		private readonly credentialsRepository: CredentialsRepository,
		private readonly projectRepository: ProjectRepository,
		private readonly projectRelationRepository: ProjectRelationRepository,
		private readonly tagRepository: TagRepository,
		private readonly sharedWorkflowRepository: SharedWorkflowRepository,
		private readonly sharedCredentialsRepository: SharedCredentialsRepository,
		private readonly userRepository: UserRepository,
		private readonly variablesRepository: VariablesRepository,
		private readonly workflowRepository: WorkflowRepository,
		private readonly workflowTagMappingRepository: WorkflowTagMappingRepository,
		private readonly workflowService: WorkflowService,
		private readonly credentialsService: CredentialsService,
		private readonly tagService: TagService,
		private readonly folderRepository: FolderRepository,
		instanceSettings: InstanceSettings,
		private readonly sourceControlContextFactory: SourceControlContextFactory,
		private readonly sourceControlScopedService: SourceControlScopedService,
		private readonly workflowHistoryService: WorkflowHistoryService,
		private readonly dataTableRepository: DataTableRepository,
		private readonly dataTableColumnRepository: DataTableColumnRepository,
		private readonly dataTableDDLService: DataTableDDLService,
		private readonly redactionEnforcementService: RedactionEnforcementService,
	) {
		this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
		this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER);
		this.credentialExportFolder = path.join(
			this.gitFolder,
			SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
		);
		this.projectExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_PROJECT_EXPORT_FOLDER);
		this.dataTableExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_DATATABLES_EXPORT_FOLDER);
	}

	async getRemoteVersionIdsFromFiles(
		context: SourceControlContext,
	): Promise<SourceControlWorkflowVersionId[]> {
		const remoteWorkflowFiles = await glob('*.json', {
			cwd: this.workflowExportFolder,
			absolute: true,
		});

		const remoteWorkflowsRead = await Promise.all(
			remoteWorkflowFiles.map(async (file) => await this.parseWorkflowFromFile(file)),
		);

		const remoteWorkflowFilesParsed = remoteWorkflowsRead
			.filter((remote) => {
				if (!remote?.id) {
					return false;
				}
				return (
					context.hasAccessToAllProjects() ||
					(remote.owner && context.findAuthorizedProjectByOwner(remote.owner))
				);
			})
			.map((remote) => {
				const project = remote.owner
					? context.findAuthorizedProjectByOwner(remote.owner)
					: undefined;
				return {
					id: remote.id,
					versionId: remote.versionId ?? '',
					name: remote.name,
					parentFolderId: remote.parentFolderId,
					remoteId: remote.id,
					filename: getWorkflowExportPath(remote.id, this.workflowExportFolder),
					owner: toStatusOwner(project ?? undefined),
					isRemoteArchived: remote.isArchived,
				};
			});

		return remoteWorkflowFilesParsed;
	}

	async getAllLocalVersionIdsFromDb(): Promise<SourceControlWorkflowVersionId[]> {
		const localWorkflows = await this.workflowRepository.find({
			relations: ['parentFolder'],
			select: {
				id: true,
				versionId: true,
				name: true,
				updatedAt: true,
				parentFolder: {
					id: true,
				},
			},
		});
		return localWorkflows.map((local) => {
			let updatedAt: Date;
			if (local.updatedAt instanceof Date) {
				updatedAt = local.updatedAt;
			} else {
				this.errorReporter.warn('updatedAt is not a Date', {
					extra: {
						type: typeof local.updatedAt,
						value: local.updatedAt,
					},
				});
				updatedAt = isNaN(Date.parse(local.updatedAt)) ? new Date() : new Date(local.updatedAt);
			}
			return {
				id: local.id,
				versionId: local.versionId,
				name: local.name,
				localId: local.id,
				parentFolderId: local.parentFolder?.id ?? null,
				filename: getWorkflowExportPath(local.id, this.workflowExportFolder),
				updatedAt: updatedAt.toISOString(),
			};
		}) as SourceControlWorkflowVersionId[];
	}

	async getLocalVersionIdsFromDb(
		context: SourceControlContext,
	): Promise<SourceControlWorkflowVersionId[]> {
		const localWorkflows = await this.workflowRepository.find({
			relations: {
				parentFolder: true,
				shared: {
					project: true,
				},
			},
			select: {
				id: true,
				versionId: true,
				name: true,
				updatedAt: true,
				parentFolder: {
					id: true,
				},
				shared: {
					project: {
						id: true,
						name: true,
						type: true,
					},
					role: true,
				},
			},
			where: this.sourceControlScopedService.getWorkflowsInAdminProjectsFromContextFilter(context),
		});

		return localWorkflows.map((local) => {
			let updatedAt: Date;
			if (local.updatedAt instanceof Date) {
				updatedAt = local.updatedAt;
			} else {
				this.errorReporter.warn('updatedAt is not a Date', {
					extra: {
						type: typeof local.updatedAt,
						value: local.updatedAt,
					},
				});
				updatedAt = isNaN(Date.parse(local.updatedAt)) ? new Date() : new Date(local.updatedAt);
			}

			const ownerProject = local.shared?.find((s) => s.role === 'workflow:owner')?.project;

			return {
				id: local.id,
				versionId: local.versionId,
				name: local.name,
				localId: local.id,
				parentFolderId: local.parentFolder?.id ?? null,
				filename: getWorkflowExportPath(local.id, this.workflowExportFolder),
				updatedAt: updatedAt.toISOString(),
				owner: toStatusOwner(ownerProject),
			};
		});
	}

	async getRemoteCredentialsFromFiles(
		context: SourceControlContext,
	): Promise<StatusExportableCredential[]> {
		const remoteCredentialFiles = await glob('*.json', {
			cwd: this.credentialExportFolder,
			absolute: true,
		});

		const remoteCredentialFilesRead = await Promise.all(
			remoteCredentialFiles.map(async (file) => {
				this.logger.debug(`Parsing credential file ${file}`);
				const remote = jsonParse<ExportableCredential>(
					await fsReadFile(file, { encoding: 'utf8' }),
				);
				return remote;
			}),
		);

		const remoteCredentialFilesParsed = remoteCredentialFilesRead
			.filter((remote) => {
				if (!remote?.id) {
					return false;
				}
				const owner = remote.ownedBy;
				return (
					!owner || context.hasAccessToAllProjects() || context.findAuthorizedProjectByOwner(owner)
				);
			})
			.map((remote) => {
				const project = remote.ownedBy
					? context.findAuthorizedProjectByOwner(remote.ownedBy)
					: null;
				return {
					...remote,
					ownedBy: project
						? {
								type: project.type,
								projectId: project.id,
								projectName: project.name,
							}
						: undefined,
					filename: getCredentialExportPath(remote.id, this.credentialExportFolder),
				};
			});

		return remoteCredentialFilesParsed.filter(
			(e) => e !== undefined,
		) as StatusExportableCredential[];
	}

	async getLocalCredentialsFromDb(
		context: SourceControlContext,
	): Promise<StatusExportableCredential[]> {
		const localCredentials = await this.credentialsRepository.find({
			relations: {
				shared: {
					project: true,
				},
			},
			select: {
				id: true,
				name: true,
				type: true,
				data: true,
				isGlobal: true,
				shared: {
					project: {
						id: true,
						name: true,
						type: true,
					},
					role: true,
				},
			},
			where:
				this.sourceControlScopedService.getCredentialsInAdminProjectsFromContextFilter(context),
		});

		return (await Promise.all(
			localCredentials.map(async (local) => {
				const ownerProject = local.shared?.find((s) => s.role === 'credential:owner')?.project;

				let data: Record<string, unknown> = {};
				try {
					const credentials = new Credentials(
						{ id: local.id, name: local.name },
						local.type,
						local.data,
					);
					data = sanitizeCredentialData(await credentials.getData());
				} catch {
					// Credential data may not be decryptable (e.g. empty or corrupted data)
				}

				return {
					id: local.id,
					name: local.name,
					type: local.type,
					data,
					filename: getCredentialExportPath(local.id, this.credentialExportFolder),
					ownedBy: toStatusOwner(ownerProject),
					isGlobal: local.isGlobal,
				};
			}),
		)) as StatusExportableCredential[];
	}

	async getRemoteVariablesFromFile(): Promise<ExportableVariable[]> {
		const variablesFile = await glob(SOURCE_CONTROL_VARIABLES_EXPORT_FILE, {
			cwd: this.gitFolder,
			absolute: true,
		});
		if (variablesFile.length > 0) {
			this.logger.debug(`Importing variables from file ${variablesFile[0]}`);
			return jsonParse<ExportableVariable[]>(
				await fsReadFile(variablesFile[0], { encoding: 'utf8' }),
				{
					fallbackValue: [],
				},
			);
		}
		return [];
	}

	async getLocalGlobalVariablesFromDb(): Promise<Variables[]> {
		return await this.variablesService.getAllCached({ globalOnly: true });
	}

	async getRemoteDataTablesFromFiles(
		context: SourceControlContext,
	): Promise<ExportableDataTable[]> {
		const dataTableFiles = await glob('*.json', {
			cwd: this.dataTableExportFolder,
			absolute: true,
		});

		if (dataTableFiles.length === 0) {
			return [];
		}

		const remoteTables = await Promise.all(
			dataTableFiles.map(async (file): Promise<ExportableDataTable | undefined> => {
				this.logger.debug(`Parsing data table file ${file}`);
				const fileContent = await fsReadFile(file, { encoding: 'utf8' });
				try {
					return jsonParse<ExportableDataTable>(fileContent);
				} catch (error) {
					this.logger.warn(`Failed to parse data table from file ${file}: invalid JSON format`);
					return undefined;
				}
			}),
		);

		return remoteTables.filter((table): table is ExportableDataTable => {
			// Filter out null/undefined values from failed parses
			if (!table) {
				return false;
			}

			// Unless data is corrupted, there should always be an owner.
			// We keep tables without an owner because they can still be imported
			// and assigned to the pulling user's personal project.
			if (!table.ownedBy) {
				return true;
			}

			const isOwnedByAuthorizedProject = !!context.findAuthorizedProjectByOwner(table.ownedBy);
			return context.hasAccessToAllProjects() || isOwnedByAuthorizedProject;
		});
	}

	async getLocalDataTablesFromDb(
		context: SourceControlContext,
	): Promise<StatusExportableDataTable[]> {
		try {
			const dataTables = await this.dataTableRepository.find({
				relations: [
					'columns',
					'project',
					'project.projectRelations',
					'project.projectRelations.role',
				],
				where:
					this.sourceControlScopedService.getDataTablesInAdminProjectsFromContextFilter(context),
			});
			return dataTables.map((table) => {
				let ownedBy: StatusResourceOwner | null = null;
				if (table.project?.type === 'personal') {
					const ownerRelation = table.project.projectRelations?.find(
						(pr) => pr.role.slug === PROJECT_OWNER_ROLE_SLUG,
					);
					if (ownerRelation) {
						ownedBy = {
							type: 'personal',
							projectId: table.project.id,
							projectName: table.project.name,
						};
					}
				} else if (table.project?.type === 'team') {
					ownedBy = {
						type: 'team',
						projectId: table.project.id,
						projectName: table.project.name,
					};
				}

				return {
					id: table.id,
					name: table.name,
					columns: (table.columns || [])
						.sort((a, b) => a.index - b.index)
						.map((col) => ({
							id: col.id,
							name: col.name,
							type: col.type,
							index: col.index,
						})),
					ownedBy,
					filename: getDataTableExportPath(table.id, this.dataTableExportFolder),
					createdAt: table.createdAt.toISOString(),
					updatedAt: table.updatedAt.toISOString(),
				};
			});
		} catch (error) {
			// Return empty array if DataTable entity is not registered (e.g., in test environments)
			if (error instanceof Error && error.message.includes('No metadata for "DataTable"')) {
				return [];
			}
			throw error;
		}
	}

	async getRemoteFoldersAndMappingsFromFile(context: SourceControlContext): Promise<{
		folders: ExportableFolder[];
	}> {
		const foldersFile = await glob(SOURCE_CONTROL_FOLDERS_EXPORT_FILE, {
			cwd: this.gitFolder,
			absolute: true,
		});
		if (foldersFile.length > 0) {
			this.logger.debug(`Importing folders from file ${foldersFile[0]}`);
			const mappedFolders = jsonParse<{
				folders: ExportableFolder[];
			}>(await fsReadFile(foldersFile[0], { encoding: 'utf8' }), {
				fallbackValue: { folders: [] },
			});

			if (!context.hasAccessToAllProjects()) {
				mappedFolders.folders = mappedFolders.folders.filter((folder) =>
					context.canAccessProject(folder.homeProjectId),
				);
			}

			return mappedFolders;
		}
		return { folders: [] };
	}

	async getLocalFoldersAndMappingsFromDb(context: SourceControlContext): Promise<{
		folders: ExportableFolder[];
	}> {
		const localFolders = await this.folderRepository.find({
			relations: ['parentFolder', 'homeProject'],
			select: {
				id: true,
				name: true,
				createdAt: true,
				updatedAt: true,
				parentFolder: { id: true },
				homeProject: { id: true },
			},
			where: this.sourceControlScopedService.getFoldersInAdminProjectsFromContextFilter(context),
		});

		return {
			folders: localFolders.map((f) => ({
				id: f.id,
				name: f.name,
				parentFolderId: f.parentFolder?.id ?? null,
				homeProjectId: f.homeProject.id,
				createdAt: f.createdAt.toISOString(),
				updatedAt: f.updatedAt.toISOString(),
			})),
		};
	}

	async getRemoteTagsAndMappingsFromFile(context: SourceControlContext): Promise<ExportableTags> {
		const tagsFile = await glob(SOURCE_CONTROL_TAGS_EXPORT_FILE, {
			cwd: this.gitFolder,
			absolute: true,
		});
		if (tagsFile.length > 0) {
			this.logger.debug(`Importing tags from file ${tagsFile[0]}`);
			const mappedTags = jsonParse<ExportableTags>(
				await fsReadFile(tagsFile[0], { encoding: 'utf8' }),
				{ fallbackValue: { tags: [], mappings: [] } },
			);

			const accessibleWorkflows =
				await this.sourceControlScopedService.getWorkflowsInAdminProjectsFromContext(context);

			if (accessibleWorkflows) {
				mappedTags.mappings = mappedTags.mappings.filter((mapping) =>
					accessibleWorkflows.some((workflow) => workflow.id === mapping.workflowId),
				);
			}

			return mappedTags;
		}
		return { tags: [], mappings: [] };
	}

	async getLocalTagsAndMappingsFromDb(context: SourceControlContext): Promise<ExportableTags> {
		const localTags = await this.tagRepository.find({ select: ['id', 'name'] });
		const localMappings = await this.workflowTagMappingRepository.find({
			select: ['workflowId', 'tagId'],
			where:
				this.sourceControlScopedService.getWorkflowTagMappingInAdminProjectsFromContextFilter(
					context,
				),
		});
		return { tags: localTags, mappings: localMappings };
	}

	/**
	 * Reads projects from the git work folder and returns the projects that are accessible to the context user
	 */
	async getRemoteProjectsFromFiles(
		context: SourceControlContext,
	): Promise<ExportableProjectWithFileName[]> {
		const remoteProjectFiles = await glob('*.json', {
			cwd: this.projectExportFolder,
			absolute: true,
		});

		const remoteProjects = await Promise.all(
			remoteProjectFiles.map(async (file) => {
				this.logger.debug(`Parsing project file ${file}`);
				const fileContent = await fsReadFile(file, { encoding: 'utf8' });
				const parsedProject = jsonParse<ExportableProject>(fileContent);

				return {
					...parsedProject,
					filename: getProjectExportPath(parsedProject.id, this.projectExportFolder),
				};
			}),
		);

		if (context.hasAccessToAllProjects()) {
			return remoteProjects;
		}

		return remoteProjects.filter((remoteProject) => {
			return context.findAuthorizedProjectByOwner(remoteProject.owner);
		});
	}

	/**
	 * Fetches team projects from the database that are accessible to the context user
	 * If context is not provided, it will return all team projects, regardless of the context user's access
	 */
	async getLocalTeamProjectsFromDb(
		context?: SourceControlContext,
	): Promise<ExportableProjectWithFileName[]> {
		let where: FindOptionsWhere<Project> = { type: 'team' };

		if (context) {
			where = {
				type: 'team',
				...(this.sourceControlScopedService.getProjectsWithPushScopeByContextFilter(context) ?? {}),
			};
		}

		const localProjects = await this.projectRepository.find({
			select: ['id', 'name', 'description', 'icon', 'type'],
			relations: ['variables'],
			where,
		});

		return localProjects.map((local) =>
			this.mapProjectEntityToExportableProjectWithFileName(local),
		);
	}

	private mapProjectEntityToExportableProjectWithFileName(
		project: Project,
	): ExportableProjectWithFileName {
		return {
			id: project.id,
			name: project.name,
			description: project.description,
			icon: project.icon,
			filename: getProjectExportPath(project.id, this.projectExportFolder),
			type: 'team', // This is safe because we only select team projects
			owner: {
				type: 'team',
				teamId: project.id,
				teamName: project.name,
			},
			variableStubs: project.variables.map((variable) => ({
				id: variable.id,
				key: variable.key,
				type: variable.type,
				value: '',
			})),
		};
	}

	async importWorkflowFromWorkFolder(
		candidates: SourceControlledFile[],
		userId: string,
		autoPublish: AutoPublishMode = 'none',
	) {
		const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId);
		const candidateIds = candidates.map((c) => c.id);
		const existingWorkflows = await this.workflowRepository.findByIds(candidateIds, {
			fields: ['id', 'name', 'versionId', 'active', 'activeVersionId', 'isArchived'],
		});

		const folders = await this.folderRepository.find({ select: ['id'] });
		const existingFolderIds = folders.map((f) => f.id);

		const allSharedWorkflows = await this.sharedWorkflowRepository.findWithFields(candidateIds, {
			select: ['workflowId', 'role', 'projectId'],
		});
		const importWorkflowsResult = [];

		// Due to SQLite concurrency issues, we cannot save all workflows at once
		// as project creation might cause constraint issues.
		// We must iterate over the array and run the whole process workflow by workflow
		for (const candidate of candidates) {
			const result = await this.importSingleWorkflowFromFile(
				candidate,
				userId,
				autoPublish,
				existingWorkflows,
				existingFolderIds,
				allSharedWorkflows,
				personalProject,
			);

			if (result) {
				importWorkflowsResult.push(result);
			}
		}

		return importWorkflowsResult;
	}

	private async importSingleWorkflowFromFile(
		candidate: SourceControlledFile,
		userId: string,
		autoPublish: AutoPublishMode,
		existingWorkflows: WorkflowEntity[],
		existingFolderIds: string[],
		allSharedWorkflows: Array<{ workflowId: string; role: string; projectId: string }>,
		personalProject: Project,
	) {
		this.logger.debug(`Importing workflow file ${candidate.file}`);

		const importedWorkflow = await this.parseWorkflowFromFile(candidate.file);

		importedWorkflow.nodeGroups ??= [];

		try {
			// Basic checks only: a git import should not reset groups for graph-shape
			// rules, only for structural integrity (missing refs, duplicate ids/names).
			validateWorkflowNodeGroups(importedWorkflow, null);
		} catch {
			this.logger.warn(
				`Workflow file ${candidate.file} has invalid nodeGroups, resetting to empty`,
			);
			importedWorkflow.nodeGroups = [];
		}

		const { versionId, nodes, connections, id, owner, nodeGroups } = importedWorkflow;

		if (!id || !versionId || !nodes || !connections) {
			this.logger.error(
				`Workflow file ${candidate.file} is missing required fields (id, versionId, nodes, connections)`,
			);
			// Skip invalid workflow file
			return;
		}
		const existingWorkflow = existingWorkflows.find((e) => e.id === id);

		await this.redactionEnforcementService.assertPolicyChangeAllowed(
			existingWorkflow?.settings?.redactionPolicy,
			importedWorkflow.settings?.redactionPolicy,
		);

		const { shouldPublishAfterImport, publishingError } = await this.preparePublishStateForImport(
			existingWorkflow,
			importedWorkflow,
			autoPublish,
			userId,
		);

		let finalPublishingError = publishingError;

		const parentFolderId = importedWorkflow.parentFolderId ?? '';

		this.logger.debug(`Updating workflow id ${id ?? 'new'}`);

		const upsertResult = await this.workflowRepository.upsert(
			{
				...importedWorkflow,
				parentFolder: existingFolderIds.includes(parentFolderId) ? { id: parentFolderId } : null,
			},
			['id'],
		);
		if (upsertResult?.identifiers?.length !== 1) {
			throw new UnexpectedError('Failed to upsert workflow', {
				extra: { workflowId: id ?? 'new' },
			});
		}

		try {
			await this.saveOrUpdateWorkflowHistory(
				{ id, versionId, nodes, connections, nodeGroups },
				userId,
			);
		} catch (error) {
			const e = ensureError(error);
			this.logger.error(`Failed to save or update workflow history for workflow ${id}`, {
				error: e,
			});
			return;
		}

		const localOwner = allSharedWorkflows.find(
			(w) => w.workflowId === id && w.role === 'workflow:owner',
		);

		await this.syncResourceOwnership({
			resourceId: id,
			remoteOwner: owner,
			localOwner,
			fallbackProject: personalProject,
			repository: this.sharedWorkflowRepository,
		});

		// Now publish the workflow if needed (after history is saved)
		if (shouldPublishAfterImport) {
			const publishResult = await this.publishWorkflow(id, versionId, userId);
			if (!publishResult.success) {
				finalPublishingError = publishResult.error;
			}
		}

		return {
			id,
			name: candidate.file,
			publishingError: finalPublishingError,
		};
	}

	private async parseWorkflowFromFile(file: string): Promise<IWorkflowToImport> {
		this.logger.debug(`Parsing workflow file ${file}`);
		try {
			const fileContent = await fsReadFile(file, { encoding: 'utf8' });
			return jsonParse<IWorkflowToImport>(fileContent);
		} catch (error) {
			this.logger.error(`Failed to parse workflow file ${file}`, { error });
			throw new UnexpectedError(
				`Failed to parse workflow file ${file}: ${error instanceof Error ? error.message : String(error)}`,
			);
		}
	}

	private shouldAutoPublishWorkflow(
		existingWorkflow: WorkflowEntity | undefined,
		importedWorkflow: IWorkflowToImport,
		autoPublish: AutoPublishMode,
	): boolean {
		return shouldAutoPublishWorkflow({
			isNewWorkflow: !existingWorkflow,
			isLocalPublished: !!existingWorkflow?.activeVersionId,
			isRemoteArchived: !!importedWorkflow.isArchived,
			autoPublish,
		});
	}

	private mustUnpublishLocalWorkflow(
		isLocalPublished: boolean,
		isRemoteArchived: boolean,
		shouldAutoPublishRemote: boolean,
	): boolean {
		if (!isLocalPublished) {
			return false;
		}

		if (isRemoteArchived) {
			return true;
		}

		return shouldAutoPublishRemote;
	}

	private async unpublishWorkflow(workflowId: string, userId: string): Promise<boolean> {
		const user = await this.userRepository.findOne({ where: { id: userId }, relations: ['role'] });
		if (!user) {
			this.logger.error(`User ${userId} not found, cannot unpublish workflow ${workflowId}`);
			return false;
		}

		try {
			this.logger.debug(`Unpublishing workflow id ${workflowId} before import`);
			await this.workflowService.deactivateWorkflow(user, workflowId);
			return true;
		} catch (e) {
			const error = ensureError(e);
			this.logger.error(`Failed to unpublish workflow ${workflowId}`, { error });
			return false;
		}
	}

	private async publishWorkflow(
		workflowId: string,
		versionId: string,
		userId: string,
	): Promise<{ success: boolean; error?: string }> {
		const user = await this.userRepository.findOne({ where: { id: userId }, relations: ['role'] });
		if (!user) {
			const errorMessage = `User ${userId} not found, cannot publish workflow ${workflowId}`;
			this.logger.error(errorMessage);
			return { success: false, error: errorMessage };
		}

		try {
			this.logger.debug(`Publishing imported workflow id ${workflowId}`);
			await this.workflowService.activateWorkflow(user, workflowId, {
				versionId,
			});
			return { success: true };
		} catch (e) {
			const error = ensureError(e);
			this.logger.error(`Failed to publish workflow ${workflowId}`, { error });
			return { success: false, error: error.message };
		}
	}

	async importCredentialsFromWorkFolder(candidates: SourceControlledFile[], userId: string) {
		const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId);
		const candidateIds = candidates.map((c) => c.id);
		const existingCredentials = await this.credentialsRepository.find({
			where: {
				id: In(candidateIds),
			},
			select: ['id', 'name', 'type', 'data'],
		});
		const existingSharedCredentials = await this.sharedCredentialsRepository.find({
			select: ['credentialsId', 'projectId', 'role'],
			where: {
				credentialsId: In(candidateIds),
				role: 'credential:owner',
			},
		});

		let importCredentialsResult: Array<{ id: string; name: string; type: string }> = [];
		importCredentialsResult = await Promise.all(
			candidates.map(async (candidate) => {
				this.logger.debug(`Importing credentials file ${candidate.file}`);
				const credential = jsonParse<ExportableCredential>(
					await fsReadFile(candidate.file, { encoding: 'utf8' }),
				);
				const existingCredential = existingCredentials.find(
					(e) => e.id === credential.id && e.type === credential.type,
				);

				const { name, type, data, id, isGlobal = false } = credential;
				const newCredentialObject = new Credentials({ id, name }, type);

				if (existingCredential?.data) {
					// Credential exists - merge expressions from remote while preserving local plain values
					const existingDecrypted = new Credentials(
						{ id: existingCredential.id, name: existingCredential.name },
						existingCredential.type,
						existingCredential.data,
					);
					const localData = await existingDecrypted.getData();
					const mergedData = mergeRemoteCrendetialDataIntoLocalCredentialData({
						local: localData,
						remote: data,
					});
					await newCredentialObject.setData(mergedData);
				} else {
					// This is a safe guard, in principle remote data should already be sanitized
					// This prevents importing invalid data that should have not been synched in the first place
					const sanitizedData = sanitizeCredentialData(data);
					await newCredentialObject.setData(sanitizedData);
				}

				this.logger.debug(`Updating credential id ${newCredentialObject.id as string}`);
				await this.credentialsRepository.upsert({ ...newCredentialObject, isGlobal }, ['id']);

				const localOwner = existingSharedCredentials.find(
					(c) => c.credentialsId === credential.id && c.role === 'credential:owner',
				);

				await this.syncResourceOwnership({
					resourceId: credential.id,
					remoteOwner: credential.ownedBy,
					localOwner,
					fallbackProject: personalProject,
					repository: this.sharedCredentialsRepository,
				});

				return {
					id: newCredentialObject.id as string,
					name: newCredentialObject.name,
					type: newCredentialObject.type,
				};
			}),
		);
		return importCredentialsResult.filter((e) => e !== undefined);
	}

	async importTagsFromWorkFolder(candidate: SourceControlledFile, user: User) {
		let mappedTags;
		try {
			this.logger.debug(`Importing tags from file ${candidate.file}`);
			mappedTags = jsonParse<{ tags: TagEntity[]; mappings: WorkflowTagMapping[] }>(
				await fsReadFile(candidate.file, { encoding: 'utf8' }),
				{ fallbackValue: { tags: [], mappings: [] } },
			);
		} catch (e) {
			const error = ensureError(e);
			this.logger.error(`Failed to import tags from file ${candidate.file}`, { error });
			return;
		}

		if (mappedTags.mappings.length === 0 && mappedTags.tags.length === 0) {
			return;
		}

		const existingWorkflowIds = new Set(
			(
				await this.workflowRepository.find({
					select: ['id'],
				})
			).map((e) => e.id),
		);

		await Promise.all(
			mappedTags.tags.map(async (tag) => {
				const findByName = await this.tagRepository.findOne({
					where: { name: tag.name },
					select: ['id'],
				});
				if (findByName && findByName.id !== tag.id) {
					throw new UserError(
						`A tag with the name <strong>${tag.name}</strong> already exists locally.<br />Please either rename the local tag, or the remote one with the id <strong>${tag.id}</strong> in the tags.json file.`,
					);
				}

				const tagCopy = this.tagRepository.create(tag);
				await this.tagRepository.upsert(tagCopy as QueryDeepPartialEntity<TagEntity>, {
					skipUpdateIfNoValuesChanged: true,
					conflictPaths: { id: true },
				});
			}),
		);

		// Get all workflow IDs from remote files (in scope for this import)
		// This ensures we delete mappings for workflows with zero tags
		const context = await this.sourceControlContextFactory.createContext(user);
		const remoteWorkflowIds = (await this.getRemoteVersionIdsFromFiles(context)).map((wf) => wf.id);

		// Include both workflows with mappings AND workflows in remote files (even with zero tags)
		const workflowIdsInImport = [
			...new Set([
				...mappedTags.mappings.map((mapping) => String(mapping.workflowId)),
				...remoteWorkflowIds,
			]),
		].filter((workflowId) => existingWorkflowIds.has(workflowId));

		const mappingsToImport = mappedTags.mappings.filter((mapping) =>
			existingWorkflowIds.has(String(mapping.workflowId)),
		);

		/**
		 * Use a transaction here as we "delete" them all and then "create" them again.
		 *
		 * Without a transaction we run the risk of deleting them all, then experiencing a failure on the "create" and
		 * leaving the customer with a partial tag/workflow import
		 */
		await this.workflowTagMappingRepository.manager.transaction(async (transactionManager) => {
			if (workflowIdsInImport.length > 0) {
				await transactionManager.delete(WorkflowTagMapping, {
					workflowId: In(workflowIdsInImport),
				});
			}

			if (mappingsToImport.length > 0) {
				await transactionManager.upsert(
					WorkflowTagMapping,
					mappingsToImport.map((mapping) => ({
						tagId: String(mapping.tagId),
						workflowId: String(mapping.workflowId),
					})),
					{
						skipUpdateIfNoValuesChanged: true,
						conflictPaths: { tagId: true, workflowId: true },
					},
				);
			}
		});

		return mappedTags;
	}

	async importFoldersFromWorkFolder(user: User, candidate: SourceControlledFile) {
		let mappedFolders;
		const projects = await this.projectRepository.find();
		const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(user.id);

		try {
			this.logger.debug(`Importing folders from file ${candidate.file}`);
			mappedFolders = jsonParse<{
				folders: ExportableFolder[];
			}>(await fsReadFile(candidate.file, { encoding: 'utf8' }), {
				fallbackValue: { folders: [] },
			});
		} catch (e) {
			const error = ensureError(e);
			this.logger.error(`Failed to import folders from file ${candidate.file}`, { error });
			return;
		}

		if (mappedFolders.folders.length === 0) {
			return;
		}

		await Promise.all(
			mappedFolders.folders.map(async (folder) => {
				const folderCopy = this.folderRepository.create({
					id: folder.id,
					name: folder.name,
					homeProject: {
						id: projects.find((p) => p.id === folder.homeProjectId)?.id ?? personalProject.id,
					},
				});

				await this.folderRepository.upsert(folderCopy as QueryDeepPartialEntity<Folder>, {
					skipUpdateIfNoValuesChanged: true,
					conflictPaths: { id: true },
				});
			}),
		);

		// After folders are created, setup the parentFolder relationship
		await Promise.all(
			mappedFolders.folders.map(async (folder) => {
				await this.folderRepository.update(
					{ id: folder.id },
					{
						parentFolder: folder.parentFolderId ? { id: folder.parentFolderId } : null,
						createdAt: folder.createdAt,
						updatedAt: folder.updatedAt,
					},
				);
			}),
		);

		return mappedFolders;
	}

	async importVariables(
		variables: ExportableVariable[],
		valueOverrides?: {
			[key: string]: string;
		},
	) {
		const result: { imported: string[] } = { imported: [] };
		const overriddenKeys = Object.keys(valueOverrides ?? {});

		for (const variable of variables) {
			if (!variable.key) {
				continue;
			}
			if (overriddenKeys.includes(variable.key) && valueOverrides) {
				variable.value = valueOverrides[variable.key];
				overriddenKeys.splice(overriddenKeys.indexOf(variable.key), 1);
			}
			try {
				// by default no value is stored remotely, so an empty string is returned
				// it must be changed to undefined so as to not overwrite existing values!
				const variableToUpsert = {
					...variable,
					value: variable.value === '' ? undefined : variable.value,
					project: variable.projectId ? { id: variable.projectId } : null,
				};

				await this.variablesRepository.upsert(variableToUpsert, ['id']);
			} catch (errorUpsert) {
				if (isUniqueConstraintError(errorUpsert as Error)) {
					this.logger.debug(`Variable ${variable.key} already exists, updating instead`);
					try {
						await this.variablesRepository.update({ key: variable.key }, { ...variable });
					} catch (errorUpdate) {
						this.logger.debug(`Failed to update variable ${variable.key}, skipping`);
						this.logger.debug((errorUpdate as Error).message);
					}
				}
			} finally {
				result.imported.push(variable.key);
			}
		}

		// add remaining overrides as new variables
		if (overriddenKeys.length > 0 && valueOverrides) {
			for (const key of overriddenKeys) {
				result.imported.push(key);
				const newVariable = this.variablesRepository.create({
					key,
					value: valueOverrides[key],
				});
				await this.variablesRepository.save(newVariable, { transaction: false });
			}
		}

		await this.variablesService.updateCache();

		return result;
	}

	async importVariablesFromWorkFolder(
		candidate: SourceControlledFile,
		valueOverrides?: {
			[key: string]: string;
		},
	) {
		let importedVariables;
		try {
			this.logger.debug(`Importing variables from file ${candidate.file}`);
			importedVariables = jsonParse<ExportableVariable[]>(
				await fsReadFile(candidate.file, { encoding: 'utf8' }),
				{ fallbackValue: [] },
			);
		} catch (e) {
			this.logger.error(`Failed to import tags from file ${candidate.file}`, { error: e });
			return;
		}

		return await this.importVariables(importedVariables, valueOverrides);
	}

	async importDataTablesFromWorkFolder(candidates: SourceControlledFile[], userId: string) {
		if (candidates.length === 0) {
			return;
		}

		// Get database type from the repository's connection
		const dbType = this.dataTableRepository.manager.connection.options.type;

		// Get the pulling user's personal project as a fallback for personal projects
		const pullingUserPersonalProject =
			await this.projectRepository.getPersonalProjectForUserOrFail(userId);

		const result: { imported: string[] } = { imported: [] };

		// Phase 1: Parse all data table files and resolve target projects upfront
		// so we can validate name collisions before any imports happen.
		const parsedTables: Array<{
			dataTable: ExportableDataTable;
			candidate: SourceControlledFile;
			targetProjectId: string;
		}> = [];

		for (const candidate of candidates) {
			this.logger.debug(`Parsing data table from file ${candidate.file}`);
			let dataTable: ExportableDataTable;
			try {
				dataTable = jsonParse<ExportableDataTable>(
					await fsReadFile(candidate.file, { encoding: 'utf8' }),
				);
			} catch (error) {
				this.logger.error(`Failed to parse data table from file ${candidate.file}`, {
					error: ensureError(error),
				});
				continue;
			}

			if (!dataTable || typeof dataTable !== 'object' || !dataTable.id || !dataTable.name) {
				this.logger.warn(`Failed to parse data table from file ${candidate.file}`);
				continue;
			}

			if (!isValidDataTableId(dataTable.id)) {
				this.logger.warn(
					`Invalid data table ID "${dataTable.id}" in file ${candidate.file}. Skipping.`,
				);
				continue;
			}

			let targetProject: Project | null = null;

			if (dataTable.ownedBy) {
				if (dataTable.ownedBy.type === 'personal') {
					const personalEmail = dataTable.ownedBy.personalEmail;
					if (personalEmail) {
						const user = await this.userRepository.findOne({ where: { email: personalEmail } });
						if (user) {
							targetProject = await this.projectRepository.getPersonalProjectForUserOrFail(user.id);
						} else {
							this.logger.debug(
								`User ${personalEmail} not found locally for data table ${dataTable.name}. Using pulling user's personal project as fallback.`,
							);
							targetProject = pullingUserPersonalProject;
						}
					}
				} else if (dataTable.ownedBy.type === 'team') {
					targetProject = await this.projectRepository.findOne({
						where: { id: dataTable.ownedBy.teamId },
					});

					if (!targetProject) {
						targetProject = await this.createTeamProject({
							type: 'team',
							teamId: dataTable.ownedBy.teamId,
							teamName: dataTable.ownedBy.teamName,
						});
					}
				}
			}

			if (!targetProject) {
				this.logger.debug(
					`No owner specified for data table ${dataTable.name}. Using pulling user's personal project.`,
				);
				targetProject = pullingUserPersonalProject;
			}

			parsedTables.push({ dataTable, candidate, targetProjectId: targetProject.id });
		}

		// Phase 2: Validate all name collisions before importing anything.
		// This prevents partial imports when a collision is detected mid-way.
		for (const { dataTable, targetProjectId } of parsedTables) {
			const existingByName = await this.dataTableRepository.findOne({
				where: { name: dataTable.name, projectId: targetProjectId },
				select: ['id'],
			});
			if (existingByName && existingByName.id !== dataTable.id) {
				throw new UserError(
					`A data table with the name <strong>${dataTable.name}</strong> already exists locally.<br />Please either rename the local data table, or the remote one with the id <strong>${dataTable.id}</strong> in the source control files.`,
				);
			}
		}

		// Phase 3: Import all data tables (no name collisions at this point)
		for (const { dataTable, candidate, targetProjectId } of parsedTables) {
			try {
				this.logger.debug(`Importing data table from file ${candidate.file}`);

				const existingDataTable = await this.dataTableRepository.findOne({
					where: { id: dataTable.id },
					relations: ['columns'],
				});

				const isNewTable = !existingDataTable;

				// Upsert data table - preserve timestamps from file to avoid false "modified" detections
				await this.dataTableRepository.upsert(
					{
						id: dataTable.id,
						name: dataTable.name,
						projectId: targetProjectId,
						createdAt: dataTable.createdAt,
						updatedAt: dataTable.updatedAt,
					},
					['id'],
				);

				// Get existing columns for this table to handle deletions/updates
				const existingColumns = await this.dataTableColumnRepository.find({
					where: { dataTable: { id: dataTable.id } },
					select: ['id', 'name'],
				});
				const existingColumnIds = new Set(existingColumns.map((c) => c.id));
				const existingColumnNameMap = new Map(existingColumns.map((c) => [c.id, c.name]));
				const importedColumnIds = new Set(dataTable.columns.map((c) => c.id));

				// Wrap all DDL + metadata operations in a transaction
				await this.dataTableRepository.manager.transaction(async (trx) => {
					// Delete columns that no longer exist in the imported data
					const columnsToDelete = Array.from(existingColumnIds).filter(
						(id) => !importedColumnIds.has(id),
					);
					if (columnsToDelete.length > 0) {
						if (!isNewTable) {
							// Drop columns from physical table
							for (const columnId of columnsToDelete) {
								const columnName = existingColumnNameMap.get(columnId);
								if (columnName) {
									await this.dataTableDDLService.dropColumnFromTable(
										dataTable.id,
										columnName,
										dbType,
										trx,
									);
								}
							}
						}
						await trx.delete(DataTableColumn, { id: In(columnsToDelete) });
					}

					// Upsert columns
					const columnEntities = [];
					for (const column of dataTable.columns) {
						if (!isValidColumnName(column.name)) {
							this.logger.warn(
								`Invalid column name "${column.name}" in data table ${dataTable.name}. Skipping column.`,
							);
							continue;
						}

						if (!isValidDataTableColumnType(column.type)) {
							this.logger.warn(
								`Invalid column type "${column.type}" in data table ${dataTable.name}, column ${column.name}. Skipping column.`,
							);
							continue;
						}

						const columnEntity = await trx.save(DataTableColumn, {
							id: column.id,
							name: column.name,
							type: column.type,
							index: column.index,
							dataTable: { id: dataTable.id },
						});
						columnEntities.push(columnEntity);

						// Rename columns whose name changed (same ID, different name)
						if (!isNewTable && existingColumnIds.has(column.id)) {
							const oldName = existingColumnNameMap.get(column.id);
							if (oldName && oldName !== column.name) {
								await this.dataTableDDLService.renameColumn(
									dataTable.id,
									oldName,
									column.name,
									dbType,
									trx,
								);
							}
						}

						// Add new columns to existing physical table
						if (!isNewTable && !existingColumnIds.has(column.id)) {
							await this.dataTableDDLService.addColumn(dataTable.id, columnEntity, dbType, trx);
						}
					}

					// Create physical table for new data tables
					if (isNewTable) {
						await this.dataTableDDLService.createTableWithColumns(
							dataTable.id,
							columnEntities,
							trx,
						);
					}
				});

				result.imported.push(dataTable.name);
			} catch (error) {
				this.logger.error(`Failed to import data table ${candidate.name}`, {
					error: ensureError(error),
				});
			}
		}

		return result;
	}

	/**
	 * Reads project files candidates from the work folder and imports them into the database.
	 *
	 * Only team projects are supported.
	 * Personal project are not supported because they are not stable across instances
	 * (different ids across instances).
	 *
	 * @param candidates - The project files to import
	 * @param pullingUserId - The ID of the user pulling the changes (will be assigned as project admin for new projects)
	 */
	async importTeamProjectsFromWorkFolder(
		candidates: SourceControlledFile[],
		pullingUserId: string,
	) {
		const importResults = [];
		const existingProjectVariables = (await this.variablesService.getAllCached()).filter(
			(v) => v.project,
		);

		for (const candidate of candidates) {
			try {
				this.logger.debug(`Importing project file ${candidate.file}`);
				const project = jsonParse<ExportableProject>(
					await fsReadFile(candidate.file, { encoding: 'utf8' }),
				);

				// Ensure that only team owned projects are imported as we can't resolve owners for personal projects
				// This is a safety check as only team owned projects should be exported in the first place
				if (
					typeof project.owner !== 'object' ||
					project.owner.type !== 'team' ||
					project.owner.teamId !== project.id
				) {
					this.logger.warn(`Project ${project.id} has inconsistent owner data, skipping`);
					continue;
				}

				// Upsert team project with metadata only
				await this.projectRepository.upsert(
					{
						id: project.id,
						name: project.name,
						icon: project.icon,
						description: project.description,
						type: 'team',
					},
					['id'],
				);

				const existingProject = await this.projectRepository.findOne({
					where: { id: project.id },
				});

				// For newly created projects OR existing projects without an admin,
				// assign the pulling user as project admin
				const hasExistingAdmin =
					existingProject &&
					(await this.projectRelationRepository.findOne({
						where: { projectId: project.id, role: { slug: PROJECT_ADMIN_ROLE_SLUG } },
					}));

				if (!hasExistingAdmin) {
					await this.projectRelationRepository.save({
						projectId: project.id,
						userId: pullingUserId,
						role: { slug: PROJECT_ADMIN_ROLE_SLUG },
					});
					this.logger.debug(`Assigned user ${pullingUserId} as admin for project ${project.name}`);
				}

				await this.importVariables(
					project.variableStubs?.map((v) => ({ ...v, projectId: project.id })) ?? [],
				);

				// Delete variables that existed before but are no longer present in the imported project
				const deletedVariables = existingProjectVariables.filter(
					(v) =>
						v.project!.id === project.id && !project.variableStubs?.some((vs) => vs.id === v.id),
				);
				await this.variablesService.deleteByIds(deletedVariables.map((v) => v.id));

				this.logger.info(`Imported team project: ${project.name}`);
				importResults.push({
					id: project.id,
					name: project.name,
				});
			} catch (error) {
				const errorMessage = ensureError(error);
				this.logger.error(`Failed to import project from file ${candidate.file}`, {
					error: errorMessage,
				});
			}
		}

		return importResults;
	}

	async deleteWorkflowsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) {
		for (const candidate of candidates) {
			await this.workflowService.delete(user, candidate.id, true);
		}
	}

	async deleteCredentialsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) {
		for (const candidate of candidates) {
			await this.credentialsService.delete(user, candidate.id);
		}
	}

	async deleteVariablesNotInWorkfolder(candidates: SourceControlledFile[]) {
		for (const candidate of candidates) {
			await this.variablesService.delete(candidate.id);
		}
	}

	async deleteDataTablesNotInWorkFolder(candidates: SourceControlledFile[]) {
		if (candidates.length === 0) {
			return;
		}

		for (const candidate of candidates) {
			await this.dataTableRepository.deleteDataTable(candidate.id);
		}
	}

	async deleteTagsNotInWorkfolder(candidates: SourceControlledFile[]) {
		for (const candidate of candidates) {
			await this.tagService.delete(candidate.id);
		}
	}

	async deleteFoldersNotInWorkfolder(candidates: SourceControlledFile[]) {
		if (candidates.length === 0) {
			return;
		}
		const candidateIds = candidates.map((c) => c.id);

		await this.folderRepository.delete({
			id: In(candidateIds),
		});
	}

	async deleteTeamProjectsNotInWorkfolder(candidates: SourceControlledFile[]) {
		if (candidates.length === 0) {
			return;
		}
		const candidateIds = candidates.map((c) => c.id);

		await this.projectRepository.delete({
			id: In(candidateIds),
		});
	}

	/**
	 * Syncs ownership of a resource (workflow or credential) during import.
	 * Handles ownership transfer by removing old ownership and assigning new ownership.
	 */
	private async syncResourceOwnership({
		resourceId,
		remoteOwner,
		localOwner,
		fallbackProject,
		repository,
	}: {
		resourceId: string;
		remoteOwner: RemoteResourceOwner | null | undefined;
		localOwner: { projectId: string } | undefined;
		fallbackProject: Project;
		repository: SharedWorkflowRepository | SharedCredentialsRepository;
	}): Promise<void> {
		let targetOwnerProject = await this.findOwnerProjectInLocalDb(remoteOwner ?? undefined);
		if (!targetOwnerProject) {
			const isSharedResource =
				remoteOwner && typeof remoteOwner !== 'string' && remoteOwner.type === 'team';

			targetOwnerProject = isSharedResource
				? await this.createTeamProject(remoteOwner)
				: fallbackProject;
		}

		const trx = this.workflowRepository.manager;

		// remove old ownership if it changed
		const shouldRemoveOldOwner = localOwner && localOwner.projectId !== targetOwnerProject.id;
		if (shouldRemoveOldOwner) {
			await repository.deleteByIds([resourceId], localOwner.projectId, trx);
		}

		// Set new ownership
		await repository.makeOwner([resourceId], targetOwnerProject.id, trx);
	}

	private async findOwnerProjectInLocalDb(owner: RemoteResourceOwner | IWorkflowToImport['owner']) {
		if (!owner) {
			return null;
		}

		if (typeof owner === 'string' || owner.type === 'personal') {
			const email = typeof owner === 'string' ? owner : owner.personalEmail;
			const user = await this.userRepository.findOne({ where: { email } });

			if (!user) {
				return null;
			}

			return await this.projectRepository.getPersonalProjectForUserOrFail(user.id);
		} else if (owner.type === 'team') {
			return await this.projectRepository.findOne({
				where: { id: owner.teamId },
			});
		}

		assertNever(owner);

		const errorOwner = owner as RemoteResourceOwner;
		throw new UnexpectedError(
			`Unknown resource owner type "${
				typeof errorOwner !== 'string' ? errorOwner.type : 'UNKNOWN'
			}" found when finding owner project`,
		);
	}

	private async createTeamProject(owner: TeamResourceOwner) {
		let teamProject: Project | null = null;

		try {
			teamProject = await this.projectRepository.save(
				this.projectRepository.create({
					id: owner.teamId,
					name: owner.teamName,
					type: 'team',
				}),
			);
		} catch (error) {
			// Workaround to handle the race condition where another worker created the project
			// between our check and insert
			teamProject = await this.projectRepository.findOne({
				where: { id: owner.teamId },
			});

			if (!teamProject) {
				throw error;
			}
		}

		return teamProject;
	}

	/**
	 * Saves or updates workflow version history during import.
	 * - If versionId is new: Creates new history record
	 * - If versionId exists with different nodes/connections: Updates existing record
	 * - If versionId exists with same content: No action
	 */
	private async saveOrUpdateWorkflowHistory(
		importedWorkflow: {
			versionId: string;
			nodes: IWorkflowToImport['nodes'];
			connections: IWorkflowToImport['connections'];
			nodeGroups: IWorkflowToImport['nodeGroups'];
			id: string;
		},
		userId: string,
	): Promise<void> {
		const { versionId, nodes, connections, nodeGroups, id } = importedWorkflow;

		// Fetch user for author info
		const user = await this.userRepository.findOne({ where: { id: userId } });
		const authors = user ? `import by ${user.firstName} ${user.lastName}` : 'import';

		const existingVersion = await this.workflowHistoryService.findVersion(id, versionId);

		if (existingVersion) {
			// Check if workflow content changed
			const nodesChanged = !isEqual(existingVersion.nodes, nodes);
			const connectionsChanged = !isEqual(existingVersion.connections, connections);
			const nodeGroupsChanged = !isEqual(existingVersion.nodeGroups, nodeGroups);

			if (nodesChanged || connectionsChanged || nodeGroupsChanged) {
				await this.workflowHistoryService.updateVersion(id, versionId, {
					nodes,
					connections,
					nodeGroups,
					authors,
				});
			}
		} else {
			// Create new version history record
			await this.workflowHistoryService.saveVersion(
				authors,
				{ versionId, nodes, connections, nodeGroups },
				id,
			);
		}
	}

	/**
	 * Prepares the publish state for importing a workflow.
	 * Determines if auto-publish should occur, handles unpublishing if needed,
	 * and resolves the final publish status for the workflow upsert.
	 *
	 * @param existingWorkflow - The existing workflow entity, if it exists
	 * @param importedWorkflow - The workflow being imported (mutated by this method)
	 * @param autoPublish - The auto-publish mode
	 * @param userId - The ID of the user performing the import
	 * @returns Object indicating whether to publish after import and any activation error
	 */
	private async preparePublishStateForImport(
		existingWorkflow: WorkflowEntity | undefined,
		importedWorkflow: IWorkflowToImport,
		autoPublish: AutoPublishMode,
		userId: string,
	): Promise<{ shouldPublishAfterImport: boolean; publishingError?: string }> {
		const shouldAutoPublishRemote = this.shouldAutoPublishWorkflow(
			existingWorkflow,
			importedWorkflow,
			autoPublish,
		);

		const isLocalPublished = !!existingWorkflow?.activeVersionId;
		const isRemoteArchived = !!importedWorkflow.isArchived;

		const mustUnpublishLocal = this.mustUnpublishLocalWorkflow(
			isLocalPublished,
			isRemoteArchived,
			shouldAutoPublishRemote,
		);

		let unpublishedLocal = false;
		let publishingError: string | undefined;
		if (mustUnpublishLocal && existingWorkflow) {
			unpublishedLocal = await this.unpublishWorkflow(existingWorkflow.id, userId);
			if (!unpublishedLocal) {
				publishingError = 'Failed to unpublish workflow before import';
			}
		}

		const shouldPublishAfterImport =
			shouldAutoPublishRemote && (!mustUnpublishLocal || unpublishedLocal);

		this.resolvePublishedStatus(
			importedWorkflow,
			existingWorkflow,
			mustUnpublishLocal,
			unpublishedLocal,
		);

		return { shouldPublishAfterImport, publishingError };
	}

	/**
	 * Resolves the publish status for the upsert of the imported workflow.
	 * We set active to false here and handle publishing after upsert.
	 * @param importedWorkflow The imported workflow.
	 * @param existingWorkflow The existing workflow entity, if it exists.
	 * @param mustUnpublishLocal Whether the local workflow must be unpublished.
	 * @param unpublishedLocal Whether the local workflow was unpublished.
	 */
	private resolvePublishedStatus(
		// Note: Workflow's active status is not saved in the remote workflow files,
		// and the field is missing despite IWorkflowToImport having it typed as boolean.
		importedWorkflow: IWorkflowToImport,
		existingWorkflow: WorkflowEntity | undefined,
		mustUnpublishLocal: boolean,
		unpublishedLocal: boolean,
	) {
		const existingWorkflowActiveVersionId = existingWorkflow?.activeVersionId;
		const isExistingArchived = !!existingWorkflow?.isArchived;

		const shouldPreserve =
			!isExistingArchived &&
			!!existingWorkflowActiveVersionId &&
			(!mustUnpublishLocal || !unpublishedLocal);

		if (shouldPreserve) {
			importedWorkflow.active = !!existingWorkflowActiveVersionId;
			importedWorkflow.activeVersionId = existingWorkflowActiveVersionId;
		} else {
			importedWorkflow.active = false;
			importedWorkflow.activeVersionId = null;
		}
	}
}
