import type {
	IDataObject,
	IWorkflowExecuteAdditionalData,
	EngineResponse,
	WorkflowExecuteMode,
	IExecuteFunctions,
	IPairedItemData,
	INodeExecutionData,
	INodeType,
	IRunExecutionData,
} from 'n8n-workflow';
import {
	BaseError,
	NodeConnectionTypes,
	UnexpectedError,
	createRunExecutionData,
} from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';

import { NodeTypes } from '@test/helpers';

vi.mock('node:fs', async (importActual) => ({
	...(await importActual()),
	existsSync: vi.fn().mockReturnValue(false),
	renameSync: vi.fn(),
}));

import { DirectedGraph } from '../partial-execution-utils';
import {
	types,
	nodeTypes,
	passThroughNode,
	nodeTypeArguments,
	modifyNode,
} from './mock-node-types';
import { createNodeData, toITaskData } from '../partial-execution-utils/__tests__/helpers';
import { WorkflowExecute } from '../workflow-execute';

describe('processRunExecutionData', () => {
	const runHook = vi.fn().mockResolvedValue(undefined);
	const additionalData = mock<IWorkflowExecuteAdditionalData>({
		hooks: { runHook },
		restartExecutionId: undefined,
		encryptedRunnerIdentity: undefined,
		webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
		formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
	});
	const executionMode: WorkflowExecuteMode = 'trigger';

	beforeEach(() => {
		vi.resetAllMocks();
		runHook.mockResolvedValue(undefined);
	});

	test('throws if execution-data is missing', () => {
		// ARRANGE
		const node = createNodeData({ name: 'passThrough', type: types.passThrough });
		const workflow = new DirectedGraph()
			.addNodes(node)
			.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });

		const executionData = createRunExecutionData({
			startData: { startNodes: [{ name: node.name, sourceData: null }] },
		});
		delete executionData.executionData;

		const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

		// ACT & ASSERT
		// The function returns a Promise, but throws synchronously, so we can't await it.
		// eslint-disable-next-line @typescript-eslint/promise-function-async
		const execution = () => workflowExecute.processRunExecutionData(workflow);

		expect(execution).toThrow(UnexpectedError);
		expect(execution).toThrow('Failed to run workflow due to missing execution data');
	});

	test('returns input data verbatim', async () => {
		// ARRANGE
		const node = createNodeData({ name: 'node', type: types.passThrough });
		const workflow = new DirectedGraph()
			.addNodes(node)
			.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });

		const taskDataConnection = { main: [[{ json: { foo: 1 } }]] };
		const executionData = createRunExecutionData({
			startData: { startNodes: [{ name: node.name, sourceData: null }] },
			executionData: { nodeExecutionStack: [{ data: taskDataConnection, node, source: null }] },
		});

		const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

		// ACT
		const result = await workflowExecute.processRunExecutionData(workflow);

		// ASSERT
		expect(result.data.resultData.runData).toMatchObject({ node: [{ data: taskDataConnection }] });
	});

	test('calls the right hooks in the right order', async () => {
		// ARRANGE
		const node1 = createNodeData({ name: 'node1', type: types.passThrough });
		const node2 = createNodeData({ name: 'node2', type: types.passThrough });
		const workflow = new DirectedGraph()
			.addNodes(node1, node2)
			.addConnections({ from: node1, to: node2 })
			.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });

		const taskDataConnection = { main: [[{ json: { foo: 1 } }]] };
		const executionData = createRunExecutionData({
			startData: { startNodes: [{ name: node1.name, sourceData: null }] },
			executionData: {
				nodeExecutionStack: [{ data: taskDataConnection, node: node1, source: null }],
			},
		});

		const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

		// ACT
		await workflowExecute.processRunExecutionData(workflow);

		// ASSERT
		expect(runHook).toHaveBeenCalledTimes(6);
		expect(runHook).toHaveBeenNthCalledWith(1, 'workflowExecuteBefore', expect.any(Array));
		expect(runHook).toHaveBeenNthCalledWith(2, 'nodeExecuteBefore', expect.any(Array));
		expect(runHook).toHaveBeenNthCalledWith(3, 'nodeExecuteAfter', expect.any(Array));
		expect(runHook).toHaveBeenNthCalledWith(4, 'nodeExecuteBefore', expect.any(Array));
		expect(runHook).toHaveBeenNthCalledWith(5, 'nodeExecuteAfter', expect.any(Array));
		expect(runHook).toHaveBeenNthCalledWith(6, 'workflowExecuteAfter', expect.any(Array));
	});

	describe('a handler that throws at workflowExecuteBefore', () => {
		const blocked = new UnexpectedError('blocked before the first node ran');
		const node = createNodeData({ name: 'node', type: types.passThrough });
		const workflow = new DirectedGraph()
			.addNodes(node)
			.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });

		const runBlocked = async (executionData: IRunExecutionData) => {
			runHook.mockImplementation(async (hookName: string) => {
				if (hookName === 'workflowExecuteBefore') throw blocked;
			});

			const result = await new WorkflowExecute(
				additionalData,
				executionMode,
				executionData,
			).processRunExecutionData(workflow);

			return { result, hooks: (runHook.mock.calls as Array<[string]>).map(([name]) => name) };
		};

		test('aborts the execution and keeps the error it was given', async () => {
			const { result, hooks } = await runBlocked(
				createRunExecutionData({
					startData: { startNodes: [{ name: node.name, sourceData: null }] },
					executionData: {
						nodeExecutionStack: [{ data: { main: [[{ json: { foo: 1 } }]] }, node, source: null }],
					},
				}),
			);

			expect(result.data.resultData.error?.message).toBe(blocked.message);
			expect(hooks).toEqual(['workflowExecuteBefore', 'workflowExecuteAfter']);
		});

		// Empty for e.g. a Chat Trigger-only workflow, which used to mask the error with a TypeError.
		test('keeps the error when there is no node on the execution stack', async () => {
			const { result, hooks } = await runBlocked(
				createRunExecutionData({ executionData: { nodeExecutionStack: [] } }),
			);

			expect(result.data.resultData.error?.message).toBe(blocked.message);
			expect(hooks).toEqual(['workflowExecuteBefore', 'workflowExecuteAfter']);
		});
	});

	test('agent node emits nodeExecuteBefore only once when resuming after tool execution', async () => {
		// ARRANGE
		// Create agent node that returns EngineRequest, then resumes with tool results
		const agentNodeType = modifyNode(passThroughNode)
			.return({
				actions: [
					{
						actionType: 'ExecutionNodeAction',
						nodeName: 'tool',
						input: { query: 'test input' },
						type: 'ai_tool',
						id: 'action_1',
						metadata: {},
					},
				],
				metadata: {},
			})
			.return((response) => [[{ json: { result: 'agent completed', response } }]])
			.done();

		const trigger = createNodeData({ name: 'trigger', type: types.passThrough });
		const agent = createNodeData({ name: 'agent', type: 'agent' });
		const tool = createNodeData({ name: 'tool', type: types.passThrough });

		const nodeTypes = NodeTypes({
			...nodeTypeArguments,
			agent: { type: agentNodeType, sourcePath: '' },
		});

		const workflowInstance = new DirectedGraph()
			.addNodes(trigger, agent, tool)
			.addConnections(
				{ from: trigger, to: agent },
				{ from: tool, to: agent, type: NodeConnectionTypes.AiTool },
			)
			.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });

		const taskDataConnection = { main: [[{ json: { foo: 1 } }]] };
		const executionData = createRunExecutionData({
			startData: { startNodes: [{ name: trigger.name, sourceData: null }] },
			executionData: {
				nodeExecutionStack: [{ data: taskDataConnection, node: trigger, source: null }],
			},
		});

		const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

		// ACT
		await workflowExecute.processRunExecutionData(workflowInstance);

		// ASSERT
		expect(
			(runHook.mock.calls as Array<[string, unknown[]]>).map((hook) => ({
				name: hook[0],
				node: typeof hook[1][0] === 'string' ? hook[1][0] : undefined,
			})),
		).toEqual([
			{ name: 'workflowExecuteBefore' },
			{ name: 'nodeExecuteBefore', node: 'trigger' },
			{ name: 'nodeExecuteAfter', node: 'trigger' },
			{ name: 'nodeExecuteBefore', node: 'agent' },
			{ name: 'nodeExecuteBefore', node: 'tool' },
			{ name: 'nodeExecuteAfter', node: 'tool' },
			{ name: 'nodeExecuteAfter', node: 'agent' },
			{ name: 'workflowExecuteAfter' },
		]);
	});

	describe('runExecutionData.waitTill', () => {
		test('handles waiting state properly when waitTill is set', async () => {
			// ARRANGE
			const node = createNodeData({ name: 'waitingNode', type: types.passThrough });
			const workflow = new DirectedGraph()
				.addNodes(node)
				.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });

			const data: IDataObject = { foo: 1 };
			const executionData = createRunExecutionData({
				startData: { startNodes: [{ name: node.name, sourceData: null }] },
				resultData: {
					runData: { waitingNode: [toITaskData([{ data }], { executionStatus: 'waiting' })] },
					lastNodeExecuted: 'waitingNode',
				},
				executionData: {
					nodeExecutionStack: [{ data: { main: [[{ json: data }]] }, node, source: null }],
				},
				waitTill: new Date('2024-01-01'),
			});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

			// ACT
			const result = await workflowExecute.processRunExecutionData(workflow);

			// ASSERT
			expect(result.waitTill).toBeUndefined();
			// The waiting state handler should have removed the last entry from
			// runData, but execution adds a new one, so we should have 1 entry.
			expect(result.data.resultData.runData.waitingNode).toHaveLength(1);
			// the status was `waiting` before
			expect(result.data.resultData.runData.waitingNode[0].executionStatus).toEqual('success');
		});

		test('restores dynamic-credential usage stashed on the resumed stack entry', async () => {
			// ARRANGE
			// The waiting task carrying the flags is popped on resume and the node re-runs
			// disabled, so the usage a sub-execution reported while this execution waited
			// rides on the stack entry metadata and must reach the freshly stamped task.
			const node = createNodeData({ name: 'waitingNode', type: types.passThrough });
			const workflow = new DirectedGraph()
				.addNodes(node)
				.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });

			const data: IDataObject = { foo: 1 };
			const executionData = createRunExecutionData({
				startData: { startNodes: [{ name: node.name, sourceData: null }] },
				resultData: {
					runData: {
						waitingNode: [
							toITaskData([{ data }], {
								executionStatus: 'waiting',
								usedDynamicCredentials: true,
							}),
						],
					},
					lastNodeExecuted: 'waitingNode',
				},
				executionData: {
					nodeExecutionStack: [
						{
							data: { main: [[{ json: data }]] },
							node,
							source: null,
							metadata: {
								dynamicCredentialsUsage: {
									usedDynamicCredentials: true,
									attemptedDynamicCredentials: true,
									dynamicCredentialsResolvedUserId: 'resolved-user',
								},
							},
						},
					],
					runtimeData: { version: 1, establishedAt: 0, source: 'trigger' },
				},
				waitTill: new Date('2024-01-01'),
			});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

			// ACT
			const result = await workflowExecute.processRunExecutionData(workflow);

			// ASSERT
			const [taskData] = result.data.resultData.runData.waitingNode;
			expect(result.data.resultData.runData.waitingNode).toHaveLength(1);
			expect(taskData.executionStatus).toEqual('success');
			expect(taskData.usedDynamicCredentials).toBe(true);
			expect(taskData.attemptedDynamicCredentials).toBe(true);
			expect(result.data.executionData?.runtimeData?.executedByUserId).toBe('resolved-user');
			// The stash is transport-only and must not persist into the task metadata.
			expect(taskData.metadata?.dynamicCredentialsUsage).toBeUndefined();
		});

		test('restores an attempted-only stash without recording a resolved user', async () => {
			// ARRANGE
			const node = createNodeData({ name: 'waitingNode', type: types.passThrough });
			const workflow = new DirectedGraph()
				.addNodes(node)
				.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });

			const data: IDataObject = { foo: 1 };
			const executionData = createRunExecutionData({
				startData: { startNodes: [{ name: node.name, sourceData: null }] },
				resultData: {
					runData: { waitingNode: [toITaskData([{ data }], { executionStatus: 'waiting' })] },
					lastNodeExecuted: 'waitingNode',
				},
				executionData: {
					nodeExecutionStack: [
						{
							data: { main: [[{ json: data }]] },
							node,
							source: null,
							metadata: {
								dynamicCredentialsUsage: {
									attemptedDynamicCredentials: true,
									// Must be ignored: a resolved user requires a used flag.
									dynamicCredentialsResolvedUserId: 'resolved-user',
								},
							},
						},
					],
					runtimeData: { version: 1, establishedAt: 0, source: 'trigger' },
				},
				waitTill: new Date('2024-01-01'),
			});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

			// ACT
			const result = await workflowExecute.processRunExecutionData(workflow);

			// ASSERT
			const [taskData] = result.data.resultData.runData.waitingNode;
			expect(taskData.attemptedDynamicCredentials).toBe(true);
			expect(taskData.usedDynamicCredentials).toBeUndefined();
			expect(result.data.executionData?.runtimeData?.executedByUserId).toBeUndefined();
		});
	});

	describe('workflow issues', () => {
		test('throws if workflow contains nodes with missing required properties', () => {
			// ARRANGE
			const node = createNodeData({ name: 'node', type: types.testNodeWithRequiredProperty });
			const workflow = new DirectedGraph()
				.addNodes(node)
				.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });

			const taskDataConnection = { main: [[{ json: { foo: 1 } }]] };
			const executionData = createRunExecutionData({
				startData: { startNodes: [{ name: node.name, sourceData: null }] },
				executionData: {
					nodeExecutionStack: [{ data: taskDataConnection, node, source: null }],
				},
			});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

			// ACT & ASSERT
			// The function returns a Promise, but throws synchronously, so we can't await it.
			// eslint-disable-next-line @typescript-eslint/promise-function-async
			const execution = () => workflowExecute.processRunExecutionData(workflow);

			expect(execution).toThrow(BaseError);
			expect(execution).toThrow(
				/^The 'node' node has issues:\n- Parameter "Required Text" is required\.$/,
			);
		});

		test('does not complain about nodes with issue past the destination node', () => {
			// ARRANGE
			const node1 = createNodeData({ name: 'node1', type: types.passThrough });
			const node2 = createNodeData({ name: 'node2', type: types.testNodeWithRequiredProperty });
			const workflow = new DirectedGraph()
				.addNodes(node1, node2)
				.addConnection({ from: node1, to: node2 })
				.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });

			const taskDataConnection = { main: [[{ json: { foo: 1 } }]] };
			const executionData = createRunExecutionData({
				startData: {
					startNodes: [{ name: node1.name, sourceData: null }],
					destinationNode: { nodeName: node1.name, mode: 'inclusive' },
				},
				executionData: {
					nodeExecutionStack: [{ data: taskDataConnection, node: node1, source: null }],
				},
			});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

			// ACT & ASSERT
			// The function returns a Promise, but throws synchronously, so we can't await it.
			// eslint-disable-next-line @typescript-eslint/promise-function-async
			expect(() => workflowExecute.processRunExecutionData(workflow)).not.toThrowError();
		});
	});

	describe('waiting tools', () => {
		test('run() executes requested ai_tool actions when destination is an agent', async () => {
			// ARRANGE
			const triggerNode = createNodeData({ name: 'trigger', type: types.passThrough });
			const toolNode = createNodeData({ name: 'tool', type: types.passThrough });
			const toolInput = { query: 'test input' };

			const agentNodeType = modifyNode(passThroughNode)
				.return({
					actions: [
						{
							actionType: 'ExecutionNodeAction',
							nodeName: toolNode.name,
							input: toolInput,
							type: 'ai_tool',
							id: 'action_1',
							metadata: {},
						},
					],
					metadata: { requestId: 'test_request' },
				})
				.return((response?: EngineResponse) => {
					return [
						[
							{
								json: {
									result: 'agent completed',
									actionResponsesCount: response?.actionResponses.length ?? 0,
								},
							},
						],
					];
				})
				.done();

			const agentNode = createNodeData({ name: 'agent', type: 'agentNodeType' });
			const customNodeTypes = NodeTypes({
				...nodeTypeArguments,
				agentNodeType: { type: agentNodeType, sourcePath: '' },
			});

			const workflow = new DirectedGraph()
				.addNodes(triggerNode, agentNode, toolNode)
				.addConnections(
					{ from: triggerNode, to: agentNode, type: NodeConnectionTypes.Main },
					{ from: toolNode, to: agentNode, type: NodeConnectionTypes.AiTool },
				)
				.toWorkflow({
					name: '',
					active: false,
					nodeTypes: customNodeTypes,
					settings: { executionOrder: 'v1' },
				});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode);

			// ACT
			const result = await workflowExecute.run({
				workflow,
				startNode: triggerNode,
				destinationNode: { nodeName: agentNode.name, mode: 'inclusive' },
			});

			// ASSERT
			const runData = result.data.resultData.runData;
			expect(result.data.resultData.error).toBeUndefined();

			expect(Object.keys(runData)).toEqual(
				expect.arrayContaining([triggerNode.name, agentNode.name, toolNode.name]),
			);
			expect(runData[toolNode.name]).toBeDefined();
			expect(runData[toolNode.name]).toHaveLength(1);
			expect(runData[toolNode.name][0].inputOverride?.ai_tool?.[0]?.[0]?.json).toMatchObject(
				toolInput,
			);

			const agentRuns = runData[agentNode.name];
			const agentFinalOutput = agentRuns[agentRuns.length - 1].data?.main?.[0]?.[0]?.json;
			expect(agentFinalOutput?.actionResponsesCount).toBe(1);
		});

		test('handles Request objects with actions correctly', async () => {
			// ARRANGE
			let response: EngineResponse | undefined;

			const tool1Node = createNodeData({ name: 'tool1', type: types.passThrough });
			const tool2Node = createNodeData({ name: 'tool2', type: types.passThrough });
			const tool1Input = { query: 'test input' };
			const tool2Input = { data: 'another input' };
			const nodeTypeWithRequests = modifyNode(passThroughNode)
				.return({
					actions: [
						{
							actionType: 'ExecutionNodeAction',
							nodeName: tool1Node.name,
							input: tool1Input,
							type: 'ai_tool',
							id: 'action_1',
							metadata: { step: 1 },
						},
						{
							actionType: 'ExecutionNodeAction',
							nodeName: tool2Node.name,
							input: tool2Input,
							type: 'ai_tool',
							id: 'action_2',
							metadata: { step: 1 },
						},
					],
					metadata: { requestId: 'test_request_step1' },
				})
				.return((r) => {
					response = r;
					// Verify the response contains the expected action responses
					expect(r).toBeDefined();
					expect(r?.actionResponses).toHaveLength(2);

					// Return final result incorporating the response data
					return [
						[
							{
								json: {
									finalResult: 'Completed with response data',
									responseMetadata: r?.metadata as IDataObject,
									actionCount: r?.actionResponses?.length,
								},
							},
						],
					];
				})
				.done();
			const nodeWithRequests = createNodeData({
				name: 'nodeWithRequests',
				type: 'nodeWithRequests',
			});

			const nodeTypes = NodeTypes({
				...nodeTypeArguments,
				nodeWithRequests: { type: nodeTypeWithRequests, sourcePath: '' },
			});

			const workflow = new DirectedGraph()
				.addNodes(nodeWithRequests, tool1Node, tool2Node)
				.addConnections({ from: tool1Node, to: nodeWithRequests, type: 'ai_tool' })
				.addConnections({ from: tool2Node, to: nodeWithRequests, type: 'ai_tool' })
				.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });

			const taskDataConnection = { main: [[{ json: { prompt: 'test prompt' } }]] };
			const executionData = createRunExecutionData({
				startData: { startNodes: [{ name: nodeWithRequests.name, sourceData: null }] },
				executionData: {
					nodeExecutionStack: [
						{
							data: taskDataConnection,
							node: nodeWithRequests,
							source: { main: [{ previousNode: 'Start' }] },
						},
					],
				},
			});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

			// ACT
			const result = await workflowExecute.processRunExecutionData(workflow);

			// ASSERT
			const runData = result.data.resultData.runData;

			// 1. Verify the Response object was passed to the second execution
			expect(response).toBeDefined();
			expect(response?.actionResponses).toHaveLength(2);
			expect(response?.metadata).toEqual({ requestId: 'test_request_step1' });

			// 2. Verify each action response contains the correct data and metadata
			const actionResponses = response?.actionResponses ?? [];
			const action1Response = actionResponses.find((r) => r.action.id === 'action_1');
			const action2Response = actionResponses.find((r) => r.action.id === 'action_2');

			expect(action1Response).toBeDefined();
			expect(action1Response?.action.metadata).toEqual({ step: 1 });
			expect(action1Response?.data.data?.ai_tool?.[0]?.[0]?.json).toMatchObject({
				query: 'test input',
				toolCallId: 'action_1',
			});

			expect(action2Response).toBeDefined();
			expect(action2Response?.action.metadata).toEqual({ step: 1 });
			expect(action2Response?.data.data?.ai_tool?.[0]?.[0]?.json).toMatchObject({
				data: 'another input',
				toolCallId: 'action_2',
			});

			// The agent should have been executed and returned a Request with actions
			expect(runData[nodeWithRequests.name]).toHaveLength(1);
			expect(runData[nodeWithRequests.name][0].metadata?.subNodeExecutionData).toBeDefined();

			// Tool nodes should have been added to runData with inputOverride
			expect(runData[tool1Node.name]).toHaveLength(1);

			expect(runData[tool1Node.name][0].inputOverride).toEqual({
				ai_tool: [
					[
						{
							json: { query: 'test input' },
							pairedItem: {
								input: 0,
								item: 0,
								sourceOverwrite: {
									previousNode: 'Start',
									previousNodeOutput: 0,
									previousNodeRun: 0,
								},
							},
						},
					],
				],
			});

			expect(runData[tool2Node.name]).toHaveLength(1);
			expect(runData[tool2Node.name][0].inputOverride).toEqual({
				ai_tool: [
					[
						{
							json: { data: 'another input' },
							pairedItem: {
								input: 0,
								item: 0,
								sourceOverwrite: {
									previousNode: 'Start',
									previousNodeOutput: 0,
									previousNodeRun: 0,
								},
							},
						},
					],
				],
			});

			// Tools should have executed successfully
			expect(runData[tool1Node.name][0].data).toBeDefined();
			expect(runData[tool1Node.name][0].executionStatus).toBe('success');
			expect(runData[tool2Node.name][0].data).toBeDefined();
			expect(runData[tool2Node.name][0].executionStatus).toBe('success');

			// 3. Verify the final node output includes response data
			expect(runData[nodeWithRequests.name][0].data).toBeDefined();
			expect(runData[nodeWithRequests.name][0].executionStatus).toBe('success');
			expect(runData[nodeWithRequests.name][0].data?.main?.[0]?.[0]?.json).toMatchObject({
				finalResult: 'Completed with response data',
				responseMetadata: { requestId: 'test_request_step1' },
				actionCount: 2,
			});
		});

		test('executes requested tools in the order the actions were requested', async () => {
			// ARRANGE
			const executionOrder: string[] = [];
			let response: EngineResponse | undefined;

			const recordingTool = (name: string): INodeType => ({
				...passThroughNode,
				async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
					executionOrder.push(name);
					return [this.getInputData()];
				},
			});

			const toolNames = ['tool1', 'tool2', 'tool3'];
			const toolNodes = toolNames.map((name) => createNodeData({ name, type: `${name}Type` }));

			const nodeTypeWithRequests = modifyNode(passThroughNode)
				.return({
					actions: toolNames.map((nodeName, index) => ({
						actionType: 'ExecutionNodeAction',
						nodeName,
						input: { query: nodeName },
						type: 'ai_tool',
						id: `action_${index}`,
						metadata: { itemIndex: 0 },
					})),
					metadata: {},
				})
				.return((r) => {
					response = r;
					return [[{ json: { done: true } }]];
				})
				.done();

			const nodeWithRequests = createNodeData({
				name: 'nodeWithRequests',
				type: 'nodeWithRequests',
			});

			const nodeTypes = NodeTypes({
				...nodeTypeArguments,
				nodeWithRequests: { type: nodeTypeWithRequests, sourcePath: '' },
				...Object.fromEntries(
					toolNames.map((name) => [`${name}Type`, { type: recordingTool(name), sourcePath: '' }]),
				),
			});

			const graph = new DirectedGraph().addNodes(nodeWithRequests, ...toolNodes);
			for (const toolNode of toolNodes) {
				graph.addConnections({ from: toolNode, to: nodeWithRequests, type: 'ai_tool' });
			}
			const workflow = graph.toWorkflow({
				name: '',
				active: false,
				nodeTypes,
				settings: { executionOrder: 'v1' },
			});

			const executionData = createRunExecutionData({
				startData: { startNodes: [{ name: nodeWithRequests.name, sourceData: null }] },
				executionData: {
					nodeExecutionStack: [
						{
							data: { main: [[{ json: { prompt: 'test prompt' } }]] },
							node: nodeWithRequests,
							source: { main: [{ previousNode: 'Start' }] },
						},
					],
				},
			});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

			// ACT
			const result = await workflowExecute.processRunExecutionData(workflow);

			// ASSERT
			// Tools run in request order, not reversed by the LIFO execution stack
			expect(executionOrder).toEqual(toolNames);

			// The responses handed back to the requesting node stay aligned with that order
			expect((response?.actionResponses ?? []).map((r) => r.action.nodeName)).toEqual(toolNames);

			// ...and so does what the logs panel sorts on
			const runData = result.data.resultData.runData;
			const executionIndexes = toolNames.map((name) => runData[name][0].executionIndex);
			expect(executionIndexes).toEqual([...executionIndexes].sort((a, b) => a - b));
		});

		test('skips waiting tools processing when parent node cannot be found', async () => {
			// ARRANGE
			// This test simulates the scenario where executionData.source.main[0].previousNode
			// is null/undefined (line 2037-2044 in workflow-execute.ts)
			let response: EngineResponse | undefined;

			const tool1Node = createNodeData({ name: 'tool1', type: types.passThrough });
			const tool1Input = { query: 'test input' };
			const nodeTypeWithRequests = modifyNode(passThroughNode)
				.return({
					actions: [
						{
							actionType: 'ExecutionNodeAction',
							nodeName: tool1Node.name,
							input: tool1Input,
							type: 'ai_tool',
							id: 'action_1',
							metadata: {},
						},
					],
					metadata: { requestId: 'test_request' },
				})
				.return((r) => {
					response = r;
					return [[{ json: { finalResult: 'Agent completed with tool results' } }]];
				})
				.done();
			const nodeWithRequests = createNodeData({
				name: 'nodeWithRequests',
				type: 'nodeWithRequests',
			});

			const nodeTypes = NodeTypes({
				...nodeTypeArguments,
				nodeWithRequests: { type: nodeTypeWithRequests, sourcePath: '' },
			});

			const workflow = new DirectedGraph()
				.addNodes(nodeWithRequests, tool1Node)
				.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });

			const taskDataConnection = { main: [[{ json: { prompt: 'test prompt' } }]] };
			const executionData = createRunExecutionData({
				startData: { startNodes: [{ name: nodeWithRequests.name, sourceData: null }] },
				executionData: {
					nodeExecutionStack: [
						{
							data: taskDataConnection,
							node: nodeWithRequests,
							// Setting source to null triggers the "Cannot find parent node" condition
							source: null,
						},
					],
				},
			});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

			// ACT
			const result = await workflowExecute.processRunExecutionData(workflow);

			// ASSERT
			const runData = result.data.resultData.runData;

			// When parent node cannot be found (line 2038-2044), the execution loop continues
			// which means the waiting tools processing is skipped entirely:

			// 1. The agent node never gets re-executed with the Response callback
			expect(runData[nodeWithRequests.name]).toBeUndefined();

			// 2. Tool nodes get added to runData with inputOverride but are never actually executed
			expect(runData[tool1Node.name]).toHaveLength(1);
			expect(runData[tool1Node.name][0].inputOverride).toEqual({
				ai_tool: [
					[
						{
							json: { query: 'test input' },
							pairedItem: {
								input: 0,
								item: 0,
								sourceOverwrite: {
									previousNode: 'nodeWithRequests',
									previousNodeOutput: 0,
									previousNodeRun: 0,
								},
							},
						},
					],
				],
			});
			// The tool node should not have execution data since it was never run
			expect(runData[tool1Node.name][0].data).toBeUndefined();
			expect(runData[tool1Node.name][0].executionStatus).toBeUndefined();

			// 3. The response callback is never called since the agent's second execution is skipped
			expect(response).toBeUndefined();
		});

		test('resets responses between different node executions', async () => {
			// ARRANGE
			let firstResponse: EngineResponse | undefined;
			let secondResponse: EngineResponse | undefined;

			// Create first node that returns Request with actions
			const firstNodeWithRequests = modifyNode(passThroughNode)
				.return({
					actions: [
						{
							actionType: 'ExecutionNodeAction',
							nodeName: 'tool1',
							input: { data: 'first node input' },
							type: 'ai_tool',
							id: 'first_action',
							metadata: {},
						},
					],
					metadata: { requestId: 'first_request' },
				})
				.return((response?: EngineResponse) => {
					firstResponse = response;
					return [[{ json: { result: 'first node completed' } }]];
				})
				.done();

			// Create second node that should NOT receive responses from the first node
			const secondNodeWithRequests = modifyNode(passThroughNode)
				.return((response?: EngineResponse) => {
					secondResponse = response;
					// This should receive an empty response, not the first node's responses
					return [[{ json: { result: 'second node completed' } }]];
				})
				.done();

			const tool1Node = createNodeData({ name: 'tool1', type: types.passThrough });
			const firstNode = createNodeData({ name: 'firstNode', type: 'firstNodeType' });
			const secondNode = createNodeData({ name: 'secondNode', type: 'secondNodeType' });

			const customNodeTypes = NodeTypes({
				...nodeTypeArguments,
				firstNodeType: { type: firstNodeWithRequests, sourcePath: '' },
				secondNodeType: { type: secondNodeWithRequests, sourcePath: '' },
			});

			const workflow = new DirectedGraph()
				.addNodes(firstNode, tool1Node, secondNode)
				.addConnections({ from: firstNode, to: secondNode })
				.toWorkflow({
					name: '',
					active: false,
					nodeTypes: customNodeTypes,
					settings: { executionOrder: 'v1' },
				});

			const taskDataConnection = { main: [[{ json: { input: 'start' } }]] };
			const executionData = createRunExecutionData({
				startData: { startNodes: [{ name: firstNode.name, sourceData: null }] },
				executionData: {
					nodeExecutionStack: [
						{
							data: taskDataConnection,
							node: firstNode,
							source: { main: [{ previousNode: 'Start' }] },
						},
					],
				},
			});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

			// ACT
			const result = await workflowExecute.processRunExecutionData(workflow);

			// ASSERT
			// 1. First node should receive responses from its tool execution
			expect(firstResponse).toBeDefined();
			expect(firstResponse?.actionResponses).toHaveLength(1);

			// 2. Second node should receive empty responses (not the first node's responses)
			expect(secondResponse).toBeDefined();
			expect(secondResponse?.actionResponses).toHaveLength(0); // This should be empty due to reset
			expect(secondResponse?.metadata).toEqual({}); // Empty metadata

			// 3. Both nodes should have completed successfully
			const runData = result.data.resultData.runData;
			expect(runData[firstNode.name]).toHaveLength(1);
			expect(runData[secondNode.name]).toHaveLength(1);
		});

		test('preserves inputOverride and sets error output when AI tool node fails', async () => {
			// ARRANGE
			// Create an error-throwing tool node
			const errorMessage = 'Tool execution failed with validation error';
			const errorThrowingNode: INodeType = {
				...passThroughNode,
				async execute(): Promise<INodeExecutionData[][]> {
					throw new Error(errorMessage);
				},
			};

			const toolNode = createNodeData({ name: 'errorTool', type: 'errorThrowingNode' });
			const toolInput = { query: 'test input that will fail' };

			const agentNodeType = modifyNode(passThroughNode)
				.return({
					actions: [
						{
							actionType: 'ExecutionNodeAction',
							nodeName: toolNode.name,
							input: toolInput,
							type: 'ai_tool',
							id: 'action_1',
							metadata: {},
						},
					],
					metadata: { requestId: 'test_request' },
				})
				.done();
			const agentNode = createNodeData({ name: 'agentNode', type: 'agentNodeType' });

			const customNodeTypes = NodeTypes({
				...nodeTypeArguments,
				agentNodeType: { type: agentNodeType, sourcePath: '' },
				errorThrowingNode: { type: errorThrowingNode, sourcePath: '' },
			});

			const workflow = new DirectedGraph()
				.addNodes(agentNode, toolNode)
				.addConnections({ from: toolNode, to: agentNode, type: 'ai_tool' })
				.toWorkflow({
					name: '',
					active: false,
					nodeTypes: customNodeTypes,
					settings: { executionOrder: 'v1' },
				});

			const taskDataConnection = { main: [[{ json: { prompt: 'test prompt' } }]] };
			const executionData = createRunExecutionData({
				startData: { startNodes: [{ name: agentNode.name, sourceData: null }] },
				executionData: {
					nodeExecutionStack: [
						{
							data: taskDataConnection,
							node: agentNode,
							source: { main: [{ previousNode: 'Start' }] },
						},
					],
				},
			});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

			// ACT
			const result = await workflowExecute.processRunExecutionData(workflow);

			// ASSERT
			const runData = result.data.resultData.runData;

			// Tool node should have exactly one entry (not two separate entries)
			expect(runData[toolNode.name]).toHaveLength(1);

			const toolRunData = runData[toolNode.name][0];

			// inputOverride should be preserved (set by requests-response.ts before execution)
			expect(toolRunData.inputOverride).toBeDefined();
			expect(toolRunData.inputOverride?.ai_tool).toBeDefined();
			expect(toolRunData.inputOverride?.ai_tool?.[0]?.[0]?.json).toMatchObject({
				query: 'test input that will fail',
			});

			// Error output should be set under the correct connection type (ai_tool)
			expect(toolRunData.data).toBeDefined();
			expect(toolRunData.data?.ai_tool).toBeDefined();
			expect(toolRunData.data?.ai_tool?.[0]?.[0]?.json).toMatchObject({
				error: errorMessage,
			});

			// Execution status should be error
			expect(toolRunData.executionStatus).toBe('error');

			// Error should be captured
			expect(toolRunData.error).toBeDefined();
			expect(toolRunData.error?.message).toContain(errorMessage);
		});
	});

	describe('lastNodeExecuted tracking', () => {
		test('sets lastNodeExecuted when node returns successful data', async () => {
			// ARRANGE
			const node = createNodeData({ name: 'successfulNode', type: types.passThrough });
			const workflow = new DirectedGraph()
				.addNodes(node)
				.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });

			const taskDataConnection = { main: [[{ json: { foo: 'bar' } }]] };
			const executionData = createRunExecutionData({
				startData: { startNodes: [{ name: node.name, sourceData: null }] },
				executionData: {
					nodeExecutionStack: [{ data: taskDataConnection, node, source: null }],
				},
			});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

			// ACT
			const result = await workflowExecute.processRunExecutionData(workflow);

			// ASSERT
			// When node returns data, lastNodeExecuted should be set (line 1919-1921 in workflow-execute.ts)
			expect(result.data.resultData.lastNodeExecuted).toBe('successfulNode');
		});

		test('does not set lastNodeExecuted when node returns no data', async () => {
			// ARRANGE
			const nodeReturningNull = modifyNode(passThroughNode).return(null).done();

			const node = createNodeData({ name: 'nodeReturningNull', type: 'nodeReturningNull' });

			const nodeTypes = NodeTypes({
				...nodeTypeArguments,
				nodeReturningNull: { type: nodeReturningNull, sourcePath: '' },
			});

			const workflow = new DirectedGraph()
				.addNodes(node)
				.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });

			const taskDataConnection = { main: [[{ json: { foo: 'bar' } }]] };
			const executionData = createRunExecutionData({
				startData: { startNodes: [{ name: node.name, sourceData: null }] },
				executionData: {
					nodeExecutionStack: [{ data: taskDataConnection, node, source: null }],
				},
			});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

			// ACT
			const result = await workflowExecute.processRunExecutionData(workflow);

			// ASSERT
			// When node returns null, lastNodeExecuted should NOT be set
			expect(result.data.resultData.lastNodeExecuted).toBeUndefined();
		});
	});

	describe('pairedItem sourceOverwrite handling', () => {
		test('preserves sourceOverwrite for tools to enable expression resolution', async () => {
			// Test: DataNode → AgentNode → ToolNode where ToolNode accesses DataNode via expressions
			const dataNodeOutput = { field: 'testValue', nested: { value: 42 } };
			const dataNode = createNodeData({ name: 'DataNode', type: types.passThrough });

			const toolNodeType = modifyNode(passThroughNode)
				.return(function (this: IExecuteFunctions, response?: EngineResponse) {
					try {
						const proxy = this.getWorkflowDataProxy(0);
						const connectionInputData =
							(this as IExecuteFunctions & { connectionInputData: INodeExecutionData[] })
								.connectionInputData || [];
						const firstItem = connectionInputData[0];
						const pairedItem = (firstItem?.pairedItem as IPairedItemData) ?? { item: 0 };
						const sourceData = this.getExecuteData().source?.main?.[0] ?? null;

						const dataNodeItem = proxy.$getPairedItem('DataNode', sourceData, pairedItem);
						const fieldValue = dataNodeItem?.json?.field;
						const nestedValue = (dataNodeItem?.json?.nested as IDataObject)?.value;

						return [
							[
								{
									json: {
										toolResult: 'Tool executed successfully',
										dataNodeField: fieldValue,
										dataNodeNested: nestedValue,
										response,
									},
								},
							],
						];
					} catch (error) {
						return [
							[
								{
									json: {
										toolResult: 'Failed to access DataNode',
										error: (error as Error).message,
										response,
									},
								},
							],
						];
					}
				})
				.done();
			const toolNode = createNodeData({ name: 'ToolNode', type: 'toolNodeType' });

			const agentNodeType = modifyNode(passThroughNode)
				.return({
					actions: [
						{
							actionType: 'ExecutionNodeAction',
							nodeName: toolNode.name,
							input: { query: 'test query' },
							type: 'ai_tool',
							id: 'tool_action_1',
							metadata: {},
						},
					],
					metadata: { requestId: 'test_agent_request' },
				})
				.return((response?: EngineResponse) => {
					return [[{ json: { agentResult: 'Agent completed', response } }]];
				})
				.done();
			const agentNode = createNodeData({ name: 'AgentNode', type: 'agentNodeType' });

			const customNodeTypes = NodeTypes({
				...nodeTypeArguments,
				agentNodeType: { type: agentNodeType, sourcePath: '' },
				toolNodeType: { type: toolNodeType, sourcePath: '' },
			});

			const workflow = new DirectedGraph()
				.addNodes(dataNode, agentNode, toolNode)
				.addConnections({ from: dataNode, to: agentNode })
				.addConnections({ from: toolNode, to: agentNode, type: 'ai_tool' })
				.toWorkflow({
					name: '',
					active: false,
					nodeTypes: customNodeTypes,
					settings: { executionOrder: 'v1' },
				});

			const taskDataConnection = { main: [[{ json: dataNodeOutput }]] };
			const executionData = createRunExecutionData({
				startData: { startNodes: [{ name: dataNode.name, sourceData: null }] },
				executionData: {
					nodeExecutionStack: [{ data: taskDataConnection, node: dataNode, source: null }],
				},
			});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

			const result = await workflowExecute.processRunExecutionData(workflow);
			const runData = result.data.resultData.runData;

			// Verify preserveSourceOverwrite metadata is set
			expect(runData[toolNode.name][0].metadata?.preserveSourceOverwrite).toBeDefined();

			// Verify sourceOverwrite points to DataNode
			const toolInput = runData[toolNode.name][0].inputOverride?.ai_tool?.[0]?.[0];
			expect(toolInput?.pairedItem).toBeDefined();
			if (typeof toolInput?.pairedItem === 'object' && !Array.isArray(toolInput.pairedItem)) {
				expect(toolInput.pairedItem.sourceOverwrite?.previousNode).toBe(dataNode.name);
			}

			// Verify tool successfully accessed DataNode data via sourceOverwrite
			const toolOutput = runData[toolNode.name][0].data?.ai_tool?.[0]?.[0]?.json;
			expect(toolOutput?.toolResult).toBe('Tool executed successfully');
			expect(toolOutput?.dataNodeField).toBe('testValue');
			expect(toolOutput?.dataNodeNested).toBe(42);
			expect(toolOutput).not.toHaveProperty('error');
		});

		test('sourceOverwrite works correctly in loop scenarios', async () => {
			// Test: TriggerNode → LoopNode → DataNode → IFNode
			// IFNode evaluates $('DataNode').item.json.email
			const triggerData = { email: 'test@example.com', name: 'Test User' };
			const triggerNode = createNodeData({ name: 'TriggerNode', type: types.passThrough });

			let loopIteration = 0;
			const loopNodeType = modifyNode(passThroughNode)
				.return(function (this: IExecuteFunctions) {
					const items = this.getInputData();
					loopIteration++;

					return [
						items.map((item, index) => ({
							json: item.json,
							pairedItem: {
								item: index,
								input: 0,
								sourceOverwrite: {
									previousNode: triggerNode.name,
									previousNodeOutput: 0,
									previousNodeRun: 0,
								},
							},
						})),
					];
				})
				.done();
			const loopNode = createNodeData({ name: 'LoopNode', type: 'loopNodeType' });
			const dataNode = createNodeData({ name: 'DataNode', type: types.passThrough });

			let expressionError: Error | undefined;
			const ifNodeType = modifyNode(passThroughNode)
				.return(function (this: IExecuteFunctions) {
					try {
						const proxy = this.getWorkflowDataProxy(0);
						const connectionInputData =
							(this as IExecuteFunctions & { connectionInputData: INodeExecutionData[] })
								.connectionInputData ?? [];
						const firstItem = connectionInputData[0];
						const pairedItem = (firstItem?.pairedItem as IPairedItemData) ?? { item: 0 };
						const sourceData = this.getExecuteData().source?.main?.[0] ?? null;

						const dataNodeItem = proxy.$getPairedItem('DataNode', sourceData, pairedItem);
						const email = dataNodeItem?.json?.email;

						return [
							[
								{
									json: {
										result: 'Expression resolved',
										email,
										iteration: loopIteration,
									},
								},
							],
						];
					} catch (error) {
						expressionError = error;
						throw error;
					}
				})
				.done();
			const ifNode = createNodeData({ name: 'IFNode', type: 'ifNodeType' });

			const customNodeTypes = NodeTypes({
				...nodeTypeArguments,
				loopNodeType: { type: loopNodeType, sourcePath: '' },
				ifNodeType: { type: ifNodeType, sourcePath: '' },
			});

			const workflow = new DirectedGraph()
				.addNodes(triggerNode, loopNode, dataNode, ifNode)
				.addConnections({ from: triggerNode, to: loopNode })
				.addConnections({ from: loopNode, to: dataNode })
				.addConnections({ from: dataNode, to: ifNode })
				.toWorkflow({
					name: '',
					active: false,
					nodeTypes: customNodeTypes,
					settings: { executionOrder: 'v1' },
				});

			const taskDataConnection = { main: [[{ json: triggerData }]] };
			const executionData = createRunExecutionData({
				startData: { startNodes: [{ name: triggerNode.name, sourceData: null }] },
				executionData: {
					nodeExecutionStack: [{ data: taskDataConnection, node: triggerNode, source: null }],
				},
			});

			const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);

			await expect(workflowExecute.processRunExecutionData(workflow)).resolves.toBeTruthy();
			expect(expressionError).toBeUndefined();
		});
	});
});
