import type { Logger } from '@n8n/backend-common';
import type { ExecutionsConfig } from '@n8n/config';
import type { IExecutionResponse, ExecutionRepository, Project } from '@n8n/db';
import { WorkflowPublishHistoryRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import type { WorkflowExecute as ActualWorkflowExecute, InstanceSettings } from 'n8n-core';
import { ExternalSecretsProxy } from 'n8n-core';
import { mockInstance } from 'n8n-core/test/utils';
import {
	type IPinData,
	type IRun,
	type ITaskData,
	type IWorkflowExecuteAdditionalData,
	Workflow,
	NodeConnectionTypes,
	type IRunExecutionData,
	type WorkflowExecuteMode,
	type ExecutionError,
} from 'n8n-workflow';

import { JobProcessor } from '../job-processor';
import type { Job } from '../scaling.types';
import type { NodeTypes } from '@/node-types';

import { CredentialsHelper } from '@/credentials-helper';
import { VariablesService } from '@/environments.ee/variables/variables.service.ee';
import { ExternalHooks } from '@/external-hooks';
import type { ManualExecutionService } from '@/manual-execution.service';
import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.service';
import { OwnershipService } from '@/services/ownership.service';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import { WorkflowHookContextService } from '@/workflow-hook-context.service';
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';

mockInstance(WorkflowPublishHistoryRepository);
mockInstance(VariablesService, {
	getAllCached: jest.fn().mockResolvedValue([]),
});
mockInstance(CredentialsHelper);
mockInstance(ExternalSecretsProxy);
mockInstance(WorkflowStaticDataService);
mockInstance(WorkflowStatisticsService);
mockInstance(ExternalHooks);
mockInstance(WorkflowHookContextService);
mockInstance(DataTableProxyService);
mockInstance(OwnershipService, {
	getWorkflowProjectCached: jest.fn().mockResolvedValue(mock<Project>({ id: 'project-id' })),
});

const processRunExecutionDataMock = jest.fn();
jest.mock('n8n-core', () => {
	const original = jest.requireActual('n8n-core');

	// Mock class constructor and prototype methods
	return {
		...original,
		WorkflowExecute: jest.fn().mockImplementation(() => ({
			processRunExecutionData: processRunExecutionDataMock,
		})),
	};
});

const logger = mock<Logger>({
	scoped: jest.fn().mockImplementation(() => logger),
});

const executionsConfig = mock<ExecutionsConfig>({
	timeout: -1,
	maxTimeout: 3600,
});

describe('JobProcessor', () => {
	it('should refrain from processing a crashed execution', async () => {
		const executionRepository = mock<ExecutionRepository>();
		executionRepository.findSingleExecution.mockResolvedValue(
			mock<IExecutionResponse>({ status: 'crashed' }),
		);
		const jobProcessor = new JobProcessor(
			logger,
			executionRepository,
			mock(),
			mock(),
			mock(),
			mock(),
			executionsConfig,
			mock(),
		);

		const result = await jobProcessor.processJob(mock<Job>());

		expect(result).toEqual({ success: false });
	});

	it.each(['manual', 'evaluation'] satisfies WorkflowExecuteMode[])(
		'should use manualExecutionService to process a job in %p mode',
		async (mode) => {
			const executionRepository = mock<ExecutionRepository>();
			executionRepository.findSingleExecution.mockResolvedValue(
				mock<IExecutionResponse>({
					mode,
					workflowData: { nodes: [] },
					data: mock<IRunExecutionData>({
						executionData: undefined,
					}),
				}),
			);

			const manualExecutionService = mock<ManualExecutionService>();
			const jobProcessor = new JobProcessor(
				logger,
				executionRepository,
				mock(),
				mock(),
				mock(),
				manualExecutionService,
				executionsConfig,
				mock(),
			);

			const job = mock<Job>();

			await jobProcessor.processJob(job);

			expect(manualExecutionService.runManually).toHaveBeenCalledTimes(1);

			expect(job.progress).toHaveBeenCalledWith(
				expect.objectContaining({
					kind: 'job-finished',
					success: true,
				}),
			);
		},
	);

	it('should send job-finished with success=false when execution has errors', async () => {
		const executionRepository = mock<ExecutionRepository>();
		// First call: initial execution fetch (no error yet)
		executionRepository.findSingleExecution.mockResolvedValueOnce(
			mock<IExecutionResponse>({
				mode: 'manual',
				workflowData: { nodes: [] },
				data: mock<IRunExecutionData>({
					executionData: undefined,
				}),
			}),
		);
		// Second call: after execution completes, fetch again to check for errors
		executionRepository.findSingleExecution.mockResolvedValueOnce(
			mock<IExecutionResponse>({
				status: 'error',
				data: {
					resultData: {
						error: mock<ExecutionError>(),
					},
				},
			}),
		);

		const manualExecutionService = mock<ManualExecutionService>();
		const jobProcessor = new JobProcessor(
			logger,
			executionRepository,
			mock(),
			mock(),
			mock(),
			manualExecutionService,
			executionsConfig,
			mock(),
		);

		const job = mock<Job>();

		await jobProcessor.processJob(job);

		expect(job.progress).toHaveBeenCalledWith(
			expect.objectContaining({
				kind: 'job-finished',
				success: false,
			}),
		);
	});

	it('should pass additional data for partial executions to run', async () => {
		const executionRepository = mock<ExecutionRepository>();
		const pinData: IPinData = { pinned: [] };
		const execution = mock<IExecutionResponse>({
			mode: 'manual',
			workflowData: { id: 'workflow-id', nodes: [], pinData },
			data: mock<IRunExecutionData>({
				resultData: {
					runData: {
						trigger: [mock<ITaskData>({ executionIndex: 1 })],
						node: [mock<ITaskData>({ executionIndex: 3 }), mock<ITaskData>({ executionIndex: 4 })],
					},
					pinData,
				},
				executionData: undefined,
			}),
		});
		executionRepository.findSingleExecution.mockResolvedValue(execution);

		const additionalData = mock<IWorkflowExecuteAdditionalData>();
		jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue(additionalData);

		const manualExecutionService = mock<ManualExecutionService>();
		const jobProcessor = new JobProcessor(
			logger,
			executionRepository,
			mock(),
			mock(),
			mock(),
			manualExecutionService,
			executionsConfig,
			mock(),
		);

		const executionId = 'execution-id';
		await jobProcessor.processJob(mock<Job>({ data: { executionId, loadStaticData: false } }));

		expect(WorkflowExecuteAdditionalData.getBase).toHaveBeenCalledWith({
			workflowId: execution.workflowData.id,
			executionTimeoutTimestamp: undefined,
			workflowSettings: execution.workflowData.settings,
		});

		expect(manualExecutionService.runManually).toHaveBeenCalledWith(
			expect.objectContaining({
				executionMode: 'manual',
			}),
			expect.any(Workflow),
			additionalData,
			executionId,
			pinData,
		);
	});

	it('should set restartExecutionId on additionalData when provided in job data', async () => {
		const executionRepository = mock<ExecutionRepository>();
		const execution = mock<IExecutionResponse>({
			mode: 'manual',
			workflowData: { id: 'workflow-id', nodes: [] },
			data: mock<IRunExecutionData>({
				resultData: { runData: {} },
				executionData: undefined,
			}),
		});
		executionRepository.findSingleExecution.mockResolvedValue(execution);

		const additionalData = mock<IWorkflowExecuteAdditionalData>();
		jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue(additionalData);

		const manualExecutionService = mock<ManualExecutionService>();
		const jobProcessor = new JobProcessor(
			logger,
			executionRepository,
			mock(),
			mock(),
			mock(),
			manualExecutionService,
			executionsConfig,
			mock(),
		);

		const executionId = 'execution-id';
		const restartExecutionId = 'restart-execution-id';
		await jobProcessor.processJob(
			mock<Job>({ data: { executionId, loadStaticData: false, restartExecutionId } }),
		);

		expect(additionalData.restartExecutionId).toBe(restartExecutionId);
	});

	it.each(['manual', 'evaluation', 'trigger'] satisfies WorkflowExecuteMode[])(
		'should use workflowExecute to process a job with mode %p with execution data',
		async (mode) => {
			const { WorkflowExecute } = await import('n8n-core');
			// Type it correctly so we can use mock methods later
			const MockedWorkflowExecute = WorkflowExecute as jest.MockedClass<
				typeof ActualWorkflowExecute
			>;
			MockedWorkflowExecute.mockClear();

			const executionRepository = mock<ExecutionRepository>();
			const executionData = mock<IRunExecutionData>({
				startData: undefined,
				executionData: {
					nodeExecutionStack: [
						{
							node: { name: 'node-name' },
						},
					],
				},
			});
			executionRepository.findSingleExecution.mockResolvedValue(
				mock<IExecutionResponse>({
					mode,
					workflowData: { nodes: [] },
					data: executionData,
				}),
			);

			const additionalData = mock<IWorkflowExecuteAdditionalData>();
			jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue(additionalData);

			const manualExecutionService = mock<ManualExecutionService>();
			const jobProcessor = new JobProcessor(
				logger,
				executionRepository,
				mock(),
				mock(),
				mock(),
				manualExecutionService,
				executionsConfig,
				mock(),
			);

			await jobProcessor.processJob(mock<Job>());

			// Assert the constructor and method were called
			expect(MockedWorkflowExecute).toHaveBeenCalledWith(additionalData, mode, executionData);
			expect(processRunExecutionDataMock).toHaveBeenCalled();
		},
	);

	describe('MCP execution support', () => {
		it('should send mcp-response message for MCP executions after job completion', async () => {
			const executionRepository = mock<ExecutionRepository>();
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					mode: 'manual',
					workflowData: { id: 'wf-1', nodes: [], staticData: {} },
					data: mock<IRunExecutionData>({
						executionData: undefined,
					}),
				}),
			);
			// Second call for checking errors
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					status: 'success',
					workflowData: { id: 'wf-1', nodes: [], staticData: {} },
					data: mock<IRunExecutionData>({ resultData: { runData: {} } }),
				}),
			);

			const manualExecutionService = mock<ManualExecutionService>();
			const mcpInstanceSettings = {
				hostId: 'worker-host-123',
			} as unknown as InstanceSettings;

			const jobProcessor = new JobProcessor(
				logger,
				executionRepository,
				mock(), // workflowRepository
				mock(), // nodeTypes
				mcpInstanceSettings, // instanceSettings
				manualExecutionService,
				executionsConfig,
				mock(), // eventService
			);

			const job = mock<Job>();
			job.data = {
				workflowId: 'wf-1',
				executionId: 'exec-mcp-123',
				loadStaticData: false,
				isMcpExecution: true,
				mcpType: 'service',
				mcpSessionId: 'session-456',
				mcpMessageId: 'msg-789',
			};

			await jobProcessor.processJob(job);

			// Should have called progress with mcp-response
			expect(job.progress).toHaveBeenCalledWith(
				expect.objectContaining({
					kind: 'mcp-response',
					executionId: 'exec-mcp-123',
					mcpType: 'service',
					sessionId: 'session-456',
					messageId: 'msg-789',
					workerId: 'worker-host-123',
				}),
			);
		});

		it('should not send mcp-response for non-MCP executions', async () => {
			const executionRepository = mock<ExecutionRepository>();
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					mode: 'manual',
					workflowData: { id: 'wf-1', nodes: [], staticData: {} },
					data: mock<IRunExecutionData>({
						executionData: undefined,
					}),
				}),
			);
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					status: 'success',
					workflowData: { id: 'wf-1', nodes: [], staticData: {} },
					data: mock<IRunExecutionData>({ resultData: { runData: {} } }),
				}),
			);

			const manualExecutionService = mock<ManualExecutionService>();
			const jobProcessor = new JobProcessor(
				logger,
				executionRepository,
				mock(),
				mock(),
				mock(),
				manualExecutionService,
				executionsConfig,
				mock(),
			);

			const job = mock<Job>();
			job.data = {
				workflowId: 'wf-1',
				executionId: 'exec-regular-123',
				loadStaticData: false,
				isMcpExecution: undefined,
				mcpSessionId: undefined,
			};

			await jobProcessor.processJob(job);

			const progressCalls = (job.progress as jest.Mock).mock.calls;
			const mcpResponseCalls = progressCalls.filter(
				(call: unknown[]) => (call[0] as { kind: string }).kind === 'mcp-response',
			);
			expect(mcpResponseCalls).toHaveLength(0);
		});

		it('should include success=false in mcp-response when execution has errors', async () => {
			const executionRepository = mock<ExecutionRepository>();
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					mode: 'manual',
					workflowData: { id: 'wf-1', nodes: [], staticData: {} },
					data: mock<IRunExecutionData>({
						executionData: undefined,
					}),
				}),
			);
			// Second call shows error
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					status: 'error',
					workflowData: { id: 'wf-1', nodes: [], staticData: {} },
					data: mock<IRunExecutionData>({
						resultData: {
							runData: {},
							error: { message: 'Test error' } as ExecutionError,
						},
					}),
				}),
			);

			const manualExecutionService = mock<ManualExecutionService>();
			const mcpInstanceSettings = {
				hostId: 'worker-host-123',
			} as unknown as InstanceSettings;

			const jobProcessor = new JobProcessor(
				logger,
				executionRepository,
				mock(), // workflowRepository
				mock(), // nodeTypes
				mcpInstanceSettings, // instanceSettings
				manualExecutionService,
				executionsConfig,
				mock(), // eventService
			);

			const job = mock<Job>();
			job.data = {
				workflowId: 'wf-1',
				executionId: 'exec-mcp-error',
				loadStaticData: false,
				isMcpExecution: true,
				mcpType: 'service',
				mcpSessionId: 'session-456',
				mcpMessageId: 'msg-789',
			};

			await jobProcessor.processJob(job);

			expect(job.progress).toHaveBeenCalledWith(
				expect.objectContaining({
					kind: 'mcp-response',
					response: expect.objectContaining({
						success: false,
					}),
				}),
			);
		});

		it('should send mcp-response for MCP Trigger executions without tool call', async () => {
			const executionRepository = mock<ExecutionRepository>();
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					mode: 'trigger',
					workflowData: { id: 'wf-1', nodes: [], staticData: {} },
					data: mock<IRunExecutionData>({
						executionData: undefined,
					}),
				}),
			);
			// Second call for fetching result
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					status: 'success',
					workflowData: { id: 'wf-1', nodes: [], staticData: {} },
					data: mock<IRunExecutionData>({ resultData: { runData: {} } }),
				}),
			);

			const manualExecutionService = mock<ManualExecutionService>();
			const mcpInstanceSettings = {
				hostId: 'worker-host-123',
			} as unknown as InstanceSettings;

			const jobProcessor = new JobProcessor(
				logger,
				executionRepository,
				mock(), // workflowRepository
				mock(), // nodeTypes
				mcpInstanceSettings,
				manualExecutionService,
				executionsConfig,
				mock(), // eventService
			);

			const job = mock<Job>();
			job.data = {
				workflowId: 'wf-1',
				executionId: 'exec-mcp-trigger',
				loadStaticData: false,
				isMcpExecution: true,
				mcpType: 'trigger', // MCP Trigger type without tool call
				mcpSessionId: 'session-789',
				mcpMessageId: 'msg-456',
				mcpToolCall: undefined, // No tool call
			};

			await jobProcessor.processJob(job);

			// Should send mcp-response even for trigger type without tool call
			expect(job.progress).toHaveBeenCalledWith(
				expect.objectContaining({
					kind: 'mcp-response',
					executionId: 'exec-mcp-trigger',
					mcpType: 'trigger',
					sessionId: 'session-789',
					messageId: 'msg-456',
					workerId: 'worker-host-123',
				}),
			);
		});

		it('should execute tool node and send result for MCP Trigger with tool call', async () => {
			const executionRepository = mock<ExecutionRepository>();
			const toolNode = { name: 'tool-node', type: 'n8n-nodes-base.tool' };
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					mode: 'trigger',
					workflowData: {
						id: 'wf-1',
						nodes: [toolNode],
						staticData: {},
					},
					data: mock<IRunExecutionData>({
						executionData: undefined,
					}),
				}),
			);
			// Second call for fetching result
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					status: 'success',
					workflowData: { id: 'wf-1', nodes: [toolNode], staticData: {} },
					data: mock<IRunExecutionData>({ resultData: { runData: {} } }),
				}),
			);

			const manualExecutionService = mock<ManualExecutionService>();
			const mcpInstanceSettings = {
				hostId: 'worker-host-123',
			} as unknown as InstanceSettings;

			// Mock the processRunExecutionData to return tool result
			processRunExecutionDataMock.mockResolvedValue({
				data: {
					resultData: {
						runData: {
							'tool-node': [
								{
									data: {
										main: [[{ json: { toolResult: 'success' } }]],
									},
								},
							],
						},
					},
				},
			});

			const jobProcessor = new JobProcessor(
				logger,
				executionRepository,
				mock(), // workflowRepository
				mock(), // nodeTypes
				mcpInstanceSettings,
				manualExecutionService,
				executionsConfig,
				mock(), // eventService
			);

			const job = mock<Job>();
			job.data = {
				workflowId: 'wf-1',
				executionId: 'exec-mcp-tool',
				loadStaticData: false,
				isMcpExecution: true,
				mcpType: 'trigger',
				mcpSessionId: 'session-tool',
				mcpMessageId: 'msg-tool',
				mcpToolCall: {
					toolName: 'test-tool',
					arguments: { arg1: 'value1' },
					sourceNodeName: 'tool-node',
				},
			};

			await jobProcessor.processJob(job);

			// Should send mcp-response with tool result
			const mcpResponseCalls = (job.progress as jest.Mock).mock.calls.filter(
				(call: unknown[]) => (call[0] as { kind: string }).kind === 'mcp-response',
			);
			expect(mcpResponseCalls.length).toBeGreaterThan(0);
			expect(mcpResponseCalls[mcpResponseCalls.length - 1][0]).toMatchObject({
				kind: 'mcp-response',
				executionId: 'exec-mcp-tool',
				mcpType: 'trigger',
				sessionId: 'session-tool',
				messageId: 'msg-tool',
				workerId: 'worker-host-123',
			});
		});

		it('should handle tool execution errors gracefully', async () => {
			const executionRepository = mock<ExecutionRepository>();
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					mode: 'trigger',
					workflowData: {
						id: 'wf-1',
						nodes: [], // Missing tool node will cause error
						staticData: {},
					},
					data: mock<IRunExecutionData>({
						executionData: undefined,
					}),
				}),
			);
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					status: 'success',
					workflowData: { id: 'wf-1', nodes: [], staticData: {} },
					data: mock<IRunExecutionData>({ resultData: { runData: {} } }),
				}),
			);

			const manualExecutionService = mock<ManualExecutionService>();
			const mcpInstanceSettings = {
				hostId: 'worker-host-123',
			} as unknown as InstanceSettings;

			const jobProcessor = new JobProcessor(
				logger,
				executionRepository,
				mock(),
				mock(),
				mcpInstanceSettings,
				manualExecutionService,
				executionsConfig,
				mock(),
			);

			const job = mock<Job>();
			job.data = {
				workflowId: 'wf-1',
				executionId: 'exec-mcp-error',
				loadStaticData: false,
				isMcpExecution: true,
				mcpType: 'trigger',
				mcpSessionId: 'session-error',
				mcpMessageId: 'msg-error',
				mcpToolCall: {
					toolName: 'missing-tool',
					arguments: {},
					sourceNodeName: 'nonexistent-node',
				},
			};

			await jobProcessor.processJob(job);

			// Should send mcp-response with error (error object without stack trace)
			expect(job.progress).toHaveBeenCalledWith(
				expect.objectContaining({
					kind: 'mcp-response',
					response: expect.objectContaining({
						error: expect.objectContaining({
							message: expect.any(String),
						}),
					}),
				}),
			);

			// Verify no stack trace is exposed
			const mcpResponseCalls = (job.progress as jest.Mock).mock.calls.filter(
				(call: unknown[]) => (call[0] as { kind: string }).kind === 'mcp-response',
			);
			const lastResponse = mcpResponseCalls[mcpResponseCalls.length - 1][0] as {
				response: { error?: { stack?: string } };
			};
			expect(lastResponse.response.error?.stack).toBeUndefined();
		});

		it('should invoke tool via execute for tool wrapper nodes without supplyData', async () => {
			const executionRepository = mock<ExecutionRepository>();
			const toolNode = {
				name: 'HTTP Request',
				type: 'n8n-nodes-base.httpRequestTool',
				typeVersion: 4.4,
				parameters: {},
				position: [0, 0] as [number, number],
			};
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					mode: 'trigger',
					workflowData: {
						id: 'wf-1',
						nodes: [toolNode],
						staticData: {},
					},
					data: mock<IRunExecutionData>({
						executionData: undefined,
					}),
				}),
			);
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					status: 'success',
					workflowData: { id: 'wf-1', nodes: [toolNode], staticData: {} },
					data: mock<IRunExecutionData>({ resultData: { runData: {} } }),
				}),
			);

			const manualExecutionService = mock<ManualExecutionService>();
			const mcpInstanceSettings = {
				hostId: 'worker-host-123',
			} as unknown as InstanceSettings;

			// Mock nodeTypes to return a node without supplyData but with AiTool output
			const mockExecuteFn = jest
				.fn()
				.mockResolvedValue([[{ json: { result: 'tool response data' } }]]);
			const nodeTypes = mock<NodeTypes>();
			nodeTypes.getByNameAndVersion.mockReturnValue({
				description: {
					name: 'httpRequestTool',
					outputs: [NodeConnectionTypes.AiTool],
					properties: [],
				},
				execute: mockExecuteFn,
			} as never);

			const jobProcessor = new JobProcessor(
				logger,
				executionRepository,
				mock(),
				nodeTypes,
				mcpInstanceSettings,
				manualExecutionService,
				executionsConfig,
				mock(),
			);

			const job = mock<Job>();
			job.data = {
				workflowId: 'wf-1',
				executionId: 'exec-mcp-tool-wrapper',
				loadStaticData: false,
				isMcpExecution: true,
				mcpType: 'trigger',
				mcpSessionId: 'session-tool',
				mcpMessageId: 'msg-tool',
				mcpToolCall: {
					toolName: 'HTTP Request',
					arguments: { url: 'https://example.com' },
					sourceNodeName: 'HTTP Request',
				},
			};

			await jobProcessor.processJob(job);

			// execute should have been called (not supplyData)
			expect(mockExecuteFn).toHaveBeenCalled();

			// Should send mcp-response with tool result
			const mcpResponseCalls = (job.progress as jest.Mock).mock.calls.filter(
				(call: unknown[]) => (call[0] as { kind: string }).kind === 'mcp-response',
			);
			expect(mcpResponseCalls.length).toBeGreaterThan(0);
			const lastResponse = mcpResponseCalls[mcpResponseCalls.length - 1][0] as {
				kind: string;
				mcpType: string;
				response: unknown;
			};
			expect(lastResponse).toMatchObject({
				kind: 'mcp-response',
				mcpType: 'trigger',
			});
			// Response should contain the tool's output data
			expect(lastResponse.response).toEqual([{ result: 'tool response data' }]);
		});

		it('should invoke tool via supplyData for nodes with supplyData method', async () => {
			const executionRepository = mock<ExecutionRepository>();
			const toolNode = {
				name: 'Tool HTTP Request',
				type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
				typeVersion: 1,
				parameters: {},
				position: [0, 0] as [number, number],
			};
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					mode: 'trigger',
					workflowData: {
						id: 'wf-1',
						nodes: [toolNode],
						staticData: {},
					},
					data: mock<IRunExecutionData>({
						executionData: undefined,
					}),
				}),
			);
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					status: 'success',
					workflowData: { id: 'wf-1', nodes: [toolNode], staticData: {} },
					data: mock<IRunExecutionData>({ resultData: { runData: {} } }),
				}),
			);

			const manualExecutionService = mock<ManualExecutionService>();
			const mcpInstanceSettings = {
				hostId: 'worker-host-123',
			} as unknown as InstanceSettings;

			// Mock a tool that supplyData returns
			const mockTool = { invoke: jest.fn().mockResolvedValue('supply data tool result') };
			const mockSupplyData = jest.fn().mockResolvedValue({ response: mockTool });

			const nodeTypes = mock<NodeTypes>();
			nodeTypes.getByNameAndVersion.mockReturnValue({
				description: {
					name: 'toolHttpRequest',
					outputs: [NodeConnectionTypes.AiTool],
					properties: [],
				},
				supplyData: mockSupplyData,
			} as never);

			const jobProcessor = new JobProcessor(
				logger,
				executionRepository,
				mock(),
				nodeTypes,
				mcpInstanceSettings,
				manualExecutionService,
				executionsConfig,
				mock(),
			);

			const job = mock<Job>();
			job.data = {
				workflowId: 'wf-1',
				executionId: 'exec-mcp-supply-data',
				loadStaticData: false,
				isMcpExecution: true,
				mcpType: 'trigger',
				mcpSessionId: 'session-supply',
				mcpMessageId: 'msg-supply',
				mcpToolCall: {
					toolName: 'Tool HTTP Request',
					arguments: { query: 'test' },
					sourceNodeName: 'Tool HTTP Request',
				},
			};

			await jobProcessor.processJob(job);

			// supplyData path should have been used
			expect(mockSupplyData).toHaveBeenCalled();
			expect(mockTool.invoke).toHaveBeenCalledWith({ query: 'test' });

			// Should send mcp-response with tool result
			const mcpResponseCalls = (job.progress as jest.Mock).mock.calls.filter(
				(call: unknown[]) => (call[0] as { kind: string }).kind === 'mcp-response',
			);
			expect(mcpResponseCalls.length).toBeGreaterThan(0);
			const lastResponse = mcpResponseCalls[mcpResponseCalls.length - 1][0] as {
				response: unknown;
			};
			expect(lastResponse.response).toBe('supply data tool result');
		});
	});

	describe('waitTill propagation', () => {
		it('carries waitTill on JobFinishedProps from the worker run (lightweight path)', () => {
			const waitTill = new Date(Date.now() + 60_000);
			const jobProcessor = new JobProcessor(
				logger,
				mock<ExecutionRepository>(),
				mock(),
				mock(),
				mock(),
				mock(),
				executionsConfig,
				mock(),
			);
			const run = mock<IRun>({
				status: 'waiting',
				stoppedAt: new Date(),
				data: mock<IRunExecutionData>({
					resultData: { runData: {}, error: undefined },
					executionData: undefined,
				}),
			});
			// set Date field after construction, else jest-mock-extended serializes them otherwise.
			run.waitTill = waitTill;

			const props = jobProcessor['deriveJobFinishedProps'](run, new Date());

			expect(props.waitTill).toBe(waitTill);
			expect(props.status).toBe('waiting');
		});

		it('carries waitTill on JobFinishedProps from the persisted execution (DB-fetch path)', async () => {
			const waitTill = new Date(Date.now() + 60_000);
			const executionRepository = mock<ExecutionRepository>();
			const persisted = mock<IExecutionResponse>({
				status: 'waiting',
				stoppedAt: new Date(),
				data: mock<IRunExecutionData>({
					resultData: { runData: {}, error: undefined },
					executionData: undefined,
				}),
			});
			persisted.waitTill = waitTill;
			executionRepository.findSingleExecution.mockResolvedValueOnce(persisted);
			const jobProcessor = new JobProcessor(
				logger,
				executionRepository,
				mock(),
				mock(),
				mock(),
				mock(),
				executionsConfig,
				mock(),
			);

			const props = await jobProcessor['fetchJobFinishedResult']('exec-1');

			expect(props.waitTill).toBe(waitTill);
			expect(props.status).toBe('waiting');
		});

		it('defaults waitTill to null on JobFinishedProps when the run is not waiting', async () => {
			const executionRepository = mock<ExecutionRepository>();
			const jobProcessor = new JobProcessor(
				logger,
				executionRepository,
				mock(),
				mock(),
				mock(),
				mock(),
				executionsConfig,
				mock(),
			);
			const run = mock<IRun>({
				status: 'success',
				stoppedAt: new Date(),
				data: mock<IRunExecutionData>({
					resultData: { runData: {}, error: undefined },
					executionData: undefined,
				}),
			});
			run.waitTill = undefined;
			expect(jobProcessor['deriveJobFinishedProps'](run, new Date()).waitTill).toBeNull();

			const persisted = mock<IExecutionResponse>({
				status: 'success',
				stoppedAt: new Date(),
				data: mock<IRunExecutionData>({
					resultData: { runData: {}, error: undefined },
					executionData: undefined,
				}),
			});
			persisted.waitTill = null;
			executionRepository.findSingleExecution.mockResolvedValueOnce(persisted);
			expect((await jobProcessor['fetchJobFinishedResult']('exec-1')).waitTill).toBeNull();
		});
	});

	describe('project info in log metadata', () => {
		beforeEach(() => {
			jest.clearAllMocks();
		});
		it('should include project info in log metadata when present in job data', async () => {
			const executionRepository = mock<ExecutionRepository>();
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					mode: 'manual',
					workflowData: { id: 'wf-1', name: 'Test Workflow', nodes: [] },
					data: mock<IRunExecutionData>({
						executionData: undefined,
					}),
				}),
			);
			// Second call for checking errors
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					status: 'success',
					data: mock<IRunExecutionData>({ resultData: { runData: {} } }),
				}),
			);

			const manualExecutionService = mock<ManualExecutionService>();
			const jobProcessor = new JobProcessor(
				logger,
				executionRepository,
				mock(),
				mock(),
				mock(),
				manualExecutionService,
				executionsConfig,
				mock(),
			);

			const job = mock<Job>();
			job.data = {
				workflowId: 'wf-1',
				executionId: 'exec-1',
				loadStaticData: false,
				projectId: 'proj-123',
				projectName: 'My Project',
			};

			await jobProcessor.processJob(job);

			// "Worker started" log should include project info
			expect(logger.info).toHaveBeenCalledWith(
				expect.stringContaining('Worker started execution'),
				expect.objectContaining({
					workflowId: 'wf-1',
					workflowName: 'Test Workflow',
					projectId: 'proj-123',
					projectName: 'My Project',
				}),
			);

			// "Worker finished" log should include project info
			expect(logger.info).toHaveBeenCalledWith(
				expect.stringContaining('Worker finished execution'),
				expect.objectContaining({
					workflowId: 'wf-1',
					workflowName: 'Test Workflow',
					projectId: 'proj-123',
					projectName: 'My Project',
				}),
			);
		});

		it('should not include project info in log metadata when absent from job data', async () => {
			const executionRepository = mock<ExecutionRepository>();
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					mode: 'manual',
					workflowData: { id: 'wf-1', name: 'Test Workflow', nodes: [] },
					data: mock<IRunExecutionData>({
						executionData: undefined,
					}),
				}),
			);
			executionRepository.findSingleExecution.mockResolvedValueOnce(
				mock<IExecutionResponse>({
					status: 'success',
					data: mock<IRunExecutionData>({ resultData: { runData: {} } }),
				}),
			);

			const manualExecutionService = mock<ManualExecutionService>();
			const jobProcessor = new JobProcessor(
				logger,
				executionRepository,
				mock(),
				mock(),
				mock(),
				manualExecutionService,
				executionsConfig,
				mock(),
			);

			const job = mock<Job>();
			job.data = {
				workflowId: 'wf-1',
				executionId: 'exec-1',
				loadStaticData: false,
			};

			await jobProcessor.processJob(job);

			// "Worker started" log should not include project fields
			const startedCall = (logger.info as jest.Mock).mock.calls.find(
				(call: unknown[]) =>
					typeof call[0] === 'string' && call[0].includes('Worker started execution'),
			) as [string, Record<string, unknown>] | undefined;
			expect(startedCall).toBeDefined();
			expect(startedCall![1].workflowId).toBe('wf-1');
			expect(startedCall![1]).not.toHaveProperty('projectId');
			expect(startedCall![1]).not.toHaveProperty('projectName');
		});
	});
});
