import type { IConnections, INode } from 'n8n-workflow';

import {
	applyOperations,
	partialUpdateOperationSchema,
	toWorkflowSlice,
	type PartialUpdateOperation,
} from '../tools/workflow-builder/workflow-operations';

const makeNode = (overrides: Partial<INode> = {}): INode => ({
	id: 'node-id',
	name: 'A',
	type: 'n8n-nodes-base.set',
	typeVersion: 1,
	position: [0, 0],
	parameters: {},
	...overrides,
});

const baseWorkflow = () => ({
	name: 'wf',
	description: 'd',
	nodes: [
		makeNode({ id: 'a', name: 'A', position: [0, 0] }),
		makeNode({ id: 'b', name: 'B', position: [200, 0], parameters: { url: 'https://old' } }),
	],
	connections: {
		A: { main: [[{ node: 'B', type: 'main', index: 0 }]] },
	} as IConnections,
});

describe('applyOperations', () => {
	describe('updateNodeParameters', () => {
		test('deep-merges by default', () => {
			const ops: PartialUpdateOperation[] = [
				{ type: 'updateNodeParameters', nodeName: 'B', parameters: { url: 'https://new' } },
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes.find((n) => n.name === 'B')!.parameters).toEqual({
				url: 'https://new',
			});
		});

		test('preserves untouched parameter keys when merging', () => {
			const wf = baseWorkflow();
			wf.nodes[1].parameters = { url: 'https://old', method: 'GET' };
			const ops: PartialUpdateOperation[] = [
				{ type: 'updateNodeParameters', nodeName: 'B', parameters: { url: 'https://new' } },
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes.find((n) => n.name === 'B')!.parameters).toEqual({
				url: 'https://new',
				method: 'GET',
			});
		});

		test('replace=true overwrites parameters', () => {
			const ops: PartialUpdateOperation[] = [
				{
					type: 'updateNodeParameters',
					nodeName: 'B',
					parameters: { method: 'POST' },
					replace: true,
				},
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes.find((n) => n.name === 'B')!.parameters).toEqual({
				method: 'POST',
			});
		});

		test('rejects when node does not exist', () => {
			const ops: PartialUpdateOperation[] = [
				{ type: 'updateNodeParameters', nodeName: 'Missing', parameters: { x: 1 } },
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.error).toContain("node 'Missing' not found");
			expect(result.opIndex).toBe(0);
		});

		test('does not mutate input on success', () => {
			const wf = baseWorkflow();
			const before = JSON.stringify(wf);
			applyOperations(wf, [
				{ type: 'updateNodeParameters', nodeName: 'B', parameters: { url: 'https://new' } },
			]);
			expect(JSON.stringify(wf)).toBe(before);
		});
	});

	describe('setNodeParameter', () => {
		test('sets a top-level parameter via JSON Pointer', () => {
			const ops: PartialUpdateOperation[] = [
				{ type: 'setNodeParameter', nodeName: 'B', path: '/url', value: 'https://new' },
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes.find((n) => n.name === 'B')!.parameters).toEqual({
				url: 'https://new',
			});
		});

		test('preserves sibling keys at the leaf', () => {
			const wf = baseWorkflow();
			wf.nodes[1].parameters = { url: 'https://old', method: 'GET' };
			const ops: PartialUpdateOperation[] = [
				{ type: 'setNodeParameter', nodeName: 'B', path: '/method', value: 'POST' },
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes.find((n) => n.name === 'B')!.parameters).toEqual({
				url: 'https://old',
				method: 'POST',
			});
		});

		test('creates intermediate objects on demand for a deep path', () => {
			const ops: PartialUpdateOperation[] = [
				{
					type: 'setNodeParameter',
					nodeName: 'B',
					path: '/options/systemMessage',
					value: 'You are a helpful assistant',
				},
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			const params = result.workflow.nodes.find((n) => n.name === 'B')!.parameters as {
				url: string;
				options: { systemMessage: string };
			};
			expect(params.url).toBe('https://old');
			expect(params.options.systemMessage).toBe('You are a helpful assistant');
		});

		test('descends into existing nested objects without clobbering siblings', () => {
			const wf = baseWorkflow();
			wf.nodes[1].parameters = { options: { mode: 'manual', timeout: 30 } };
			const ops: PartialUpdateOperation[] = [
				{ type: 'setNodeParameter', nodeName: 'B', path: '/options/timeout', value: 60 },
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes.find((n) => n.name === 'B')!.parameters).toEqual({
				options: { mode: 'manual', timeout: 60 },
			});
		});

		test('accepts non-string values', () => {
			const ops: PartialUpdateOperation[] = [
				{ type: 'setNodeParameter', nodeName: 'B', path: '/retries', value: 3 },
				{ type: 'setNodeParameter', nodeName: 'B', path: '/disabled', value: false },
				{ type: 'setNodeParameter', nodeName: 'B', path: '/headers', value: { 'x-id': '1' } },
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			const params = result.workflow.nodes.find((n) => n.name === 'B')!.parameters;
			expect(params).toMatchObject({
				retries: 3,
				disabled: false,
				headers: { 'x-id': '1' },
			});
		});

		test('decodes ~1 and ~0 escapes in path segments', () => {
			const ops: PartialUpdateOperation[] = [
				{ type: 'setNodeParameter', nodeName: 'B', path: '/a~1b', value: 1 },
				{ type: 'setNodeParameter', nodeName: 'B', path: '/c~0d', value: 2 },
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			const params = result.workflow.nodes.find((n) => n.name === 'B')!.parameters as Record<
				string,
				unknown
			>;
			expect(params['a/b']).toBe(1);
			expect(params['c~d']).toBe(2);
		});

		test('rejects when node does not exist', () => {
			const ops: PartialUpdateOperation[] = [
				{ type: 'setNodeParameter', nodeName: 'Missing', path: '/x', value: 1 },
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(false);
		});

		test('rejects path that does not start with /', () => {
			const ops: PartialUpdateOperation[] = [
				{ type: 'setNodeParameter', nodeName: 'B', path: 'url', value: 'x' },
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(false);
		});

		test('rejects unsafe segment in path', () => {
			const ops: PartialUpdateOperation[] = [
				{ type: 'setNodeParameter', nodeName: 'B', path: '/__proto__/polluted', value: true },
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(false);
			expect(({} as Record<string, unknown>).polluted).toBeUndefined();
		});

		test('sanitizes unsafe keys inside the value', () => {
			const ops: PartialUpdateOperation[] = [
				{
					type: 'setNodeParameter',
					nodeName: 'B',
					path: '/options',
					value: { __proto__: { polluted: true }, mode: 'manual' },
				},
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			const options = (
				result.workflow.nodes.find((n) => n.name === 'B')!.parameters as {
					options: Record<string, unknown>;
				}
			).options;
			expect(options.mode).toBe('manual');
			expect(Object.prototype.hasOwnProperty.call(options, '__proto__')).toBe(false);
			expect(({} as Record<string, unknown>).polluted).toBeUndefined();
		});

		test('rejects descent through a non-object intermediate', () => {
			const wf = baseWorkflow();
			wf.nodes[1].parameters = { options: 'not-an-object' };
			const ops: PartialUpdateOperation[] = [
				{ type: 'setNodeParameter', nodeName: 'B', path: '/options/mode', value: 'manual' },
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.error).toContain('cannot descend');
		});

		test('rejects descent through a null intermediate (does not silently overwrite)', () => {
			const wf = baseWorkflow();
			wf.nodes[1].parameters = { options: null };
			const ops: PartialUpdateOperation[] = [
				{ type: 'setNodeParameter', nodeName: 'B', path: '/options/mode', value: 'manual' },
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.error).toContain('cannot descend');
			expect(wf.nodes[1].parameters).toEqual({ options: null });
		});

		test('does not mutate input on success', () => {
			const wf = baseWorkflow();
			const before = JSON.stringify(wf);
			applyOperations(wf, [
				{ type: 'setNodeParameter', nodeName: 'B', path: '/url', value: 'https://new' },
			]);
			expect(JSON.stringify(wf)).toBe(before);
		});

		test('schema rejects an omitted (undefined) value', () => {
			const parsed = partialUpdateOperationSchema.safeParse({
				type: 'setNodeParameter',
				nodeName: 'B',
				path: '/url',
			});
			expect(parsed.success).toBe(false);
		});

		test('rejects paths with empty segments', () => {
			for (const path of ['/foo//bar', '/foo/', '//bar']) {
				const result = applyOperations(baseWorkflow(), [
					{ type: 'setNodeParameter', nodeName: 'B', path, value: 1 },
				]);
				expect(result.success).toBe(false);
				if (result.success) continue;
				expect(result.error).toContain('invalid');
			}
		});

		test('rejects paths with invalid ~ escape sequences', () => {
			for (const path of ['/foo~2bar', '/foo~', '/~', '/foo/bar~']) {
				const result = applyOperations(baseWorkflow(), [
					{ type: 'setNodeParameter', nodeName: 'B', path, value: 1 },
				]);
				expect(result.success).toBe(false);
				if (result.success) continue;
				expect(result.error).toContain('invalid');
			}
		});

		test('descends into an array by numeric index and sets a nested field', () => {
			const wf = baseWorkflow();
			wf.nodes[1].parameters = { values: [{ name: 'Content-Type', value: 'application/json' }] };
			const result = applyOperations(wf, [
				{ type: 'setNodeParameter', nodeName: 'B', path: '/values/0/value', value: 'text/plain' },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes[1].parameters).toEqual({
				values: [{ name: 'Content-Type', value: 'text/plain' }],
			});
		});

		test('replaces an array element outright when the path ends at the index', () => {
			const wf = baseWorkflow();
			wf.nodes[1].parameters = { values: [{ name: 'a' }, { name: 'b' }] };
			const result = applyOperations(wf, [
				{ type: 'setNodeParameter', nodeName: 'B', path: '/values/1', value: { name: 'c' } },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes[1].parameters).toEqual({
				values: [{ name: 'a' }, { name: 'c' }],
			});
		});

		test('rejects an out-of-bounds array index', () => {
			const wf = baseWorkflow();
			wf.nodes[1].parameters = { values: [{ name: 'a' }] };

			const result = applyOperations(wf, [
				{ type: 'setNodeParameter', nodeName: 'B', path: '/values/1/name', value: 'b' },
			]);

			expect(result.success).toBe(false);
			if (result.success) return;

			expect(result.error).toContain('array index 1 out of bounds');
			expect(result.error).toContain("at '/values'");
		});

		test('rejects a non-numeric segment used against an array', () => {
			const wf = baseWorkflow();
			wf.nodes[1].parameters = { values: [{ name: 'a' }] };

			const result = applyOperations(wf, [
				{ type: 'setNodeParameter', nodeName: 'B', path: '/values/foo/name', value: 'b' },
			]);

			expect(result.success).toBe(false);
			if (result.success) return;

			expect(result.error).toContain("cannot use 'foo' as an array index");
			expect(result.error).toContain("at '/values'");
		});

		test('rejects creating an array via a numeric segment when the container does not exist', () => {
			const wf = baseWorkflow();

			// Empty node with no parameters, so updating an array value will error
			wf.nodes[1].parameters = {};

			const result = applyOperations(wf, [
				{
					type: 'setNodeParameter',
					nodeName: 'B',
					path: '/assignments/assignments/0/value',
					value: 99,
				},
			]);

			expect(result.success).toBe(false);
			if (result.success) return;

			expect(result.error).toContain("cannot create array at '/assignments/assignments'");
			expect(result.error).toContain("for numeric segment '0'");
			expect(result.error).toContain('updateNodeParameters');
			expect(wf.nodes[1].parameters).toEqual({});
		});
	});

	describe('addNode', () => {
		test('appends a new node and tracks it as added', () => {
			const ops: PartialUpdateOperation[] = [
				{
					type: 'addNode',
					node: {
						name: 'C',
						type: 'n8n-nodes-base.set',
						typeVersion: 1,
						parameters: { value: 1 },
					},
				},
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes).toHaveLength(3);
			expect(result.workflow.nodes[2].name).toBe('C');
			expect(result.workflow.nodes[2].id).toBeTruthy();
			expect(result.addedNodeNames).toEqual(['C']);
		});

		test('uses provided position and id', () => {
			const ops: PartialUpdateOperation[] = [
				{
					type: 'addNode',
					node: {
						id: 'fixed-id',
						name: 'C',
						type: 'n8n-nodes-base.set',
						typeVersion: 1,
						position: [400, 100],
					},
				},
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			const c = result.workflow.nodes.find((n) => n.name === 'C')!;
			expect(c.id).toBe('fixed-id');
			expect(c.position).toEqual([400, 100]);
		});

		test('rejects when name already exists', () => {
			const ops: PartialUpdateOperation[] = [
				{ type: 'addNode', node: { name: 'A', type: 'n8n-nodes-base.set', typeVersion: 1 } },
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.error).toContain("a node named 'A' already exists");
		});
	});

	describe('removeNode', () => {
		test('removes node and prunes inbound + outbound connections', () => {
			const wf = baseWorkflow();
			wf.connections.B = { main: [[{ node: 'A', type: 'main', index: 0 }]] };
			const ops: PartialUpdateOperation[] = [{ type: 'removeNode', nodeName: 'B' }];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes).toHaveLength(1);
			expect(result.workflow.connections).toEqual({});
		});

		test('rejects when node does not exist', () => {
			const result = applyOperations(baseWorkflow(), [{ type: 'removeNode', nodeName: 'Nope' }]);
			expect(result.success).toBe(false);
		});

		test('untracks an added node when it is removed in the same batch', () => {
			const ops: PartialUpdateOperation[] = [
				{ type: 'addNode', node: { name: 'C', type: 'n8n-nodes-base.set', typeVersion: 1 } },
				{ type: 'removeNode', nodeName: 'C' },
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.addedNodeNames).toEqual([]);
		});

		test('prunes the removed node from its group', () => {
			const wf = {
				...baseWorkflow(),
				nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a', 'b'] }],
			};
			const result = applyOperations(wf, [{ type: 'removeNode', nodeName: 'B' }]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups).toEqual([{ id: 'g1', name: 'Group', nodeIds: ['a'] }]);
			expect(result.nodeGroupsChanged).toBe(true);
		});

		test('drops a group that empties out when its last node is removed', () => {
			const wf = {
				...baseWorkflow(),
				nodeGroups: [
					{ id: 'g1', name: 'Solo', nodeIds: ['b'] },
					{ id: 'g2', name: 'Other', nodeIds: ['a'] },
				],
			};
			const result = applyOperations(wf, [{ type: 'removeNode', nodeName: 'B' }]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups).toEqual([{ id: 'g2', name: 'Other', nodeIds: ['a'] }]);
			expect(result.nodeGroupsChanged).toBe(true);
		});

		test('leaves groups untouched when the removed node is not grouped', () => {
			const wf = {
				...baseWorkflow(),
				nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a'] }],
			};
			const result = applyOperations(wf, [{ type: 'removeNode', nodeName: 'B' }]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups).toEqual([{ id: 'g1', name: 'Group', nodeIds: ['a'] }]);
			expect(result.nodeGroupsChanged).toBe(false);
		});
	});

	describe('renameNode', () => {
		test('renames node and rewrites connections both as source and target', () => {
			const ops: PartialUpdateOperation[] = [
				{ type: 'renameNode', oldName: 'B', newName: 'BRenamed' },
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes.find((n) => n.name === 'B')).toBeUndefined();
			expect(result.workflow.nodes.find((n) => n.name === 'BRenamed')).toBeDefined();
			expect(result.workflow.connections.A.main[0]![0].node).toBe('BRenamed');
		});

		test('renames source-key references too', () => {
			const wf = baseWorkflow();
			wf.connections.B = { main: [[{ node: 'A', type: 'main', index: 0 }]] };
			const ops: PartialUpdateOperation[] = [
				{ type: 'renameNode', oldName: 'B', newName: 'BRenamed' },
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.connections.B).toBeUndefined();
			expect(result.workflow.connections.BRenamed).toEqual({
				main: [[{ node: 'A', type: 'main', index: 0 }]],
			});
		});

		test('no-op when oldName equals newName', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'renameNode', oldName: 'A', newName: 'A' },
			]);
			expect(result.success).toBe(true);
		});

		test('rejects when newName collides', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'renameNode', oldName: 'A', newName: 'B' },
			]);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.error).toContain("a node named 'B' already exists");
		});

		test('rejects when oldName does not exist', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'renameNode', oldName: 'X', newName: 'Y' },
			]);
			expect(result.success).toBe(false);
		});
	});

	describe('addConnection', () => {
		test('adds a connection with default indices and main type', () => {
			const wf = baseWorkflow();
			wf.connections = {};
			const ops: PartialUpdateOperation[] = [{ type: 'addConnection', source: 'A', target: 'B' }];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.connections.A.main[0]).toEqual([
				{ node: 'B', type: 'main', index: 0 },
			]);
		});

		test('is idempotent — adding the same connection twice yields one entry', () => {
			const wf = baseWorkflow();
			wf.connections = {};
			const ops: PartialUpdateOperation[] = [
				{ type: 'addConnection', source: 'A', target: 'B' },
				{ type: 'addConnection', source: 'A', target: 'B' },
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.connections.A.main[0]).toHaveLength(1);
		});

		test('pads earlier output indices with null when adding to a higher index', () => {
			const wf = baseWorkflow();
			wf.connections = {};
			const ops: PartialUpdateOperation[] = [
				{ type: 'addConnection', source: 'A', target: 'B', sourceIndex: 2 },
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.connections.A.main).toEqual([
				null,
				null,
				[{ node: 'B', type: 'main', index: 0 }],
			]);
		});

		test('rejects when source node is missing', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'addConnection', source: 'Missing', target: 'B' },
			]);
			expect(result.success).toBe(false);
		});

		test('rejects when target node is missing', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'addConnection', source: 'A', target: 'Missing' },
			]);
			expect(result.success).toBe(false);
		});
	});

	describe('removeConnection', () => {
		test('removes the matching connection and prunes empty shapes', () => {
			const ops: PartialUpdateOperation[] = [
				{ type: 'removeConnection', source: 'A', target: 'B' },
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.connections).toEqual({});
		});

		test('rejects when no such connection exists', () => {
			const ops: PartialUpdateOperation[] = [
				{ type: 'removeConnection', source: 'A', target: 'B', sourceIndex: 5 },
			];
			const result = applyOperations(baseWorkflow(), ops);
			expect(result.success).toBe(false);
		});
	});

	describe('setNodeCredential', () => {
		test('sets credentials and preserves other credential entries', () => {
			const wf = baseWorkflow();
			wf.nodes[0].credentials = { other: { id: 'o1', name: 'OtherCred' } };
			const ops: PartialUpdateOperation[] = [
				{
					type: 'setNodeCredential',
					nodeName: 'A',
					credentialKey: 'slackApi',
					credentialId: 'cred-1',
					credentialName: 'My Slack',
				},
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes[0].credentials).toEqual({
				other: { id: 'o1', name: 'OtherCred' },
				slackApi: { id: 'cred-1', name: 'My Slack' },
			});
		});
	});

	describe('setNodePosition / setNodeDisabled', () => {
		test('updates position', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'setNodePosition', nodeName: 'A', position: [123, 456] },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes[0].position).toEqual([123, 456]);
		});

		test('updates disabled flag', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'setNodeDisabled', nodeName: 'A', disabled: true },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes[0].disabled).toBe(true);
		});
	});

	describe('setNodeSettings', () => {
		test('sets a single field without touching others', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'setNodeSettings', nodeName: 'A', settings: { onError: 'continueErrorOutput' } },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			const a = result.workflow.nodes.find((n) => n.name === 'A')!;
			expect(a.onError).toBe('continueErrorOutput');
			expect(a.retryOnFail).toBeUndefined();
			expect(a.maxTries).toBeUndefined();
			expect(a.waitBetweenTries).toBeUndefined();
			expect(a.alwaysOutputData).toBeUndefined();
			expect(a.executeOnce).toBeUndefined();
		});

		test('sets multiple fields in one op', () => {
			const result = applyOperations(baseWorkflow(), [
				{
					type: 'setNodeSettings',
					nodeName: 'A',
					settings: {
						retryOnFail: true,
						maxTries: 3,
						waitBetweenTries: 1500,
						alwaysOutputData: true,
						executeOnce: true,
					},
				},
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			const a = result.workflow.nodes.find((n) => n.name === 'A')!;
			expect(a.retryOnFail).toBe(true);
			expect(a.maxTries).toBe(3);
			expect(a.waitBetweenTries).toBe(1500);
			expect(a.alwaysOutputData).toBe(true);
			expect(a.executeOnce).toBe(true);
		});

		test('leaves omitted fields unchanged when the node already has settings', () => {
			const wf = baseWorkflow();
			wf.nodes[0].executeOnce = true;
			wf.nodes[0].retryOnFail = true;
			wf.nodes[0].maxTries = 4;
			const result = applyOperations(wf, [
				{ type: 'setNodeSettings', nodeName: 'A', settings: { onError: 'stopWorkflow' } },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			const a = result.workflow.nodes.find((n) => n.name === 'A')!;
			expect(a.onError).toBe('stopWorkflow');
			expect(a.executeOnce).toBe(true);
			expect(a.retryOnFail).toBe(true);
			expect(a.maxTries).toBe(4);
		});

		test('rejects when node does not exist', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'setNodeSettings', nodeName: 'Missing', settings: { retryOnFail: true } },
			]);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.opIndex).toBe(0);
			expect(result.error).toContain("'Missing' not found");
		});

		test('schema rejects an empty settings object', () => {
			const parsed = partialUpdateOperationSchema.safeParse({
				type: 'setNodeSettings',
				nodeName: 'A',
				settings: {},
			});
			expect(parsed.success).toBe(false);
		});

		test('schema rejects an unknown onError value', () => {
			const parsed = partialUpdateOperationSchema.safeParse({
				type: 'setNodeSettings',
				nodeName: 'A',
				settings: { onError: 'bogus' },
			});
			expect(parsed.success).toBe(false);
		});

		test('schema rejects out-of-range maxTries', () => {
			const parsed = partialUpdateOperationSchema.safeParse({
				type: 'setNodeSettings',
				nodeName: 'A',
				settings: { maxTries: 1 },
			});
			expect(parsed.success).toBe(false);
		});
	});

	describe('setWorkflowMetadata', () => {
		test('updates name and description', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'setWorkflowMetadata', name: 'New', description: 'updated' },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.name).toBe('New');
			expect(result.workflow.description).toBe('updated');
		});
	});

	describe('setWorkflowSettings', () => {
		test('sets the error workflow on a workflow with no prior settings', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'setWorkflowSettings', settings: { errorWorkflow: 'err-wf-1' } },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.settings).toEqual({ errorWorkflow: 'err-wf-1' });
		});

		test('merges into existing settings without dropping untouched keys', () => {
			const wf = { ...baseWorkflow(), settings: { availableInMCP: true, timezone: 'UTC' } };
			const result = applyOperations(wf, [
				{
					type: 'setWorkflowSettings',
					settings: { errorWorkflow: 'err-wf-1', timezone: 'America/New_York' },
				},
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.settings).toEqual({
				availableInMCP: true,
				timezone: 'America/New_York',
				errorWorkflow: 'err-wf-1',
			});
		});

		test('keeps "DEFAULT" as-is (cleanup happens later in WorkflowService.update)', () => {
			const wf = { ...baseWorkflow(), settings: { errorWorkflow: 'err-wf-1' } };
			const result = applyOperations(wf, [
				{ type: 'setWorkflowSettings', settings: { errorWorkflow: 'DEFAULT' } },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.settings).toEqual({ errorWorkflow: 'DEFAULT' });
		});

		test('does not mutate the input workflow settings', () => {
			const wf = { ...baseWorkflow(), settings: { timezone: 'UTC' } };
			const before = JSON.stringify(wf.settings);
			applyOperations(wf, [
				{ type: 'setWorkflowSettings', settings: { timezone: 'America/New_York' } },
			]);
			expect(JSON.stringify(wf.settings)).toBe(before);
		});

		test('schema rejects an empty settings object', () => {
			const parsed = partialUpdateOperationSchema.safeParse({
				type: 'setWorkflowSettings',
				settings: {},
			});
			expect(parsed.success).toBe(false);
		});

		test('schema rejects an unknown executionOrder value', () => {
			const parsed = partialUpdateOperationSchema.safeParse({
				type: 'setWorkflowSettings',
				settings: { executionOrder: 'v2' },
			});
			expect(parsed.success).toBe(false);
		});

		test('schema rejects executionTimeout of 0 or other negatives', () => {
			for (const executionTimeout of [0, -30]) {
				const parsed = partialUpdateOperationSchema.safeParse({
					type: 'setWorkflowSettings',
					settings: { executionTimeout },
				});
				expect(parsed.success).toBe(false);
			}
		});

		test('schema accepts executionTimeout of -1 (unlimited)', () => {
			const parsed = partialUpdateOperationSchema.safeParse({
				type: 'setWorkflowSettings',
				settings: { executionTimeout: -1 },
			});
			expect(parsed.success).toBe(true);
		});

		test('schema accepts a valid IANA timezone and "DEFAULT"', () => {
			for (const timezone of ['America/New_York', 'UTC', 'Europe/Berlin', 'DEFAULT']) {
				const parsed = partialUpdateOperationSchema.safeParse({
					type: 'setWorkflowSettings',
					settings: { timezone },
				});
				expect(parsed.success).toBe(true);
			}
		});

		test('schema rejects an invalid timezone', () => {
			for (const timezone of ['Not/AZone', 'EST5', 'Mars/Olympus_Mons', '']) {
				const parsed = partialUpdateOperationSchema.safeParse({
					type: 'setWorkflowSettings',
					settings: { timezone },
				});
				expect(parsed.success).toBe(false);
			}
		});
	});

	describe('atomicity', () => {
		test('rolls back the whole batch if any op fails', () => {
			const wf = baseWorkflow();
			const before = JSON.stringify(wf);
			const ops: PartialUpdateOperation[] = [
				{ type: 'updateNodeParameters', nodeName: 'B', parameters: { url: 'https://new' } },
				{ type: 'removeNode', nodeName: 'Missing' },
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.opIndex).toBe(1);
			expect(JSON.stringify(wf)).toBe(before);
		});
	});

	describe('object key safety', () => {
		test('strips unsafe keys from updateNodeParameters merge', () => {
			const wf = baseWorkflow();
			const ops: PartialUpdateOperation[] = [
				{
					type: 'updateNodeParameters',
					nodeName: 'B',
					parameters: { __proto__: { polluted: true }, url: 'https://safe' } as Record<
						string,
						unknown
					>,
				},
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(({} as Record<string, unknown>).polluted).toBeUndefined();
			const params = result.workflow.nodes.find((n) => n.name === 'B')!.parameters as Record<
				string,
				unknown
			>;
			expect(params.url).toBe('https://safe');
			expect(Object.prototype.hasOwnProperty.call(params, '__proto__')).toBe(false);
		});

		test('strips unsafe keys from nested parameters', () => {
			const wf = baseWorkflow();
			const ops: PartialUpdateOperation[] = [
				{
					type: 'updateNodeParameters',
					nodeName: 'B',
					parameters: {
						options: { constructor: { polluted: true }, mode: 'manual' },
					} as Record<string, unknown>,
				},
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(true);
			if (!result.success) return;
			const params = result.workflow.nodes.find((n) => n.name === 'B')!.parameters as Record<
				string,
				Record<string, unknown>
			>;
			expect(params.options.mode).toBe('manual');
			expect(Object.prototype.hasOwnProperty.call(params.options, 'constructor')).toBe(false);
		});

		test('rejects addNode with unsafe name', () => {
			const wf = baseWorkflow();
			const ops: PartialUpdateOperation[] = [
				{
					type: 'addNode',
					node: { name: '__proto__', type: 'n8n-nodes-base.set', typeVersion: 1 },
				},
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(false);
		});

		test('rejects renameNode to unsafe name', () => {
			const wf = baseWorkflow();
			const ops: PartialUpdateOperation[] = [
				{ type: 'renameNode', oldName: 'A', newName: 'constructor' },
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(false);
		});

		test('rejects addConnection with unsafe source', () => {
			const wf = baseWorkflow();
			wf.nodes.push(makeNode({ id: 'p', name: '__proto__' }));
			const ops: PartialUpdateOperation[] = [
				{ type: 'addConnection', source: '__proto__', target: 'B' },
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(false);
		});

		test('rejects addConnection with unsafe connectionType', () => {
			const wf = baseWorkflow();
			const ops: PartialUpdateOperation[] = [
				{ type: 'addConnection', source: 'A', target: 'B', connectionType: '__proto__' },
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(false);
		});

		test('rejects setNodeCredential with unsafe credentialKey', () => {
			const wf = baseWorkflow();
			const ops: PartialUpdateOperation[] = [
				{
					type: 'setNodeCredential',
					nodeName: 'B',
					credentialKey: '__proto__',
					credentialId: 'c1',
					credentialName: 'cred',
				},
			];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(false);
		});
	});

	describe('addTags / removeTags', () => {
		test('addTags adds names to the existing set without duplicating', () => {
			const wf = { ...baseWorkflow(), tagNames: ['production'] };
			const ops: PartialUpdateOperation[] = [
				{ type: 'addTags', names: ['critical', 'production'] },
			];
			const result = applyOperations(wf, ops);
			if (!result.success) throw new Error('expected success');
			expect(result.tagNames?.sort()).toEqual(['critical', 'production']);
			expect(result.workflow.tagNames?.sort()).toEqual(['critical', 'production']);
		});

		test('removeTags removes only the matching names', () => {
			const wf = { ...baseWorkflow(), tagNames: ['production', 'critical', 'wip'] };
			const ops: PartialUpdateOperation[] = [{ type: 'removeTags', names: ['wip', 'missing'] }];
			const result = applyOperations(wf, ops);
			if (!result.success) throw new Error('expected success');
			expect(result.tagNames?.sort()).toEqual(['critical', 'production']);
		});

		test('sequential ops apply in order (add then remove yields empty)', () => {
			const wf = { ...baseWorkflow(), tagNames: [] };
			const ops: PartialUpdateOperation[] = [
				{ type: 'addTags', names: ['a', 'b'] },
				{ type: 'removeTags', names: ['a'] },
				{ type: 'addTags', names: ['c'] },
			];
			const result = applyOperations(wf, ops);
			if (!result.success) throw new Error('expected success');
			expect(result.tagNames?.sort()).toEqual(['b', 'c']);
		});

		test('returns undefined tagNames when batch has no tag operations', () => {
			const wf = { ...baseWorkflow(), tagNames: ['production'] };
			const ops: PartialUpdateOperation[] = [{ type: 'setWorkflowMetadata', name: 'renamed' }];
			const result = applyOperations(wf, ops);
			if (!result.success) throw new Error('expected success');
			expect(result.tagNames).toBeUndefined();
		});

		test('fails when tag operations are present but existing tags were not loaded', () => {
			const wf = baseWorkflow();
			const ops: PartialUpdateOperation[] = [{ type: 'addTags', names: ['production'] }];
			const result = applyOperations(wf, ops);
			expect(result.success).toBe(false);
		});

		test('does not mutate input tagNames on success', () => {
			const wf = { ...baseWorkflow(), tagNames: ['production'] };
			const before = [...wf.tagNames];
			applyOperations(wf, [{ type: 'addTags', names: ['critical'] }]);
			expect(wf.tagNames).toEqual(before);
		});

		test('schema rejects empty names array', () => {
			const parsed = partialUpdateOperationSchema.safeParse({ type: 'addTags', names: [] });
			expect(parsed.success).toBe(false);
		});

		test('schema rejects empty string names', () => {
			const parsed = partialUpdateOperationSchema.safeParse({ type: 'removeTags', names: [''] });
			expect(parsed.success).toBe(false);
		});

		test('schema trims whitespace from names', () => {
			const parsed = partialUpdateOperationSchema.safeParse({
				type: 'addTags',
				names: ['  spaced  '],
			});
			expect(parsed.success).toBe(true);
			if (parsed.success && parsed.data.type === 'addTags') {
				expect(parsed.data.names).toEqual(['spaced']);
			}
		});
	});

	describe('setNodeGroups', () => {
		test('sets node groups on the workflow, resolving node names to ids', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'setNodeGroups', nodeGroups: [{ id: 'g1', name: 'Group', nodeNames: ['A', 'B'] }] },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups).toEqual([
				{ id: 'g1', name: 'Group', nodeIds: ['a', 'b'] },
			]);
		});

		test('generates an id when omitted', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'setNodeGroups', nodeGroups: [{ name: 'Group', nodeNames: ['A'] }] },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups).toHaveLength(1);
			expect(result.workflow.nodeGroups![0].id).toEqual(expect.any(String));
			expect(result.workflow.nodeGroups![0].id.length).toBeGreaterThan(0);
		});

		test('clears all groups with an empty array', () => {
			const wf = { ...baseWorkflow(), nodeGroups: [{ id: 'g1', name: 'Old', nodeIds: ['a'] }] };
			const result = applyOperations(wf, [{ type: 'setNodeGroups', nodeGroups: [] }]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups).toEqual([]);
		});

		test('fails when a node name does not exist', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'setNodeGroups', nodeGroups: [{ name: 'Group', nodeNames: ['A', 'Missing'] }] },
			]);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.error).toContain("node 'Missing' in group 'Group' not found");
		});

		test('resolves nodes added earlier in the same operation batch', () => {
			const result = applyOperations(baseWorkflow(), [
				{
					type: 'addNode',
					node: { id: 'c', name: 'C', type: 'n8n-nodes-base.set', typeVersion: 1 },
				},
				{ type: 'setNodeGroups', nodeGroups: [{ id: 'g1', name: 'Group', nodeNames: ['C'] }] },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups).toEqual([{ id: 'g1', name: 'Group', nodeIds: ['c'] }]);
		});

		test('dedupes duplicate node names within a group', () => {
			const result = applyOperations(baseWorkflow(), [
				{
					type: 'setNodeGroups',
					nodeGroups: [{ id: 'g1', name: 'Group', nodeNames: ['A', 'B', 'A'] }],
				},
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups![0].nodeIds).toEqual(['a', 'b']);
		});

		test('does not mutate the input workflow', () => {
			const wf = baseWorkflow();
			applyOperations(wf, [
				{ type: 'setNodeGroups', nodeGroups: [{ id: 'g1', name: 'Group', nodeNames: ['A'] }] },
			]);
			expect((wf as { nodeGroups?: unknown }).nodeGroups).toBeUndefined();
		});

		test('schema parses a valid setNodeGroups op', () => {
			const parsed = partialUpdateOperationSchema.safeParse({
				type: 'setNodeGroups',
				nodeGroups: [{ id: 'g1', name: 'Group', nodeNames: ['A', 'B'] }],
			});
			expect(parsed.success).toBe(true);
		});

		test('schema rejects a group without nodeNames', () => {
			const parsed = partialUpdateOperationSchema.safeParse({
				type: 'setNodeGroups',
				nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a'] }],
			});
			expect(parsed.success).toBe(false);
		});

		test('persists a group description', () => {
			const result = applyOperations(baseWorkflow(), [
				{
					type: 'setNodeGroups',
					nodeGroups: [
						{ id: 'g1', name: 'Group', nodeNames: ['A'], description: 'What this does' },
					],
				},
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups).toEqual([
				{ id: 'g1', name: 'Group', nodeIds: ['a'], description: 'What this does' },
			]);
		});

		test('omits an empty description so the group stays unset', () => {
			const result = applyOperations(baseWorkflow(), [
				{
					type: 'setNodeGroups',
					nodeGroups: [{ id: 'g1', name: 'Group', nodeNames: ['A'], description: '   ' }],
				},
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups![0]).not.toHaveProperty('description');
		});

		test('schema rejects a description over the max length', () => {
			const parsed = partialUpdateOperationSchema.safeParse({
				type: 'setNodeGroups',
				nodeGroups: [{ id: 'g1', name: 'Group', nodeNames: ['A'], description: 'x'.repeat(1000) }],
			});
			expect(parsed.success).toBe(false);
		});
	});

	describe('addNodeGroup', () => {
		test('adds a group resolving node names to ids, generating an id when omitted', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'addNodeGroup', name: 'Group', nodeNames: ['A', 'B'] },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups).toEqual([
				{ id: expect.any(String), name: 'Group', nodeIds: ['a', 'b'] },
			]);
			expect(result.nodeGroupsChanged).toBe(true);
		});

		test('appends to existing groups without touching them', () => {
			const wf = {
				...baseWorkflow(),
				nodeGroups: [{ id: 'g1', name: 'Existing', nodeIds: ['a'] }],
			};
			const result = applyOperations(wf, [
				{ type: 'addNodeGroup', id: 'g2', name: 'New', nodeNames: ['B'] },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups).toEqual([
				{ id: 'g1', name: 'Existing', nodeIds: ['a'] },
				{ id: 'g2', name: 'New', nodeIds: ['b'] },
			]);
		});

		test('fails when a group with the same name already exists', () => {
			const wf = {
				...baseWorkflow(),
				nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a'] }],
			};
			const result = applyOperations(wf, [
				{ type: 'addNodeGroup', name: 'Group', nodeNames: ['B'] },
			]);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.error).toContain("a node group named 'Group' already exists");
		});

		test('fails when a group with the same id already exists', () => {
			const wf = {
				...baseWorkflow(),
				nodeGroups: [{ id: 'g1', name: 'Existing', nodeIds: ['a'] }],
			};
			const result = applyOperations(wf, [
				{ type: 'addNodeGroup', id: 'g1', name: 'New', nodeNames: ['B'] },
			]);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.error).toContain("a node group with id 'g1' already exists");
		});

		test('fails when a node name does not exist', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] },
			]);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.error).toContain("node 'Missing' in group 'Group' not found");
		});

		test('dedupes duplicate node names', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'addNodeGroup', name: 'Group', nodeNames: ['A', 'A', 'B'] },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups![0].nodeIds).toEqual(['a', 'b']);
		});

		test('omits a blank description', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'addNodeGroup', name: 'Group', nodeNames: ['A'], description: '   ' },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups![0]).not.toHaveProperty('description');
		});

		test('can group a node added earlier in the same batch', () => {
			const result = applyOperations(baseWorkflow(), [
				{
					type: 'addNode',
					node: { id: 'c', name: 'C', type: 'n8n-nodes-base.set', typeVersion: 1 },
				},
				{ type: 'addNodeGroup', name: 'Group', nodeNames: ['C'] },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups![0].nodeIds).toEqual(['c']);
		});

		test('schema rejects an empty nodeNames array', () => {
			const parsed = partialUpdateOperationSchema.safeParse({
				type: 'addNodeGroup',
				name: 'Group',
				nodeNames: [],
			});
			expect(parsed.success).toBe(false);
		});
	});

	describe('removeNodeGroup', () => {
		test('removes the named group and keeps the others', () => {
			const wf = {
				...baseWorkflow(),
				nodeGroups: [
					{ id: 'g1', name: 'First', nodeIds: ['a'] },
					{ id: 'g2', name: 'Second', nodeIds: ['b'] },
				],
			};
			const result = applyOperations(wf, [{ type: 'removeNodeGroup', groupName: 'First' }]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups).toEqual([{ id: 'g2', name: 'Second', nodeIds: ['b'] }]);
			expect(result.nodeGroupsChanged).toBe(true);
		});

		test('keeps the grouped nodes in the workflow', () => {
			const wf = {
				...baseWorkflow(),
				nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a', 'b'] }],
			};
			const result = applyOperations(wf, [{ type: 'removeNodeGroup', groupName: 'Group' }]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodes).toHaveLength(2);
		});

		test('fails when the group does not exist', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'removeNodeGroup', groupName: 'Missing' },
			]);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.error).toContain("node group 'Missing' not found");
		});

		test('can remove a group added earlier in the same batch', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'addNodeGroup', name: 'Group', nodeNames: ['A'] },
				{ type: 'removeNodeGroup', groupName: 'Group' },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups).toEqual([]);
		});
	});

	describe('updateNodeGroup', () => {
		const groupedWorkflow = () => ({
			...baseWorkflow(),
			nodeGroups: [
				{ id: 'g1', name: 'Group', nodeIds: ['a'], description: 'Old description' },
				{ id: 'g2', name: 'Other', nodeIds: ['b'] },
			],
		});

		test('renames a group', () => {
			const result = applyOperations(groupedWorkflow(), [
				{ type: 'updateNodeGroup', groupName: 'Group', newName: 'Renamed' },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups![0].name).toBe('Renamed');
			expect(result.nodeGroupsChanged).toBe(true);
		});

		test('allows a no-op rename to the same name', () => {
			const result = applyOperations(groupedWorkflow(), [
				{ type: 'updateNodeGroup', groupName: 'Group', newName: 'Group' },
			]);
			expect(result.success).toBe(true);
		});

		test('fails when the new name collides with another group', () => {
			const result = applyOperations(groupedWorkflow(), [
				{ type: 'updateNodeGroup', groupName: 'Group', newName: 'Other' },
			]);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.error).toContain("a node group named 'Other' already exists");
		});

		test('replaces membership resolving and deduping node names', () => {
			const result = applyOperations(groupedWorkflow(), [
				{ type: 'updateNodeGroup', groupName: 'Other', nodeNames: ['A', 'A'] },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups![1].nodeIds).toEqual(['a']);
		});

		test('fails when a member node name does not exist', () => {
			const result = applyOperations(groupedWorkflow(), [
				{ type: 'updateNodeGroup', groupName: 'Group', nodeNames: ['Missing'] },
			]);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.error).toContain("node 'Missing' in group 'Group' not found");
		});

		test('sets a new description', () => {
			const result = applyOperations(groupedWorkflow(), [
				{ type: 'updateNodeGroup', groupName: 'Group', description: 'New description' },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups![0].description).toBe('New description');
		});

		test('clears the description with an empty string', () => {
			const result = applyOperations(groupedWorkflow(), [
				{ type: 'updateNodeGroup', groupName: 'Group', description: '' },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups![0]).not.toHaveProperty('description');
		});

		test('leaves the description unchanged when omitted', () => {
			const result = applyOperations(groupedWorkflow(), [
				{ type: 'updateNodeGroup', groupName: 'Group', newName: 'Renamed' },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups![0].description).toBe('Old description');
		});

		test('fails when the group does not exist', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'updateNodeGroup', groupName: 'Missing', newName: 'X' },
			]);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.error).toContain("node group 'Missing' not found");
		});

		test('fails when no change is specified', () => {
			const result = applyOperations(groupedWorkflow(), [
				{ type: 'updateNodeGroup', groupName: 'Group' },
			]);
			expect(result.success).toBe(false);
			if (result.success) return;
			expect(result.error).toContain(
				'updateNodeGroup must specify at least one of newName, nodeNames, or description',
			);
		});

		test('does not mutate the input workflow groups', () => {
			const wf = groupedWorkflow();
			applyOperations(wf, [{ type: 'updateNodeGroup', groupName: 'Group', newName: 'Renamed' }]);
			expect(wf.nodeGroups[0].name).toBe('Group');
		});

		test('leaves the group untouched when a later field fails validation', () => {
			// Valid nodeNames combined with a colliding newName: since a failing group
			// op is skipped rather than fatal, applying the membership before checking
			// the name would persist a change the skip report says never happened.
			const result = applyOperations(
				groupedWorkflow(),
				[{ type: 'updateNodeGroup', groupName: 'Group', nodeNames: ['A', 'B'], newName: 'Other' }],
				{ canvasGroupsEnabled: true },
			);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.skippedOperations).toEqual([
				{
					opIndex: 0,
					type: 'updateNodeGroup',
					reason: "a node group named 'Other' already exists",
				},
			]);
			expect(result.workflow.nodeGroups![0]).toEqual({
				id: 'g1',
				name: 'Group',
				nodeIds: ['a'],
				description: 'Old description',
			});
		});
	});

	describe('nodeGroupsChanged', () => {
		test('is false when no operation touches groups', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'setNodePosition', nodeName: 'A', position: [10, 10] },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.nodeGroupsChanged).toBe(false);
		});

		test('is true when setNodeGroups runs', () => {
			const result = applyOperations(baseWorkflow(), [{ type: 'setNodeGroups', nodeGroups: [] }]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.nodeGroupsChanged).toBe(true);
		});
	});

	describe('groupOperations', () => {
		// Only groups the batch explicitly asked for belong here: it's what lets a
		// caller tell "your group operation failed" from "an unrelated edit
		// invalidated a group that was already there".
		const withGroup = () => ({
			...baseWorkflow(),
			nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a', 'b'] }],
		});

		test('is empty when the workflow arrives with groups and no group operation runs', () => {
			const result = applyOperations(withGroup(), [
				{ type: 'setNodePosition', nodeName: 'A', position: [10, 10] },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.groupOperations).toEqual({});
		});

		test('is empty after a removeNode prunes an existing group', () => {
			const result = applyOperations(withGroup(), [{ type: 'removeNode', nodeName: 'B' }]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.workflow.nodeGroups).toEqual([{ id: 'g1', name: 'Group', nodeIds: ['a'] }]);
			expect(result.groupOperations).toEqual({});
		});

		test('records the operation index and type for addNodeGroup', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'setNodePosition', nodeName: 'A', position: [10, 10] },
				{ type: 'addNodeGroup', id: 'g1', name: 'Group', nodeNames: ['A', 'B'] },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.groupOperations).toEqual({ g1: { opIndex: 1, type: 'addNodeGroup' } });
		});

		test('records every group a setNodeGroups produced against that operation', () => {
			const result = applyOperations(baseWorkflow(), [
				{
					type: 'setNodeGroups',
					nodeGroups: [
						{ id: 'g1', name: 'One', nodeNames: ['A'] },
						{ id: 'g2', name: 'Two', nodeNames: ['B'] },
					],
				},
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.groupOperations).toEqual({
				g1: { opIndex: 0, type: 'setNodeGroups' },
				g2: { opIndex: 0, type: 'setNodeGroups' },
			});
		});

		test('records updateNodeGroup on a pre-existing group', () => {
			const result = applyOperations(withGroup(), [
				{ type: 'updateNodeGroup', groupName: 'Group', nodeNames: ['A'] },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.groupOperations).toEqual({ g1: { opIndex: 0, type: 'updateNodeGroup' } });
		});

		test('the last operation to touch a group wins', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'addNodeGroup', id: 'g1', name: 'Group', nodeNames: ['A', 'B'] },
				{ type: 'updateNodeGroup', groupName: 'Group', nodeNames: ['A'] },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.groupOperations).toEqual({ g1: { opIndex: 1, type: 'updateNodeGroup' } });
		});

		test('drops a group that removeNodeGroup deleted', () => {
			const result = applyOperations(baseWorkflow(), [
				{ type: 'addNodeGroup', id: 'g1', name: 'Group', nodeNames: ['A', 'B'] },
				{ type: 'removeNodeGroup', groupName: 'Group' },
			]);
			expect(result.success).toBe(true);
			if (!result.success) return;
			expect(result.groupOperations).toEqual({});
		});
	});

	describe('non-fatal operation types', () => {
		describe('canvasGroupsEnabled off', () => {
			test('a normal batch with no failures succeeds with no skipped operations', () => {
				const result = applyOperations(baseWorkflow(), [
					{ type: 'updateNodeParameters', nodeName: 'B', parameters: { url: 'https://new' } },
				]);
				expect(result.success).toBe(true);
				if (!result.success) return;
				expect(result.skippedOperations ?? []).toEqual([]);
			});

			test('a failing group operation still aborts the whole batch', () => {
				const result = applyOperations(baseWorkflow(), [
					{ type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] },
				]);
				expect(result.success).toBe(false);
				if (result.success) return;
				expect(result.error).toContain("node 'Missing' in group 'Group' not found");
				expect(result.opIndex).toBe(0);
			});

			test('a failing non-group operation still aborts the whole batch', () => {
				const result = applyOperations(baseWorkflow(), [
					{ type: 'updateNodeParameters', nodeName: 'B', parameters: { url: 'https://new' } },
					{ type: 'removeNode', nodeName: 'Missing' },
				]);
				expect(result.success).toBe(false);
				if (result.success) return;
				expect(result.opIndex).toBe(1);
			});
		});

		describe('canvasGroupsEnabled on', () => {
			test('a failing addNodeGroup is skipped while surrounding operations still apply', () => {
				const wf = baseWorkflow();
				const ops: PartialUpdateOperation[] = [
					{ type: 'addNode', node: { name: 'C', type: 'n8n-nodes-base.set', typeVersion: 1 } },
					{ type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] },
					{ type: 'addConnection', source: 'A', target: 'C' },
				];
				const result = applyOperations(wf, ops, { canvasGroupsEnabled: true });
				expect(result.success).toBe(true);
				if (!result.success) return;
				expect(result.workflow.nodes.some((n) => n.name === 'C')).toBe(true);
				expect(result.workflow.connections.A?.main?.[0]).toEqual(
					expect.arrayContaining([expect.objectContaining({ node: 'C' })]),
				);
				expect(result.workflow.nodeGroups ?? []).toEqual([]);
				expect(result.skippedOperations).toEqual([
					{
						opIndex: 1,
						type: 'addNodeGroup',
						reason: "node 'Missing' in group 'Group' not found",
					},
				]);
			});

			test('a failing non-group operation still aborts the whole batch even with the flag on', () => {
				const result = applyOperations(
					baseWorkflow(),
					[{ type: 'removeNode', nodeName: 'Missing' }],
					{ canvasGroupsEnabled: true },
				);
				expect(result.success).toBe(false);
				if (result.success) return;
				expect(result.opIndex).toBe(0);
			});

			test('two non-fatal failures in the same batch are both skipped, in order', () => {
				const wf = {
					...baseWorkflow(),
					nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a'] }],
				};
				const ops: PartialUpdateOperation[] = [
					{ type: 'addNodeGroup', name: 'Group', nodeNames: ['B'] },
					{ type: 'updateNodeGroup', groupName: 'Missing', newName: 'X' },
				];
				const result = applyOperations(wf, ops, { canvasGroupsEnabled: true });
				expect(result.success).toBe(true);
				if (!result.success) return;
				expect(result.workflow.nodeGroups).toEqual([{ id: 'g1', name: 'Group', nodeIds: ['a'] }]);
				expect(result.skippedOperations).toEqual([
					{
						opIndex: 0,
						type: 'addNodeGroup',
						reason: "a node group named 'Group' already exists",
					},
					{
						opIndex: 1,
						type: 'updateNodeGroup',
						reason: "node group 'Missing' not found",
					},
				]);
			});

			test('a batch where every operation is a failing group op still succeeds with no groups persisted', () => {
				const ops: PartialUpdateOperation[] = [
					{ type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] },
					{ type: 'removeNodeGroup', groupName: 'Missing' },
				];
				const result = applyOperations(baseWorkflow(), ops, { canvasGroupsEnabled: true });
				expect(result.success).toBe(true);
				if (!result.success) return;
				expect(result.workflow.nodeGroups ?? []).toEqual([]);
				expect(result.nodeGroupsChanged).toBe(false);
				expect(result.skippedOperations).toHaveLength(2);
			});

			test('skipping a non-fatal operation does not corrupt state for later operations', () => {
				const ops: PartialUpdateOperation[] = [
					{ type: 'addNode', node: { name: 'C', type: 'n8n-nodes-base.set', typeVersion: 1 } },
					{ type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] },
					{ type: 'setNodeParameter', nodeName: 'C', path: '/foo', value: 'bar' },
				];
				const result = applyOperations(baseWorkflow(), ops, { canvasGroupsEnabled: true });
				expect(result.success).toBe(true);
				if (!result.success) return;
				expect(result.workflow.nodes.find((n) => n.name === 'C')!.parameters).toEqual({
					foo: 'bar',
				});
				expect(result.skippedOperations).toHaveLength(1);
			});

			test('opIndex in skippedOperations reflects the original position in the input array', () => {
				const ops: PartialUpdateOperation[] = [
					{ type: 'updateNodeParameters', nodeName: 'B', parameters: { url: 'https://new' } },
					{ type: 'addNode', node: { name: 'C', type: 'n8n-nodes-base.set', typeVersion: 1 } },
					{ type: 'addNodeGroup', name: 'Group', nodeNames: ['Missing'] },
					{ type: 'setNodeParameter', nodeName: 'C', path: '/foo', value: 'bar' },
				];
				const result = applyOperations(baseWorkflow(), ops, { canvasGroupsEnabled: true });
				expect(result.success).toBe(true);
				if (!result.success) return;
				expect(result.skippedOperations).toEqual([
					{ opIndex: 2, type: 'addNodeGroup', reason: expect.any(String) as string },
				]);
			});
		});
	});

	describe('toWorkflowSlice', () => {
		test('carries nodeGroups through from the workflow entity', () => {
			const slice = toWorkflowSlice({
				name: 'wf',
				nodes: [],
				connections: {},
				nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a'] }],
			} as never);
			expect(slice.nodeGroups).toEqual([{ id: 'g1', name: 'Group', nodeIds: ['a'] }]);
		});
	});
});
