import { mockInstance } from '@n8n/backend-test-utils';
import { TOOL_EXECUTOR_NODE_NAME } from '@n8n/constants';
import { ExecutionRepository } from '@n8n/db';
import type { IExecutionResponse } from '@n8n/db';
import { mock } from 'vitest-mock-extended';

import type { ExecutionPersistence } from '@/executions/execution-persistence';
import {
	CHAT_NODE_TYPE,
	CHAT_TOOL_NODE_TYPE,
	NodeConnectionTypes,
	RESPOND_TO_WEBHOOK_NODE_TYPE,
} from 'n8n-workflow';
import type { Mock } from 'vitest';

import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import { WorkflowRunner } from '@/workflow-runner';

import { NodeTypes } from '../../node-types';
import { OwnershipService } from '../../services/ownership.service';
import { ChatExecutionManager } from '../chat-execution-manager';
import type { ChatMessage } from '../chat-service.types';

describe('ChatExecutionManager', () => {
	const executionRepository = mockInstance(ExecutionRepository);
	const executionPersistence = mock<ExecutionPersistence>();
	const workflowRunner = mockInstance(WorkflowRunner);
	const ownershipService = mockInstance(OwnershipService);
	const nodeTypes = mockInstance(NodeTypes);
	const chatExecutionManager = new ChatExecutionManager(
		executionRepository,
		executionPersistence,
		workflowRunner,
		ownershipService,
		nodeTypes,
	);

	beforeEach(() => {
		vi.restoreAllMocks();
		vi.clearAllMocks();
	});

	it('should handle errors from getRunData gracefully', async () => {
		const execution = { id: '1', workflowData: {}, data: {} } as IExecutionResponse;
		const message = { sessionId: '123', action: 'sendMessage', chatInput: 'input' } as ChatMessage;

		vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(true);
		vi.spyOn(chatExecutionManager as any, 'getRunData').mockRejectedValue(new Error('Test error'));

		await expect(chatExecutionManager.runWorkflow(execution, message)).rejects.toThrow(
			'Test error',
		);
	});

	describe('runWorkflow', () => {
		it('should call WorkflowRunner.run with correct parameters', async () => {
			const execution = { id: '1', workflowData: {}, data: {} } as IExecutionResponse;
			const message = {
				sessionId: '123',
				action: 'sendMessage',
				chatInput: 'input',
			} as ChatMessage;
			const runData = { executionMode: 'manual', executionData: {}, workflowData: {} } as any;

			vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(true);
			vi.spyOn(chatExecutionManager as any, 'getRunData').mockResolvedValue(runData);

			await chatExecutionManager.runWorkflow(execution, message);

			expect(workflowRunner.run).toHaveBeenCalledWith(runData, true, true, {
				executionId: '1',
				expectedStatus: 'waiting',
			});
		});

		it('refuses to run when the suspended node is not chat-driven', async () => {
			const execution = { id: '1', workflowData: {}, data: {} } as IExecutionResponse;
			const message = {
				sessionId: '123',
				action: 'sendMessage',
				chatInput: 'input',
			} as ChatMessage;

			vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(false);
			const getRunDataSpy = vi.spyOn(chatExecutionManager as any, 'getRunData');

			await expect(chatExecutionManager.runWorkflow(execution, message)).rejects.toThrow(
				'Refusing to resume a non-chat node over chat',
			);
			expect(getRunDataSpy).not.toHaveBeenCalled();
			expect(workflowRunner.run).not.toHaveBeenCalled();
		});
	});

	describe('cancelExecution', () => {
		beforeEach(() => {
			// The status-transition tests below exercise the cancel logic itself; the
			// chat-ownership gate is covered by its own tests. Treat the execution as
			// chat-driven so these focus on status handling.
			vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(true);
		});

		it('should update execution status to canceled if it is running', async () => {
			const executionId = '1';
			const execution = { id: executionId, status: 'running' } as any;

			executionPersistence.findSingleExecution.mockResolvedValue(execution);

			await chatExecutionManager.cancelExecution(executionId);

			expect(executionRepository.update).toHaveBeenCalledWith(
				{ id: executionId },
				{ status: 'canceled' },
			);
		});

		it('should update execution status to canceled if it is waiting', async () => {
			const executionId = '2';
			const execution = { id: executionId, status: 'waiting' } as any;

			executionPersistence.findSingleExecution.mockResolvedValue(execution);

			await chatExecutionManager.cancelExecution(executionId);

			expect(executionRepository.update).toHaveBeenCalledWith(
				{ id: executionId },
				{ status: 'canceled' },
			);
		});

		it('should update execution status to canceled if it is unknown', async () => {
			const executionId = '3';
			const execution = { id: executionId, status: 'unknown' } as any;

			executionPersistence.findSingleExecution.mockResolvedValue(execution);

			await chatExecutionManager.cancelExecution(executionId);

			expect(executionRepository.update).toHaveBeenCalledWith(
				{ id: executionId },
				{ status: 'canceled' },
			);
		});

		it('should not update execution status if it is not running', async () => {
			const executionId = '1';
			const execution = { id: executionId, status: 'completed' } as any;

			executionPersistence.findSingleExecution.mockResolvedValue(execution);

			await chatExecutionManager.cancelExecution(executionId);

			expect(executionRepository.update).not.toHaveBeenCalled();
		});

		it('should not cancel a waiting execution the chat socket does not drive', async () => {
			// A socket attached to a non-chat execution (e.g. a Send-and-Wait gate via
			// a leaked token) must not cancel it by going idle.
			vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(false);
			const executionId = '9';
			const execution = { id: executionId, status: 'waiting' } as any;

			executionPersistence.findSingleExecution.mockResolvedValue(execution);

			await chatExecutionManager.cancelExecution(executionId);

			expect(executionRepository.update).not.toHaveBeenCalled();
		});

		it('does not throw or cancel when the parked node type is unresolvable', async () => {
			// Use the real isChatDrivenExecution (the describe's beforeEach stubs it) so
			// the resolution failure is exercised: it must be swallowed, so cancelExecution
			// returns and the heartbeat loop can still clean up the session.
			vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockRestore();
			vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockImplementation(() => {
				throw new Error('Unrecognized node type: some.uninstalled.node');
			});
			const execution = {
				id: '9',
				status: 'waiting',
				workflowData: { id: 'wf', nodes: [] },
				data: {
					executionData: {
						nodeExecutionStack: [{ node: { name: 'X', type: 'x' }, data: { main: [[]] } }],
					},
				},
			} as any;
			executionPersistence.findSingleExecution.mockResolvedValue(execution);

			await expect(chatExecutionManager.cancelExecution('9')).resolves.toBeUndefined();
			expect(executionRepository.update).not.toHaveBeenCalled();
		});
	});

	describe('findExecution', () => {
		it('should return undefined if execution does not exist', async () => {
			const executionId = 'non-existent';

			executionPersistence.findSingleExecution.mockResolvedValue(undefined);

			const result = await chatExecutionManager.findExecution(executionId);

			expect(result).toBeUndefined;
		});

		it('should return execution data', async () => {
			const executionId = '1';
			const execution = { id: executionId } as any;

			executionPersistence.findSingleExecution.mockResolvedValue(execution);

			const result = await chatExecutionManager.findExecution(executionId);

			expect(result).toEqual(execution);
		});
	});

	describe('getRunData', () => {
		it('should call runNode with correct parameters and return runData', async () => {
			const execution = {
				id: '1',
				workflowData: { id: 'workflowId' },
				data: {
					resultData: { pinData: {} },
					executionData: {
						nodeExecutionStack: [{ node: { type: 'testType' }, data: { main: [[]] } }],
					},
					pushRef: 'pushRef',
				},
				mode: 'manual',
			} as any;
			const message = {
				sessionId: '123',
				action: 'sendMessage',
				chatInput: 'input',
			} as ChatMessage;
			const project = { id: 'projectId' };
			const nodeExecutionData = [[{ json: message }]];

			const getRunDataSpy = vi
				.spyOn(chatExecutionManager as any, 'runNode')
				.mockResolvedValue(nodeExecutionData);
			const getWorkflowProjectCachedSpy =
				ownershipService.getWorkflowProjectCached.mockResolvedValue(project as any);

			const runData = await (chatExecutionManager as any).getRunData(execution, message);

			expect(getRunDataSpy).toHaveBeenCalledWith(execution, message);
			expect(getWorkflowProjectCachedSpy).toHaveBeenCalledWith('workflowId');
			expect(runData).toEqual({
				executionMode: 'manual',
				executionData: execution.data,
				pushRef: execution.data.pushRef,
				workflowData: execution.workflowData,
				pinData: execution.data.resultData.pinData,
				projectId: 'projectId',
			});
		});

		describe('when node is a chat tool', () => {
			const message: ChatMessage = { sessionId: '123', action: 'sendMessage', chatInput: 'input' };

			function makeChatToolExecution(runDataEntry: object = {}) {
				return {
					id: '1',
					workflowData: { id: 'workflowId' },
					data: {
						resultData: {
							pinData: {},
							lastNodeExecuted: 'ChatToolNode',
							runData: {
								ChatToolNode: [
									{
										startTime: 123,
										executionTime: 456,
										executionIndex: 0,
										source: [],
										...runDataEntry,
									},
								],
							},
						},
						executionData: {
							nodeExecutionStack: [
								{
									node: { name: 'ChatToolNode', type: CHAT_TOOL_NODE_TYPE, disabled: false },
									data: { main: [[]] },
								},
							],
						},
						pushRef: 'pushRef',
					},
					mode: 'manual',
				} as any;
			}

			beforeEach(() => {
				vi.spyOn(chatExecutionManager as any, 'runNode').mockResolvedValue(null);
				ownershipService.getWorkflowProjectCached.mockResolvedValue({ id: 'projectId' } as any);
			});

			it('should disable node and clear waitTill even when waitTill is not set', async () => {
				const execution = makeChatToolExecution();

				await (chatExecutionManager as any).getRunData(execution, message);

				expect(execution.data.executionData.nodeExecutionStack[0].node.disabled).toBe(true);
				expect(execution.data.waitTill).toBeUndefined();
			});

			it('should set rewireOutputLogTo to AiTool', async () => {
				const execution = makeChatToolExecution();

				await (chatExecutionManager as any).getRunData(execution, message);

				expect(execution.data.executionData.nodeExecutionStack[0].node.rewireOutputLogTo).toBe(
					NodeConnectionTypes.AiTool,
				);
			});

			it('should replace run data entry with inputOverride placeholder', async () => {
				const inputOverride = { main: [[{ json: { toolInput: 'test' } }]] };
				const execution = makeChatToolExecution({
					source: [{ previousNode: 'Agent' }],
					inputOverride,
				});

				await (chatExecutionManager as any).getRunData(execution, message);

				const runData = execution.data.resultData.runData.ChatToolNode;
				expect(runData).toHaveLength(1);
				expect(runData[0].inputOverride).toBe(inputOverride);
				expect(runData[0].startTime).toBe(0);
				expect(runData[0].source[0]).toMatchObject({ previousNode: 'Agent' });
			});

			it('should pop run data entry when no inputOverride', async () => {
				const execution = makeChatToolExecution();

				await (chatExecutionManager as any).getRunData(execution, message);

				expect(execution.data.resultData.runData.ChatToolNode).toHaveLength(0);
			});
		});

		describe('when node is a HITL tool', () => {
			// HITL tools carry a generated `<base>HitlTool` type (e.g. chatHitlTool),
			// not CHAT_TOOL_NODE_TYPE, but must get the same resume fix-up so the
			// approval is logged on the ai_tool channel and the gated tool executes.
			const HITL_TOOL_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatHitlTool';
			const message: ChatMessage = { sessionId: '123', action: 'sendMessage', chatInput: '' };

			function makeHitlToolExecution() {
				return {
					id: '1',
					workflowData: { id: 'workflowId' },
					data: {
						resultData: {
							pinData: {},
							lastNodeExecuted: 'HitlToolNode',
							runData: {
								HitlToolNode: [
									{ startTime: 123, executionTime: 456, executionIndex: 0, source: [] },
								],
							},
						},
						executionData: {
							nodeExecutionStack: [
								{
									node: { name: 'HitlToolNode', type: HITL_TOOL_NODE_TYPE, disabled: false },
									data: { main: [[]] },
								},
							],
						},
						pushRef: 'pushRef',
						waitTill: new Date(),
					},
					mode: 'manual',
				} as any;
			}

			beforeEach(() => {
				vi.spyOn(chatExecutionManager as any, 'runNode').mockResolvedValue(null);
				ownershipService.getWorkflowProjectCached.mockResolvedValue({ id: 'projectId' } as any);
			});

			it('should disable node, clear waitTill and rewire output to AiTool', async () => {
				const execution = makeHitlToolExecution();

				await (chatExecutionManager as any).getRunData(execution, message);

				const resumingNode = execution.data.executionData.nodeExecutionStack[0].node;
				expect(resumingNode.disabled).toBe(true);
				expect(execution.data.waitTill).toBeUndefined();
				expect(resumingNode.rewireOutputLogTo).toBe(NodeConnectionTypes.AiTool);
			});
		});

		describe('when node is a regular chat node', () => {
			const message: ChatMessage = { sessionId: '123', action: 'sendMessage', chatInput: 'input' };

			beforeEach(() => {
				vi.spyOn(chatExecutionManager as any, 'runNode').mockResolvedValue(null);
				ownershipService.getWorkflowProjectCached.mockResolvedValue({ id: 'projectId' } as any);
			});

			it('should not modify node state or run data for chat node', async () => {
				const runDataEntry = { startTime: 123, executionTime: 456, executionIndex: 0, source: [] };
				const waitTill = new Date();
				const execution = {
					id: '1',
					workflowData: { id: 'workflowId' },
					data: {
						resultData: {
							pinData: {},
							lastNodeExecuted: 'ChatNode',
							runData: { ChatNode: [runDataEntry] },
						},
						executionData: {
							nodeExecutionStack: [
								{
									node: { name: 'ChatNode', type: CHAT_NODE_TYPE, disabled: false },
									data: { main: [[]] },
								},
							],
						},
						pushRef: 'pushRef',
						waitTill,
					},
					mode: 'manual',
				} as any;

				await (chatExecutionManager as any).getRunData(execution, message);

				expect(execution.data.executionData.nodeExecutionStack[0].node.disabled).toBe(false);
				expect(execution.data.waitTill).toBe(waitTill);
				expect(execution.data.resultData.runData.ChatNode).toHaveLength(1);
				expect(
					execution.data.executionData.nodeExecutionStack[0].node.rewireOutputLogTo,
				).toBeUndefined();
			});

			it('should not modify node state or run data for respond to webhook node', async () => {
				const runDataEntry = { startTime: 123, executionTime: 456, executionIndex: 0, source: [] };
				const waitTill = new Date();
				const execution = {
					id: '1',
					workflowData: { id: 'workflowId' },
					data: {
						resultData: {
							pinData: {},
							lastNodeExecuted: 'RespondNode',
							runData: { RespondNode: [runDataEntry] },
						},
						executionData: {
							nodeExecutionStack: [
								{
									node: {
										name: 'RespondNode',
										type: RESPOND_TO_WEBHOOK_NODE_TYPE,
										disabled: false,
									},
									data: { main: [[]] },
								},
							],
						},
						pushRef: 'pushRef',
						waitTill,
					},
					mode: 'manual',
				} as any;

				await (chatExecutionManager as any).getRunData(execution, message);

				expect(execution.data.executionData.nodeExecutionStack[0].node.disabled).toBe(false);
				expect(execution.data.waitTill).toBe(waitTill);
				expect(execution.data.resultData.runData.RespondNode).toHaveLength(1);
				expect(
					execution.data.executionData.nodeExecutionStack[0].node.rewireOutputLogTo,
				).toBeUndefined();
			});
		});
	});

	describe('runNode', () => {
		it('should return null if node is not found', async () => {
			const execution = {
				id: '1',
				workflowData: { id: 'workflowId' },
				data: {
					resultData: { lastNodeExecuted: 'nodeId' },
					executionData: {
						nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[]] } }],
					},
				},
				mode: 'manual',
			} as any;
			const message = {
				sessionId: '123',
				action: 'sendMessage',
				chatInput: 'input',
			} as ChatMessage;

			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);
			const workflow = { getNode: vi.fn().mockReturnValue(null) };
			vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);

			const result = await (chatExecutionManager as any).runNode(execution, message);

			expect(result).toBeNull();
		});

		it('should return null if executionData is undefined', async () => {
			const execution = {
				id: '1',
				workflowData: { id: 'workflowId' },
				data: {
					resultData: { lastNodeExecuted: 'nodeId' },
					executionData: { nodeExecutionStack: [] },
				},
				mode: 'manual',
			} as any;
			const message = {
				sessionId: '123',
				action: 'sendMessage',
				chatInput: 'input',
			} as ChatMessage;

			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);
			const workflow = { getNode: vi.fn().mockReturnValue({}) };
			vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);

			const result = await (chatExecutionManager as any).runNode(execution, message);

			expect(result).toBeNull();
		});

		it('should call nodeType.onMessage with correct parameters and return the result', async () => {
			const execution = {
				id: '1',
				workflowData: { id: 'workflowId' },
				data: {
					resultData: { lastNodeExecuted: 'nodeId' },
					executionData: {
						nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[{}]] } }],
					},
				},
				mode: 'manual',
			} as any;
			const message = {
				sessionId: '123',
				action: 'sendMessage',
				chatInput: 'input',
				files: [],
			} as ChatMessage;
			const node = { type: 'testType', typeVersion: 1 };
			const nodeType = { onMessage: vi.fn().mockResolvedValue([[{ json: message }]]) };
			const workflow = {
				getNode: vi.fn().mockReturnValue(node),
				nodeTypes: { getByNameAndVersion: vi.fn().mockReturnValue(nodeType) },
				expression: {
					acquireIsolate: vi.fn().mockResolvedValue(undefined),
					releaseIsolate: vi.fn().mockResolvedValue(undefined),
				},
			};
			vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);

			const result = await (chatExecutionManager as any).runNode(execution, message);

			expect(workflow.nodeTypes.getByNameAndVersion).toHaveBeenCalledWith('testType', 1);
			expect(nodeType.onMessage).toHaveBeenCalled();
			expect(result).toEqual([[{ json: message }]]);
		});

		it('should return nodeExecutionData with sessionId, action and chatInput', async () => {
			const execution = {
				id: '1',
				workflowData: { id: 'workflowId' },
				data: {
					resultData: { lastNodeExecuted: 'nodeId' },
					executionData: {
						nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[{}]] } }],
					},
				},
				mode: 'manual',
			} as any;
			const message = {
				sessionId: '123',
				action: 'sendMessage',
				chatInput: 'input',
			} as ChatMessage;
			const node = { type: 'testType', typeVersion: 1 };
			const nodeType = { onMessage: vi.fn().mockResolvedValue([[{ json: message }]]) };
			const workflow = {
				getNode: vi.fn().mockReturnValue(node),
				nodeTypes: { getByNameAndVersion: vi.fn().mockReturnValue(nodeType) },
				expression: {
					acquireIsolate: vi.fn().mockResolvedValue(undefined),
					releaseIsolate: vi.fn().mockResolvedValue(undefined),
				},
			};
			vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);

			const result = await (chatExecutionManager as any).runNode(execution, message);

			expect(result).toEqual([[{ json: message }]]);
		});

		describe('expression isolate lifecycle', () => {
			const message: ChatMessage = {
				sessionId: '123',
				action: 'sendMessage',
				chatInput: 'input',
				files: [],
			};

			function makeExecution() {
				return {
					id: '1',
					workflowData: { id: 'workflowId' },
					data: {
						resultData: { lastNodeExecuted: 'nodeId' },
						executionData: {
							nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[{}]] } }],
						},
					},
					mode: 'manual',
				} as any;
			}

			function makeWorkflow(nodeType: { onMessage?: Mock }) {
				const expression = {
					acquireIsolate: vi.fn().mockResolvedValue(undefined),
					releaseIsolate: vi.fn().mockResolvedValue(undefined),
				};
				const workflow = {
					getNode: vi.fn().mockReturnValue({ type: 'testType', typeVersion: 1 }),
					nodeTypes: { getByNameAndVersion: vi.fn().mockReturnValue(nodeType) },
					expression,
				};
				vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
				vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);
				return { workflow, expression };
			}

			it('should acquire and release isolate around onMessage', async () => {
				const onMessage = vi.fn().mockResolvedValue([[{ json: message }]]);
				const { expression } = makeWorkflow({ onMessage });

				await (chatExecutionManager as any).runNode(makeExecution(), message);

				expect(expression.acquireIsolate).toHaveBeenCalledTimes(1);
				expect(expression.releaseIsolate).toHaveBeenCalledTimes(1);
				const [acquireOrder] = expression.acquireIsolate.mock.invocationCallOrder;
				const [onMessageOrder] = onMessage.mock.invocationCallOrder;
				const [releaseOrder] = expression.releaseIsolate.mock.invocationCallOrder;
				expect(acquireOrder).toBeLessThan(onMessageOrder);
				expect(onMessageOrder).toBeLessThan(releaseOrder);
			});

			it('should release isolate when onMessage throws', async () => {
				const onMessage = vi.fn().mockRejectedValue(new Error('boom'));
				const { expression } = makeWorkflow({ onMessage });

				await expect(
					(chatExecutionManager as any).runNode(makeExecution(), message),
				).rejects.toThrow('boom');

				expect(expression.acquireIsolate).toHaveBeenCalledTimes(1);
				expect(expression.releaseIsolate).toHaveBeenCalledTimes(1);
			});

			it('should not acquire isolate when node type has no onMessage', async () => {
				const { expression } = makeWorkflow({});

				await (chatExecutionManager as any).runNode(makeExecution(), message);

				expect(expression.acquireIsolate).not.toHaveBeenCalled();
				expect(expression.releaseIsolate).not.toHaveBeenCalled();
			});
		});

		describe('when lastNodeExecuted is TOOL_EXECUTOR_NODE_NAME', () => {
			const message: ChatMessage = { sessionId: '123', action: 'sendMessage', chatInput: 'input' };
			const toolNode = {
				name: 'My Tool',
				type: 'n8n-nodes-base.myTool',
				typeVersion: 1,
				parameters: {},
			};

			it('returns null when the referenced tool node is not in the workflow', async () => {
				const execution = {
					id: '1',
					workflowData: { id: 'workflowId' },
					data: {
						resultData: { lastNodeExecuted: TOOL_EXECUTOR_NODE_NAME, runData: {} },
						startData: {},
						executionData: {
							nodeExecutionStack: [
								{
									node: {
										name: TOOL_EXECUTOR_NODE_NAME,
										parameters: { node: 'My Tool' },
									},
									data: { main: [[{}]] },
								},
							],
						},
					},
					mode: 'manual',
				} as any;

				const workflow = {
					id: 'workflowId',
					getNode: vi.fn().mockReturnValue(null), // tool node not found
				};
				vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
				vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);

				const result = await (chatExecutionManager as any).runNode(execution, message);

				expect(result).toBeNull();
			});

			it('redirects to the real tool node and runs onMessage on it', async () => {
				const onMessage = vi.fn().mockResolvedValue([[{ json: { result: 'ok' } }]]);
				const execution = {
					id: '1',
					workflowData: { id: 'workflowId' },
					data: {
						resultData: { lastNodeExecuted: TOOL_EXECUTOR_NODE_NAME, runData: {} },
						startData: { runNodeFilter: ['My Tool'], destinationNode: 'My Tool' },
						executionData: {
							nodeExecutionStack: [
								{
									node: {
										name: TOOL_EXECUTOR_NODE_NAME,
										parameters: { node: 'My Tool' },
									},
									data: { main: [[{}]] },
								},
							],
						},
					},
					mode: 'manual',
				} as any;

				const workflow = {
					id: 'workflowId',
					getNode: vi.fn().mockImplementation((name: string) => {
						if (name === TOOL_EXECUTOR_NODE_NAME) return null;
						if (name === 'My Tool') return toolNode;
						return null;
					}),
					nodeTypes: {
						getByNameAndVersion: vi.fn().mockReturnValue({ onMessage }),
					},
					expression: {
						acquireIsolate: vi.fn().mockResolvedValue(undefined),
						releaseIsolate: vi.fn().mockResolvedValue(undefined),
					},
				};
				vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
				vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);

				const result = await (chatExecutionManager as any).runNode(execution, message);

				// The node was redirected to the real tool node
				expect(execution.data.resultData.lastNodeExecuted).toBe('My Tool');
				// onMessage was called on the tool node
				expect(onMessage).toHaveBeenCalled();
				expect(result).toEqual([[{ json: { result: 'ok' } }]]);
			});
		});
	});

	describe('canResumeOverChat', () => {
		// A chat message may only resume a node whose resolved type implements the
		// onMessage hook. Everything else (Send-and-Wait gates, non-chat HITL tools,
		// plain Wait) must be refused regardless of the token presented.
		function makeExecution(
			nodeType: string,
			parameters: Record<string, unknown> = {},
			nodeName = 'parkedNode',
		): IExecutionResponse {
			const node = { name: nodeName, type: nodeType, typeVersion: 1, parameters };
			return {
				id: '1',
				workflowData: { id: 'wf', nodes: [node] },
				data: {
					resultData: { lastNodeExecuted: nodeName, runData: {} },
					executionData: { nodeExecutionStack: [{ node, data: { main: [[]] } }] },
				},
			} as unknown as IExecutionResponse;
		}

		function mockWorkflow(execution: IExecutionResponse, hasOnMessage: boolean) {
			const node = execution.data.executionData!.nodeExecutionStack[0].node;
			const workflow = {
				id: 'wf',
				getNode: (name: string) => (name === node.name ? node : null),
				nodeTypes: {
					getByNameAndVersion: () => (hasOnMessage ? { onMessage: vi.fn() } : {}),
				},
			};
			vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
		}

		it('allows a chat node that implements onMessage', () => {
			const execution = makeExecution(CHAT_NODE_TYPE);
			mockWorkflow(execution, true);

			expect(chatExecutionManager.canResumeOverChat(execution)).toBe(true);
		});

		it('returns false (does not throw) when node type resolution fails', () => {
			const execution = makeExecution(CHAT_NODE_TYPE);
			vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockImplementation(() => {
				throw new Error('Unrecognized node type: some.uninstalled.node');
			});

			expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
		});

		it('allows RespondToWebhook (implements onMessage)', () => {
			const execution = makeExecution(RESPOND_TO_WEBHOOK_NODE_TYPE);
			mockWorkflow(execution, true);

			expect(chatExecutionManager.canResumeOverChat(execution)).toBe(true);
		});

		it('allows a chat-based HITL tool (inherits onMessage)', () => {
			const execution = makeExecution('@n8n/n8n-nodes-langchain.chatHitlTool');
			mockWorkflow(execution, true);

			expect(chatExecutionManager.canResumeOverChat(execution)).toBe(true);
		});

		it('refuses a chat node that opted out of user input', () => {
			const execution = makeExecution(CHAT_NODE_TYPE, { blockUserInput: true });
			mockWorkflow(execution, true);

			expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
		});

		it('applies the blockUserInput carve-out to a chat-based HITL tool', () => {
			// chatHitlTool inherits onMessage and retains blockUserInput; the carve-out
			// must reach it even though its type is not literally CHAT_(TOOL_)NODE_TYPE.
			const execution = makeExecution('@n8n/n8n-nodes-langchain.chatHitlTool', {
				blockUserInput: true,
			});
			mockWorkflow(execution, true);

			expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
		});

		it('refuses a Send-and-Wait node (no onMessage)', () => {
			const execution = makeExecution('n8n-nodes-base.telegram', { operation: 'sendAndWait' });
			mockWorkflow(execution, false);

			expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
		});

		it('refuses a non-chat HITL tool (no onMessage)', () => {
			const execution = makeExecution('n8n-nodes-base.telegramHitlTool');
			mockWorkflow(execution, false);

			expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
		});

		it('refuses a plain Wait node (no onMessage)', () => {
			const execution = makeExecution('n8n-nodes-base.wait', { resume: 'timeInterval' });
			mockWorkflow(execution, false);

			expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
		});

		it('keys off the parked stack node, not lastNodeExecuted', () => {
			// Confused-deputy guard: lastNodeExecuted names a chat decoy, but the node
			// the engine would resume (nodeExecutionStack[0]) is a Telegram gate. The
			// decision must follow the parked stack node and refuse.
			const decoy = { name: 'Decoy', type: CHAT_NODE_TYPE, typeVersion: 1, parameters: {} };
			const gate = {
				name: 'Manager approval',
				type: 'n8n-nodes-base.telegram',
				typeVersion: 1,
				parameters: { operation: 'sendAndWait' },
			};
			const execution = {
				id: '1',
				workflowData: { id: 'wf', nodes: [decoy, gate] },
				data: {
					resultData: { lastNodeExecuted: 'Decoy', runData: {} },
					executionData: { nodeExecutionStack: [{ node: gate, data: { main: [[]] } }] },
				},
			} as unknown as IExecutionResponse;
			const workflow = {
				getNode: (name: string) => (name === 'Decoy' ? decoy : name === gate.name ? gate : null),
				nodeTypes: {
					getByNameAndVersion: (type: string) =>
						type === CHAT_NODE_TYPE ? { onMessage: vi.fn() } : {},
				},
			};
			vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);

			expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
		});

		it('refuses when there is no parked execution data', () => {
			const execution = {
				id: '1',
				workflowData: { id: 'wf', nodes: [] },
				data: { resultData: { lastNodeExecuted: 'x', runData: {} }, executionData: undefined },
			} as unknown as IExecutionResponse;
			vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue({
				getNode: () => null,
				nodeTypes: { getByNameAndVersion: () => ({ onMessage: vi.fn() }) },
			});

			expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false);
		});
	});

	describe('resolveResumeNodeType', () => {
		it('reports the wrapped tool type for a tool-executor entry, not the executor', () => {
			const toolNode = {
				name: 'My Tool',
				type: 'n8n-nodes-base.telegramHitlTool',
				typeVersion: 1,
				parameters: {},
			};
			const execution = {
				id: '1',
				workflowData: { id: 'wf', nodes: [toolNode] },
				data: {
					resultData: { lastNodeExecuted: TOOL_EXECUTOR_NODE_NAME, runData: {} },
					executionData: {
						nodeExecutionStack: [
							{
								node: {
									name: TOOL_EXECUTOR_NODE_NAME,
									type: '@n8n/n8n-nodes-langchain.toolExecutor',
									parameters: { node: 'My Tool' },
								},
								data: { main: [[]] },
							},
						],
					},
				},
			} as unknown as IExecutionResponse;
			const workflow = {
				getNode: (name: string) => (name === 'My Tool' ? toolNode : null),
				nodeTypes: { getByNameAndVersion: () => ({}) },
			};
			vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);

			expect(chatExecutionManager.resolveResumeNodeType(execution)).toBe(
				'n8n-nodes-base.telegramHitlTool',
			);
		});

		it('returns undefined when the node type cannot be resolved', () => {
			const execution = {
				id: '1',
				data: {
					executionData: { nodeExecutionStack: [{ node: { name: 'x', type: 'x' }, data: {} }] },
				},
			} as unknown as IExecutionResponse;
			vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockImplementation(() => {
				throw new Error('Unrecognized node type');
			});

			expect(chatExecutionManager.resolveResumeNodeType(execution)).toBeUndefined();
		});
	});
});
