import {
	createTeamProject,
	createWorkflow,
	getPersonalProject,
	linkUserToProject,
	shareWorkflowWithProjects,
	testDb,
	testModules,
} from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import { jsonParse, type INode } from 'n8n-workflow';

import { EventService } from '@/events/event.service';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
import { saveCredential } from '@test-integration/db/credentials';
import { createFolder } from '@test-integration/db/folders';
import { createMember, createOwner } from '@test-integration/db/users';
import { createProjectVariable } from '@test-integration/db/variables';

import { N8nPackagesService } from '../n8n-packages.service';
import { FORMAT_VERSION } from '../spec/constants';
import { readExport } from './utils/tar-support';
import {
	buildVersionedWorkflow,
	buildWorkflowReferencingCredential,
	buildWorkflowCallingSubWorkflow,
	buildWorkflowReferencingVariables,
	noOpNode,
} from './utils/test-builders';

let service: N8nPackagesService;

beforeAll(async () => {
	await testModules.loadModules(['n8n-packages']);
	await testDb.init();
	service = Container.get(N8nPackagesService);
});

afterAll(async () => {
	await testDb.terminate();
});

beforeEach(async () => {
	await testDb.truncate([
		'Folder',
		// WorkflowEntity first: its activeVersionId points at WorkflowHistory.
		'WorkflowEntity',
		'WorkflowHistory',
		'SharedWorkflow',
		'CredentialsEntity',
		'SharedCredentials',
		'Variables',
		'ProjectRelation',
		'Project',
	]);
});

async function exportProjects(user: User, projectIds: string[]) {
	return await readExport((await service.exportPackage({ user, projectIds })).stream);
}

async function exportProject(user: User, projectId: string) {
	return await exportProjects(user, [projectId]);
}

describe('project package export', () => {
	it('emits a tar with manifest.json first and project.json for an empty team project', async () => {
		const owner = await createOwner();
		const project = await createTeamProject('Empty Project', owner);

		const { manifest, entries } = await exportProject(owner, project.id);

		expect(entries[0].name).toBe('manifest.json');
		expect(manifest).toMatchObject({
			packageFormatVersion: FORMAT_VERSION,
			exportedAt: expect.any(String),
			sourceN8nVersion: expect.any(String),
			sourceId: expect.any(String),
		});
		expect(manifest.projects).toEqual([
			{ id: project.id, name: 'Empty Project', target: expect.any(String) },
		]);
		expect(manifest.workflows).toBeUndefined();

		const projectFile = entries.find(
			(e) => e.name === `${manifest.projects![0].target}/project.json`,
		);
		expect(projectFile).toBeDefined();
		const serialized = JSON.parse(projectFile!.content.toString());
		expect(serialized).toEqual({
			id: project.id,
			name: 'Empty Project',
		});
	});

	it('emits n8n-package-exported with the exported projectIds', async () => {
		const owner = await createOwner();
		const firstProject = await createTeamProject('Alpha Project', owner);
		const secondProject = await createTeamProject('Beta Project', owner);

		const emitSpy = vi.spyOn(Container.get(EventService), 'emit');

		try {
			await service.exportPackage({
				user: owner,
				projectIds: [firstProject.id, secondProject.id],
			});

			const exportedEvents = emitSpy.mock.calls.filter(([name]) => name === 'n8n-package-exported');
			expect(exportedEvents).toHaveLength(1);

			const payload = exportedEvents[0][1] as RelayEventMap['n8n-package-exported'];
			expect([...payload.projectIds!].sort()).toEqual([firstProject.id, secondProject.id].sort());
		} finally {
			emitSpy.mockRestore();
		}
	});

	it('exports multiple team projects in a single request', async () => {
		const owner = await createOwner();
		const firstProject = await createTeamProject('Alpha Project', owner);
		const secondProject = await createTeamProject('Beta Project', owner);

		const { manifest, entries } = await exportProjects(owner, [secondProject.id, firstProject.id]);

		expect(manifest.projects).toHaveLength(2);
		expect(manifest.projects).toEqual(
			expect.arrayContaining([
				{
					id: firstProject.id,
					name: 'Alpha Project',
					target: `projects/alpha-project-${firstProject.id}`,
				},
				{
					id: secondProject.id,
					name: 'Beta Project',
					target: `projects/beta-project-${secondProject.id}`,
				},
			]),
		);

		for (const { id, name, target } of manifest.projects!) {
			const projectFile = entries.find((entry) => entry.name === `${target}/project.json`);
			expect(projectFile).toBeDefined();
			expect(JSON.parse(projectFile!.content.toString())).toEqual({ id, name });
		}
	});

	it('blocks project exports when a workflow dependency is outside the package', async () => {
		const owner = await createOwner();
		const projectA = await createTeamProject('Project A', owner);
		const projectB = await createTeamProject('Project B', owner);
		const externalChild = await createWorkflow(
			{ name: 'External Child', nodes: [], connections: {} },
			projectB,
		);
		await buildWorkflowCallingSubWorkflow({
			name: 'Parent',
			project: projectA,
			subWorkflowId: externalChild.id,
		});

		await expect(service.exportPackage({ user: owner, projectIds: [projectA.id] })).rejects.toThrow(
			'workflow dependency not included in the package',
		);
	});

	it('auto-includes a static sub-workflow in another project folder under a partial project shell', async () => {
		const owner = await createOwner();
		const projectA = await createTeamProject('Project A', owner);
		const projectB = await createTeamProject('Project B', owner);
		const dependencyRoot = await createFolder(projectB, { name: 'Dependencies' });
		const dependencyFolder = await createFolder(projectB, {
			name: 'Nested',
			parentFolder: dependencyRoot,
		});
		const siblingFolder = await createFolder(projectB, { name: 'Sibling' });
		const dependency = await createWorkflow(
			{ name: 'External Child', parentFolder: dependencyFolder },
			projectB,
		);
		await createWorkflow({ name: 'Unrelated', parentFolder: siblingFolder }, projectB);
		await buildWorkflowCallingSubWorkflow({
			name: 'Parent',
			project: projectA,
			subWorkflowId: dependency.id,
		});

		const emitSpy = vi.spyOn(Container.get(EventService), 'emit');

		try {
			const { stream } = await service.exportPackage({
				user: owner,
				projectIds: [projectA.id],
				missingWorkflowDependencyPolicy: 'include-in-package',
			});
			const { manifest, entries } = await readExport(stream);

			const projectBEntry = manifest.projects!.find(({ id }) => id === projectB.id)!;
			expect(projectBEntry.target).toMatch(/^projects\//);
			expect(entries.find((e) => e.name === `${projectBEntry.target}/project.json`)).toBeDefined();
			expect(manifest.folders!.map(({ id }) => id).sort()).toEqual(
				expect.arrayContaining([dependencyRoot.id, dependencyFolder.id]),
			);
			expect(manifest.folders!.some(({ id }) => id === siblingFolder.id)).toBe(false);

			const dependencyFolderEntry = manifest.folders!.find(({ id }) => id === dependencyFolder.id)!;
			const dependencyEntry = manifest.workflows!.find(({ id }) => id === dependency.id)!;
			expect(dependencyEntry.target).toMatch(
				new RegExp(`^${dependencyFolderEntry.target}/workflows/[^/]+$`),
			);

			const exportedEvents = emitSpy.mock.calls.filter(([name]) => name === 'n8n-package-exported');
			expect(exportedEvents).toHaveLength(1);
			const payload = exportedEvents[0][1] as RelayEventMap['n8n-package-exported'];
			expect(payload.projectIds?.sort()).toEqual([projectA.id, projectB.id].sort());
			expect(payload.folderIds?.sort()).toEqual([dependencyRoot.id, dependencyFolder.id].sort());
		} finally {
			emitSpy.mockRestore();
		}
	});

	it('auto-includes a static sub-workflow at another project root', async () => {
		const owner = await createOwner();
		const projectA = await createTeamProject('Project A', owner);
		const projectB = await createTeamProject('Project B', owner);
		const dependency = await createWorkflow({ name: 'External Root' }, projectB);
		await buildWorkflowCallingSubWorkflow({
			name: 'Parent',
			project: projectA,
			subWorkflowId: dependency.id,
		});

		const { stream } = await service.exportPackage({
			user: owner,
			projectIds: [projectA.id],
			missingWorkflowDependencyPolicy: 'include-in-package',
		});
		const { manifest } = await readExport(stream);

		const projectBEntry = manifest.projects!.find(({ id }) => id === projectB.id)!;
		const dependencyEntry = manifest.workflows!.find(({ id }) => id === dependency.id)!;
		expect(dependencyEntry.target).toMatch(new RegExp(`^${projectBEntry.target}/workflows/[^/]+$`));
	});

	it('auto-includes a shared workflow under its owner project', async () => {
		const owner = await createOwner();
		const projectA = await createTeamProject('Project A', owner);
		const projectB = await createTeamProject('Project B', owner);
		const dependency = await createWorkflow({ name: 'Shared Child' }, projectB);
		await shareWorkflowWithProjects(dependency, [{ project: projectA, role: 'workflow:editor' }]);
		await buildWorkflowCallingSubWorkflow({
			name: 'Parent',
			project: projectA,
			subWorkflowId: dependency.id,
		});

		const { stream } = await service.exportPackage({
			user: owner,
			projectIds: [projectA.id],
			missingWorkflowDependencyPolicy: 'include-in-package',
		});
		const { manifest } = await readExport(stream);

		const projectBEntry = manifest.projects!.find(({ id }) => id === projectB.id)!;
		const dependencyEntry = manifest.workflows!.find(({ id }) => id === dependency.id)!;
		expect(dependencyEntry.target).toMatch(new RegExp(`^${projectBEntry.target}/workflows/[^/]+$`));
	});

	it('allows sub-workflow dependencies inside any selected project', async () => {
		const owner = await createOwner();
		const projectA = await createTeamProject('Project A', owner);
		const projectB = await createTeamProject('Project B', owner);
		const child = await createWorkflow({ name: 'Child', nodes: [], connections: {} }, projectB);
		const parent = await buildWorkflowCallingSubWorkflow({
			name: 'Parent',
			project: projectA,
			subWorkflowId: child.id,
		});

		const { manifest } = await exportProjects(owner, [projectA.id, projectB.id]);

		expect(manifest.workflows!.map(({ id }) => id).sort()).toEqual([parent.id, child.id].sort());
		expect(manifest.requirements?.workflows).toEqual([
			{ id: child.id, name: child.name, usedByWorkflows: [parent.id] },
		]);
	});

	it('exports the owner personal project', async () => {
		const owner = await createOwner();
		const personalProject = await getPersonalProject(owner);

		const { manifest, entries } = await exportProject(owner, personalProject.id);

		expect(manifest.projects).toEqual([
			{ id: personalProject.id, name: personalProject.name, target: expect.any(String) },
		]);

		const projectFile = entries.find(
			(entry) => entry.name === `${manifest.projects![0].target}/project.json`,
		);
		expect(projectFile).toBeDefined();
		expect(JSON.parse(projectFile!.content.toString())).toEqual({
			id: personalProject.id,
			name: personalProject.name,
		});
	});

	it('allows a project editor to export an empty team project', async () => {
		const owner = await createOwner();
		const editor = await createMember();
		const project = await createTeamProject('Editor Project', owner);
		await linkUserToProject(editor, project, 'project:editor');

		const { manifest } = await exportProject(editor, project.id);

		expect(manifest.projects).toEqual([
			{ id: project.id, name: 'Editor Project', target: expect.any(String) },
		]);
	});

	it('rejects export when the user lacks project access', async () => {
		const owner = await createOwner();
		const outsider = await createMember();
		const project = await createTeamProject('Private Project', owner);

		await expect(exportProject(outsider, project.id)).rejects.toThrow(
			'1 project(s) not found or not accessible. Export aborted.',
		);
	});

	it("rejects exporting another user's personal project", async () => {
		const projectOwner = await createMember();
		const otherUser = await createMember();
		const personalProject = await getPersonalProject(projectOwner);

		await expect(exportProject(otherUser, personalProject.id)).rejects.toThrow(
			'1 project(s) not found or not accessible. Export aborted.',
		);
	});
});

describe('project package export — with folders / workflows', () => {
	it('nests a folder and its workflow inside the project namespace', async () => {
		const owner = await createOwner();
		const project = await createTeamProject('team-ligo', owner);
		const folder = await createFolder(project, { name: 'in_progress' });
		const workflow = await createWorkflow({ name: 'triage', parentFolder: folder }, project);

		const { manifest, entries } = await exportProject(owner, project.id);

		const projectEntry = manifest.projects!.find((p) => p.id === project.id)!;
		expect(entries.find((e) => e.name === `${projectEntry.target}/project.json`)).toBeDefined();

		const folderEntry = manifest.folders!.find((f) => f.id === folder.id)!;
		expect(folderEntry.target).toMatch(new RegExp(`^${projectEntry.target}/folders/[^/]+$`));

		const workflowEntry = manifest.workflows!.find((w) => w.id === workflow.id)!;
		expect(workflowEntry.target).toMatch(new RegExp(`^${folderEntry.target}/workflows/[^/]+$`));
		expect(entries.find((e) => e.name === `${workflowEntry.target}/workflow.json`)).toBeDefined();
	});

	it('preserves nested folders and exports an empty folder as a shell', async () => {
		const owner = await createOwner();
		const project = await createTeamProject('team-ligo', owner);
		const emptyFolder = await createFolder(project, { name: 'to_production' });
		const inProgress = await createFolder(project, { name: 'in_progress' });
		const nested = await createFolder(project, { name: 'nested', parentFolder: inProgress });
		const workflow = await createWorkflow({ name: 'playground', parentFolder: nested }, project);

		const { manifest, entries } = await exportProject(owner, project.id);

		const projectEntry = manifest.projects!.find((p) => p.id === project.id)!;
		const inProgressEntry = manifest.folders!.find((f) => f.id === inProgress.id)!;
		const nestedEntry = manifest.folders!.find((f) => f.id === nested.id)!;

		expect(inProgressEntry.target).toMatch(new RegExp(`^${projectEntry.target}/folders/[^/]+$`));
		expect(nestedEntry.target).toMatch(new RegExp(`^${inProgressEntry.target}/[^/]+$`));

		const emptyEntry = manifest.folders!.find((f) => f.id === emptyFolder.id)!;
		expect(emptyEntry.target).toMatch(new RegExp(`^${projectEntry.target}/folders/[^/]+$`));
		expect(entries.some((e) => e.name.startsWith(`${emptyEntry.target}/workflows/`))).toBe(false);

		const workflowEntry = manifest.workflows!.find((w) => w.id === workflow.id)!;
		expect(workflowEntry.target).toMatch(new RegExp(`^${nestedEntry.target}/workflows/[^/]+$`));
	});

	it('nests a project root workflow under the project workflows/ dir', async () => {
		const owner = await createOwner();
		const project = await createTeamProject('team-ligo', owner);
		const workflow = await createWorkflow({ name: 'standalone' }, project);

		const { manifest, entries } = await exportProject(owner, project.id);

		const projectEntry = manifest.projects!.find((p) => p.id === project.id)!;
		const workflowEntry = manifest.workflows!.find((w) => w.id === workflow.id)!;
		expect(workflowEntry.target).toMatch(new RegExp(`^${projectEntry.target}/workflows/[^/]+$`));
		expect(entries.find((e) => e.name === `${workflowEntry.target}/workflow.json`)).toBeDefined();
		expect(manifest.folders).toBeUndefined();
	});

	it('namespaces a project-owned credential inside the project directory', async () => {
		const owner = await createOwner();
		const project = await createTeamProject('team-ligo', owner);
		const folder = await createFolder(project, { name: 'in_progress' });
		const credential = await saveCredential(
			{ name: 'Linear API', type: 'httpHeaderAuth', data: { name: 'X-Auth', value: 'secret' } },
			{ project, role: 'credential:owner' },
		);
		const workflow = await buildWorkflowReferencingCredential({
			name: 'triage',
			project,
			credential,
			parentFolder: folder,
		});

		const { manifest, entries } = await exportProject(owner, project.id);

		const projectEntry = manifest.projects!.find((p) => p.id === project.id)!;
		expect(manifest.credentials).toHaveLength(1);
		const credentialEntry = manifest.credentials![0];
		expect(credentialEntry.id).toBe(credential.id);
		expect(credentialEntry.target).toMatch(
			new RegExp(`^${projectEntry.target}/credentials/[^/]+$`),
		);
		expect(
			entries.find((e) => e.name === `${credentialEntry.target}/credential.json`),
		).toBeDefined();
		expect(manifest.requirements).toEqual({
			nodeTypes: [
				{ type: 'n8n-nodes-base.httpRequest', typeVersion: 1, usedByWorkflows: [workflow.id] },
			],
			credentials: [
				{
					id: credential.id,
					name: credential.name,
					type: 'httpHeaderAuth',
					usedByWorkflows: [workflow.id],
				},
			],
		});
	});

	it('namespaces a project-owned variable inside the project directory and counts it in telemetry', async () => {
		const owner = await createOwner();
		const project = await createTeamProject('team-ligo', owner);
		const folder = await createFolder(project, { name: 'in_progress' });
		const variable = await createProjectVariable('API_URL', 'https://team.example.com', project);
		const workflow = await buildWorkflowReferencingVariables({
			name: 'triage',
			project,
			variableNames: ['API_URL'],
			parentFolder: folder,
		});

		const emitSpy = vi.spyOn(Container.get(EventService), 'emit');

		const { manifest, entries } = await exportProject(owner, project.id);

		const projectEntry = manifest.projects!.find((p) => p.id === project.id)!;
		expect(manifest.variables).toHaveLength(1);
		const variableEntry = manifest.variables![0];
		expect(variableEntry.id).toBe(variable.id);
		expect(variableEntry.target).toMatch(new RegExp(`^${projectEntry.target}/variables/[^/]+$`));
		expect(entries.find((e) => e.name === `${variableEntry.target}/variable.json`)).toBeDefined();
		expect(manifest.requirements).toEqual({
			nodeTypes: expect.any(Array),
			variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }],
		});

		const events = emitSpy.mock.calls.filter(([name]) => name === 'n8n-package-exported');
		expect(events).toHaveLength(1);
		const payload = events[0][1] as RelayEventMap['n8n-package-exported'];
		expect(payload.counts.variables).toBe(1);
	});

	it('keeps a credential owned by a non-exported project at the package top level', async () => {
		const owner = await createOwner();
		const exportedProject = await createTeamProject('team-ligo', owner);
		const otherProject = await createTeamProject('team-stilton', owner);
		const credential = await saveCredential(
			{ name: 'Shared OpenAI', type: 'httpHeaderAuth', data: { name: 'X', value: 'y' } },
			{ project: otherProject, role: 'credential:owner' },
		);
		await buildWorkflowReferencingCredential({
			name: 'uses-external',
			project: exportedProject,
			credential,
		});

		const { manifest } = await exportProject(owner, exportedProject.id);

		expect(manifest.credentials).toHaveLength(1);
		// Owned elsewhere → top-level credentials/, not inside the project namespace.
		expect(manifest.credentials![0].target).toMatch(/^credentials\/[^/]+$/);
	});

	it('writes a credential shared across two exported projects once, under its owner', async () => {
		const owner = await createOwner();
		const projectA = await createTeamProject('team-ligo', owner);
		const projectB = await createTeamProject('team-stilton', owner);
		const credential = await saveCredential(
			{ name: 'Linear API', type: 'httpHeaderAuth', data: { name: 'X', value: 'y' } },
			{ project: projectA, role: 'credential:owner' },
		);
		const workflowA = await buildWorkflowReferencingCredential({
			name: 'in-a',
			project: projectA,
			credential,
		});
		const workflowB = await buildWorkflowReferencingCredential({
			name: 'in-b',
			project: projectB,
			credential,
		});

		const { manifest } = await exportProjects(owner, [projectA.id, projectB.id]);

		const projectAEntry = manifest.projects!.find((p) => p.id === projectA.id)!;
		expect(manifest.credentials).toHaveLength(1);
		expect(manifest.credentials![0].id).toBe(credential.id);
		expect(manifest.credentials![0].target).toMatch(
			new RegExp(`^${projectAEntry.target}/credentials/[^/]+$`),
		);
		const usedByWorkflows = (manifest.requirements?.credentials ?? []).flatMap(
			(c) => c.usedByWorkflows,
		);
		expect(usedByWorkflows.sort()).toEqual([workflowA.id, workflowB.id].sort());
	});

	it('exports a root workflow shared across two projects only under its owner', async () => {
		const owner = await createOwner();
		const ownerProject = await createTeamProject('team-ligo', owner);
		const otherProject = await createTeamProject('team-stilton', owner);
		const workflow = await createWorkflow({ name: 'shared-root' }, ownerProject);
		await shareWorkflowWithProjects(workflow, [{ project: otherProject, role: 'workflow:editor' }]);

		const { manifest } = await exportProjects(owner, [ownerProject.id, otherProject.id]);

		const ownerProjectEntry = manifest.projects!.find((p) => p.id === ownerProject.id)!;
		const matching = manifest.workflows!.filter((w) => w.id === workflow.id);
		expect(matching).toHaveLength(1);
		expect(matching[0].target.startsWith(`${ownerProjectEntry.target}/`)).toBe(true);
	});

	it('allocates same-named credentials independently per namespace', async () => {
		const owner = await createOwner();
		const project = await createTeamProject('team-ligo', owner);
		const otherProject = await createTeamProject('team-stilton', owner);
		const ownedCred = await saveCredential(
			{ name: 'Shared API', type: 'httpHeaderAuth', data: { name: 'X', value: 'y' } },
			{ project, role: 'credential:owner' },
		);
		const externalCred = await saveCredential(
			{ name: 'Shared API', type: 'httpHeaderAuth', data: { name: 'X', value: 'y' } },
			{ project: otherProject, role: 'credential:owner' },
		);
		await buildWorkflowReferencingCredential({
			name: 'uses-owned',
			project,
			credential: ownedCred,
		});
		await buildWorkflowReferencingCredential({
			name: 'uses-external',
			project,
			credential: externalCred,
		});

		const { manifest } = await exportProject(owner, project.id);

		const projectEntry = manifest.projects!.find((p) => p.id === project.id)!;
		const ownedEntry = manifest.credentials!.find((c) => c.id === ownedCred.id)!;
		const externalEntry = manifest.credentials!.find((c) => c.id === externalCred.id)!;
		// Same name, different base dirs → each keeps the bare slug, no cross-namespace suffixing.
		expect(ownedEntry.target).toMatch(new RegExp(`^${projectEntry.target}/credentials/[^/]+$`));
		expect(externalEntry.target).toMatch(/^credentials\/[^/]+$/);
		expect(ownedEntry.target).not.toBe(externalEntry.target);
	});

	it('exports two projects each with their own nested contents', async () => {
		const owner = await createOwner();
		const projectA = await createTeamProject('team-ligo', owner);
		const folderA = await createFolder(projectA, { name: 'in_progress' });
		const workflowA = await createWorkflow({ name: 'triage', parentFolder: folderA }, projectA);

		const projectB = await createTeamProject('team-stilton', owner);
		const folderB = await createFolder(projectB, { name: 'backlog' });
		const workflowB = await createWorkflow({ name: 'sync', parentFolder: folderB }, projectB);

		const { manifest } = await exportProjects(owner, [projectA.id, projectB.id]);

		expect(manifest.projects).toHaveLength(2);
		const projectAEntry = manifest.projects!.find((p) => p.id === projectA.id)!;
		const projectBEntry = manifest.projects!.find((p) => p.id === projectB.id)!;

		const workflowAEntry = manifest.workflows!.find((w) => w.id === workflowA.id)!;
		const workflowBEntry = manifest.workflows!.find((w) => w.id === workflowB.id)!;
		expect(workflowAEntry.target.startsWith(`${projectAEntry.target}/`)).toBe(true);
		expect(workflowBEntry.target.startsWith(`${projectBEntry.target}/`)).toBe(true);
		expect(projectAEntry.target).not.toBe(projectBEntry.target);
	});

	it('exports a project folder workflow at its published version, skipping unpublished ones', async () => {
		const owner = await createOwner();
		const project = await createTeamProject('team-ligo', owner);
		const folder = await createFolder(project, { name: 'in_progress' });
		const { workflow: published } = await buildVersionedWorkflow({
			name: 'triage',
			project,
			parentFolder: folder,
			versions: [[noOpNode('published-v1')], [noOpNode('published-v2')]],
			publishedVersion: 0,
		});
		await buildVersionedWorkflow({ name: 'sync', project, versions: [[noOpNode('draft-v1')]] });

		const { stream } = await service.exportPackage({
			user: owner,
			projectIds: [project.id],
			workflowVersionPolicy: 'ignore-unpublished',
		});
		const { manifest, entries } = await readExport(stream);

		expect(manifest.workflows!.map(({ id }) => id)).toEqual([published.id]);
		const exported = jsonParse<{ nodes: INode[] }>(
			entries
				.find((e) => e.name === `${manifest.workflows![0].target}/workflow.json`)!
				.content.toString(),
		);
		expect(exported.nodes.map(({ name }) => name)).toEqual(['published-v1']);
	});

	// Telemetry
	it('counts project folders, workflows and credentials in the export telemetry', async () => {
		const owner = await createOwner();
		const project = await createTeamProject('team-ligo', owner);
		const folder = await createFolder(project, { name: 'in_progress' });
		const credential = await saveCredential(
			{ name: 'Linear API', type: 'httpHeaderAuth', data: { name: 'X', value: 'y' } },
			{ project, role: 'credential:owner' },
		);
		await buildWorkflowReferencingCredential({
			name: 'triage',
			project,
			credential,
			parentFolder: folder,
		});
		await createWorkflow({ name: 'standalone' }, project);

		const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
		try {
			await service.exportPackage({ user: owner, projectIds: [project.id] });

			const events = emitSpy.mock.calls.filter(([name]) => name === 'n8n-package-exported');
			expect(events).toHaveLength(1);
			const payload = events[0][1] as RelayEventMap['n8n-package-exported'];
			expect(payload.counts.workflows).toBe(2);
			expect(payload.counts.folders).toBe(1);
			expect(payload.counts.credentials).toBe(1);
		} finally {
			emitSpy.mockRestore();
		}
	});

	// Abort propagates through the project → folder → workflow composition
	it('aborts when the project contains a workflow the caller cannot export', async () => {
		const member = await createMember();
		const memberProject = await createTeamProject('team-ligo', member);
		const folder = await createFolder(memberProject, { name: 'in_progress' });

		const owner = await createOwner();
		const ownerProject = await createTeamProject('owner-project', owner);
		// The workflow sits in the member's folder but is shared only with the owner's
		// project, so the member can project:export their own project yet cannot
		// workflow:export this workflow — the composition must abort, not ship partial.
		await createWorkflow({ name: 'secret', parentFolder: folder }, ownerProject);

		await expect(
			service.exportPackage({ user: member, projectIds: [memberProject.id] }),
		).rejects.toThrow(/workflow\(s\) not found or not accessible/);
	});
});
