import type { EndpointsConfig } from '@n8n/config';
import type { IExecutionResponse } from '@n8n/db';
import type express from 'express';
import type { InstanceSettings } from 'n8n-core';
import { WAITING_TOKEN_QUERY_PARAM } from 'n8n-core';
import type { INodeParameters, IWorkflowBase, Workflow } from 'n8n-workflow';
import { SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';

import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { EventService } from '@/events/event.service';
import type { ExecutionPersistence } from '@/executions/execution-persistence';
import { WaitingWebhooks } from '@/webhooks/waiting-webhooks';
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
import type { WebhookService } from '@/webhooks/webhook.service';
import type { WaitingWebhookRequest } from '@/webhooks/webhook.types';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';

class TestWaitingWebhooks extends WaitingWebhooks {
	exposeCreateWorkflow(workflowData: IWorkflowBase): Workflow {
		return this.createWorkflow(workflowData);
	}

	exposeValidateSignature(req: express.Request): { valid: boolean; webhookPath?: string } {
		return this.validateSignature(req);
	}

	exposeValidateToken(
		req: express.Request,
		execution: IExecutionResponse,
	): { valid: boolean; webhookPath?: string } {
		return this.validateToken(req, execution);
	}
}

describe('WaitingWebhooks', () => {
	const TEST_HMAC_SECRET = 'test-hmac-secret-key';
	const executionPersistence = mock<ExecutionPersistence>();
	const mockWebhookService = mock<WebhookService>();
	const mockInstanceSettings = mock<InstanceSettings>({ hmacSignatureSecret: TEST_HMAC_SECRET });
	const mockEventService = mock<EventService>();
	const mockEndpointsConfig = mock<EndpointsConfig>({
		webhookWaiting: 'webhook-waiting',
		formWaiting: 'form-waiting',
	});
	const waitingWebhooks = new TestWaitingWebhooks(
		mock(),
		mock(),
		executionPersistence,
		mockWebhookService,
		mockInstanceSettings,
		mockEventService,
		mockEndpointsConfig,
	);

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

	it('should throw NotFoundError if there is no execution to resume', async () => {
		/**
		 * Arrange
		 */
		executionPersistence.findSingleExecution.mockResolvedValue(undefined);

		/**
		 * Act
		 */
		const promise = waitingWebhooks.executeWebhook(
			mock<WaitingWebhookRequest>(),
			mock<express.Response>(),
		);

		/**
		 * Assert
		 */
		await expect(promise).rejects.toThrowError(NotFoundError);
	});

	it('should throw ConflictError if the execution to resume is already running', async () => {
		/**
		 * Arrange
		 */
		executionPersistence.findSingleExecution.mockResolvedValue(
			mock<IExecutionResponse>({ status: 'running' }),
		);

		/**
		 * Act
		 */
		const promise = waitingWebhooks.executeWebhook(
			mock<WaitingWebhookRequest>(),
			mock<express.Response>(),
		);

		/**
		 * Assert
		 */
		await expect(promise).rejects.toThrowError(ConflictError);
	});

	it('should throw ConflictError if the execution to resume already finished', async () => {
		/**
		 * Arrange
		 */
		executionPersistence.findSingleExecution.mockResolvedValue(
			mock<IExecutionResponse>({
				finished: true,
				// Provide real values for the fields the Workflow constructor reads so
				// vitest-mock-extended keeps them as plain data rather than auto-mocking
				// them into functions (whose read-only `.mock` breaks ObservableObject).
				workflowData: { nodes: [], connections: {}, staticData: {} },
			}),
		);

		/**
		 * Act
		 */
		const promise = waitingWebhooks.executeWebhook(
			mock<WaitingWebhookRequest>(),
			mock<express.Response>(),
		);

		/**
		 * Assert
		 */
		await expect(promise).rejects.toThrowError(ConflictError);
	});

	describe('findAccessControlOptions', () => {
		it('should return * as allowed origins', async () => {
			const options = await waitingWebhooks.findAccessControlOptions();
			expect(options).toEqual({ allowedOrigins: '*' });
		});
	});

	describe('validateSignature', () => {
		const EXAMPLE_HOST = 'example.com';

		// Helper to generate proper HMAC signature for a URL
		const generateTestSignature = (urlPath: string) => {
			const crypto = require('crypto');
			return crypto.createHmac('sha256', TEST_HMAC_SECRET).update(urlPath).digest('hex');
		};

		const createMockRequest = (opts: { host?: string; signature?: string; path?: string }) => {
			const urlPath = opts.path ?? '/webhook/test';
			const fullUrl = opts.signature
				? `${urlPath}?${WAITING_TOKEN_QUERY_PARAM}=${opts.signature}`
				: urlPath;
			return mock<express.Request>({
				url: fullUrl,
				headers: { host: opts.host ?? EXAMPLE_HOST },
			});
		};

		it('should validate signature correctly when HMAC matches', () => {
			/* Arrange */
			const urlPath = '/webhook/test';
			const validSignature = generateTestSignature(urlPath);
			const mockReq = createMockRequest({ signature: validSignature, path: urlPath });

			/* Act */
			const result = waitingWebhooks.exposeValidateSignature(mockReq);

			/* Assert */
			expect(result).toEqual({ valid: true, webhookPath: undefined });
		});

		it('should return false when signature is missing', () => {
			/* Arrange */
			const mockReq = createMockRequest({});

			/* Act */
			const result = waitingWebhooks.exposeValidateSignature(mockReq);

			/* Assert */
			expect(result).toEqual({ valid: false });
		});

		it('should return false when signature is invalid', () => {
			/* Arrange */
			const wrongSignature = 'b'.repeat(64);
			const mockReq = createMockRequest({ signature: wrongSignature });

			/* Act */
			const result = waitingWebhooks.exposeValidateSignature(mockReq);

			/* Assert */
			expect(result).toEqual({ valid: false, webhookPath: undefined });
		});

		it('should return false when signature length is wrong', () => {
			/* Arrange */
			const shortSignature = 'abc123';
			const mockReq = createMockRequest({ signature: shortSignature });

			/* Act */
			const result = waitingWebhooks.exposeValidateSignature(mockReq);

			/* Assert */
			expect(result).toEqual({ valid: false, webhookPath: undefined });
		});

		it('should extract suffix from signature when appended (backwards compat)', () => {
			/* Arrange - URL format: ?signature=hmac/suffix */
			const urlPath = '/webhook/test';
			const validSignature = generateTestSignature(urlPath);
			const signatureWithSuffix = `${validSignature}/my-suffix`;
			const mockReq = createMockRequest({ signature: signatureWithSuffix, path: urlPath });

			/* Act */
			const result = waitingWebhooks.exposeValidateSignature(mockReq);

			/* Assert */
			expect(result).toEqual({ valid: true, webhookPath: 'my-suffix' });
		});

		it('should handle nested suffix paths (backwards compat)', () => {
			/* Arrange - URL format: ?signature=hmac/path/to/suffix */
			const urlPath = '/webhook/test';
			const validSignature = generateTestSignature(urlPath);
			const signatureWithNestedSuffix = `${validSignature}/path/to/suffix`;
			const mockReq = createMockRequest({ signature: signatureWithNestedSuffix, path: urlPath });

			/* Act */
			const result = waitingWebhooks.exposeValidateSignature(mockReq);

			/* Assert */
			expect(result).toEqual({ valid: true, webhookPath: 'path/to/suffix' });
		});

		it('should validate signature correctly when nodeId suffix is part of signed URL (send-and-wait)', () => {
			/* Arrange - Send-and-wait URLs include nodeId in the signed path along with query params.
			 * The signature is computed from pathname + query params (excluding signature itself). */
			const nodeId = '3a3e4b33-52d1-41f9-9c69-2829030062ff';
			const pathWithNodeId = `/webhook-waiting/123/${nodeId}`;
			const pathWithParams = `${pathWithNodeId}?approved=true`;
			// Signature generated for full path including nodeId and query params
			const validSignature = generateTestSignature(pathWithParams);
			// Construct URL with both approved and signature params
			const fullUrl = `${pathWithNodeId}?approved=true&${WAITING_TOKEN_QUERY_PARAM}=${validSignature}`;
			const mockReq = mock<express.Request>({
				url: fullUrl,
				headers: { host: EXAMPLE_HOST },
			});

			/* Act - Don't pass suffix because nodeId is part of signed URL */
			const result = waitingWebhooks.exposeValidateSignature(mockReq);

			/* Assert */
			expect(result).toEqual({ valid: true, webhookPath: undefined });
		});
	});

	describe('executeWebhook - signature validation', () => {
		it('should return 401 with HTML for send-and-wait requests with invalid signature', async () => {
			/* Arrange */
			const nodeId = 'send-and-wait-node-id';
			const execution = mock<IExecutionResponse>({
				finished: false,
				status: 'waiting',
				data: {
					resultData: {
						lastNodeExecuted: 'SendAndWaitNode',
						runData: {},
						error: undefined,
					},
					resumeToken: 'a'.repeat(64),
				},
				workflowData: {
					id: 'workflow1',
					name: 'Test Workflow',
					nodes: [
						{
							id: nodeId,
							name: 'SendAndWaitNode',
							type: 'n8n-nodes-base.sendAndWait',
							parameters: { operation: SEND_AND_WAIT_OPERATION },
							typeVersion: 1,
							position: [0, 0],
						},
					],
					connections: {},
					active: false,
					settings: {},
					staticData: {},
				},
			});
			executionPersistence.findSingleExecution.mockResolvedValue(execution);

			const mockStatus = vi.fn().mockReturnThis();
			const mockRender = vi.fn();
			const mockJson = vi.fn();
			const res = mock<express.Response>({
				status: mockStatus,
				render: mockRender,
				json: mockJson,
			});
			// Request has wrong signature
			const req = mock<WaitingWebhookRequest>({
				params: { path: 'execution-id', suffix: nodeId },
				method: 'GET',
				url: `/webhook/execution-id/${nodeId}?${WAITING_TOKEN_QUERY_PARAM}=wrong-signature`,
				headers: { host: 'example.com' },
			});

			/* Act */
			const result = await waitingWebhooks.executeWebhook(req, res);

			/* Assert */
			expect(mockStatus).toHaveBeenCalledWith(401);
			expect(mockRender).toHaveBeenCalledWith('form-invalid-token');
			expect(res.setHeader).toHaveBeenCalledWith(
				'Content-Security-Policy',
				expect.stringContaining('sandbox'),
			);
			expect(mockJson).not.toHaveBeenCalled();
			expect(result).toEqual({ noWebhookResponse: true });
		});

		it('should return 401 with JSON for non-send-and-wait requests with invalid token', async () => {
			/* Arrange */
			const execution = mock<IExecutionResponse>({
				finished: false,
				status: 'waiting',
				data: {
					resultData: {
						lastNodeExecuted: 'WaitNode',
						runData: {},
						error: undefined,
					},
					resumeToken: 'a'.repeat(64),
				},
				workflowData: {
					id: 'workflow1',
					name: 'Test Workflow',
					nodes: [
						{
							id: 'wait-node-id',
							name: 'WaitNode',
							type: 'n8n-nodes-base.wait',
							parameters: { operation: 'webhook' },
							typeVersion: 1,
							position: [0, 0],
						},
					],
					connections: {},
					active: false,
					settings: {},
					staticData: {},
				},
			});
			executionPersistence.findSingleExecution.mockResolvedValue(execution);

			const mockStatus = vi.fn().mockReturnThis();
			const mockRender = vi.fn();
			const mockJson = vi.fn();
			const res = mock<express.Response>({
				status: mockStatus,
				render: mockRender,
				json: mockJson,
			});
			// Request has wrong signature
			const req = mock<WaitingWebhookRequest>({
				params: { path: 'execution-id', suffix: 'wait-node-id' },
				method: 'GET',
				url: `/webhook/execution-id/wait-node-id?${WAITING_TOKEN_QUERY_PARAM}=wrong-signature`,
				headers: { host: 'example.com' },
			});

			/* Act */
			const result = await waitingWebhooks.executeWebhook(req, res);

			/* Assert */
			expect(mockStatus).toHaveBeenCalledWith(401);
			expect(mockJson).toHaveBeenCalledWith({ error: 'Invalid token' });
			expect(mockRender).not.toHaveBeenCalled();
			expect(result).toEqual({ noWebhookResponse: true });
		});
	});

	describe('form-resume redirect to form-waiting', () => {
		const buildExecution = (opts: { nodeType: string; nodeParameters?: INodeParameters }) =>
			mock<IExecutionResponse>({
				status: 'waiting',
				finished: false,
				data: {
					resumeToken: undefined,
					executionData: {
						nodeExecutionStack: [
							{
								node: {
									id: 'resume-node-id',
									name: 'ResumeNode',
									type: opts.nodeType,
									parameters: opts.nodeParameters ?? {},
									typeVersion: 1,
									position: [0, 0],
									disabled: false,
								},
								data: {},
								source: null,
							},
						],
					},
					resultData: {
						lastNodeExecuted: 'ResumeNode',
						runData: {
							ResumeNode: [{ startTime: 0, executionTime: 0, executionIndex: 0, source: [] }],
						},
						error: undefined,
					},
				},
				workflowData: {
					id: 'workflow1',
					name: 'Test Workflow',
					nodes: [
						{
							id: 'resume-node-id',
							name: 'ResumeNode',
							type: opts.nodeType,
							parameters: opts.nodeParameters ?? {},
							typeVersion: 1,
							position: [0, 0],
						},
					],
					connections: {},
					active: true,
					settings: {},
					staticData: {},
				},
			});

		const buildRes = () =>
			mock<express.Response>({
				redirect: vi.fn(),
				status: vi.fn().mockReturnThis(),
				json: vi.fn(),
				render: vi.fn(),
			});

		it('redirects a Wait node resuming on form submission', async () => {
			executionPersistence.findSingleExecution.mockResolvedValue(
				buildExecution({ nodeType: 'n8n-nodes-base.wait', nodeParameters: { resume: 'form' } }),
			);
			const res = buildRes();
			const req = mock<WaitingWebhookRequest>({
				params: { path: 'exec-id', suffix: undefined },
				method: 'GET',
				originalUrl: '/webhook-waiting/exec-id?signature=abc',
			});

			const result = await waitingWebhooks.executeWebhook(req, res);

			expect(res.redirect).toHaveBeenCalledWith(307, '/form-waiting/exec-id?signature=abc');
			expect(result).toEqual({ noWebhookResponse: true });
		});

		it('redirects a Form node', async () => {
			executionPersistence.findSingleExecution.mockResolvedValue(
				buildExecution({ nodeType: 'n8n-nodes-base.form' }),
			);
			const res = buildRes();
			const req = mock<WaitingWebhookRequest>({
				params: { path: 'exec-id', suffix: 'my-suffix' },
				method: 'POST',
				originalUrl: '/webhook-waiting/exec-id/my-suffix?signature=abc',
			});

			const result = await waitingWebhooks.executeWebhook(req, res);

			expect(res.redirect).toHaveBeenCalledWith(
				307,
				'/form-waiting/exec-id/my-suffix?signature=abc',
			);
			expect(result).toEqual({ noWebhookResponse: true });
		});

		it('does not redirect a Wait node resuming on webhook call', async () => {
			executionPersistence.findSingleExecution.mockResolvedValue(
				buildExecution({ nodeType: 'n8n-nodes-base.wait', nodeParameters: { resume: 'webhook' } }),
			);
			mockWebhookService.getNodeWebhooks.mockReturnValue([
				{
					httpMethod: 'GET',
					path: '',
					webhookDescription: {
						restartWebhook: true,
						httpMethod: 'GET',
						name: 'default',
						path: '',
						nodeType: undefined,
					} as any,
				},
			] as any);
			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);
			vi.spyOn(WebhookHelpers, 'executeWebhook').mockImplementation(
				async (_w, _wd, _wfd, _wsn, _m, _pr, _red, _eid, _req, _res, callback) => {
					callback(null, { noWebhookResponse: true });
					return undefined;
				},
			);
			const res = buildRes();
			const req = mock<WaitingWebhookRequest>({
				params: { path: 'exec-id', suffix: undefined },
				method: 'GET',
				originalUrl: '/webhook-waiting/exec-id?signature=abc',
			});

			await waitingWebhooks.executeWebhook(req, res);

			expect(res.redirect).not.toHaveBeenCalled();
		});

		it('does not redirect when the waiting endpoints are configured identically', async () => {
			const identicalEndpointsConfig = mock<EndpointsConfig>({
				webhookWaiting: 'webhook-waiting',
				formWaiting: 'webhook-waiting',
			});
			const webhooksWithIdenticalEndpoints = new TestWaitingWebhooks(
				mock(),
				mock(),
				executionPersistence,
				mockWebhookService,
				mockInstanceSettings,
				mockEventService,
				identicalEndpointsConfig,
			);
			executionPersistence.findSingleExecution.mockResolvedValue(
				buildExecution({ nodeType: 'n8n-nodes-base.wait', nodeParameters: { resume: 'form' } }),
			);
			mockWebhookService.getNodeWebhooks.mockReturnValue([]);
			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);
			const res = buildRes();
			const req = mock<WaitingWebhookRequest>({
				params: { path: 'exec-id', suffix: undefined },
				method: 'GET',
				originalUrl: '/webhook-waiting/exec-id?signature=abc',
			});

			await expect(webhooksWithIdenticalEndpoints.executeWebhook(req, res)).rejects.toThrowError(
				NotFoundError,
			);
			expect(res.redirect).not.toHaveBeenCalled();
		});
	});

	describe('createWorkflow', () => {
		it('should handle old executions with missing activeVersionId field when active=true', () => {
			const workflowData = {
				id: 'workflow1',
				name: 'Test Workflow',
				nodes: [],
				connections: {},
				active: true,
				activeVersionId: undefined, // Must be explicitly set to undefined; vitest-mock-extended returns a truthy mock if omitted
				settings: {},
				staticData: {},
				isArchived: false,
				createdAt: new Date(),
				updatedAt: new Date(),
			};

			// @ts-expect-error: createWorkflow typing is incorrect, will be fixed later
			const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData);

			expect(workflow.active).toBe(true);
		});

		it('should handle old executions with missing activeVersionId field when active=false', () => {
			const workflowData = {
				id: 'workflow1',
				name: 'Test Workflow',
				nodes: [],
				connections: {},
				active: false,
				activeVersionId: undefined, // Must be explicitly set to undefined; vitest-mock-extended returns a truthy mock if omitted
				settings: {},
				staticData: {},
				isArchived: false,
				createdAt: new Date(),
				updatedAt: new Date(),
			};

			// @ts-expect-error: createWorkflow typing is incorrect, will be fixed later
			const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData);

			expect(workflow.active).toBe(false);
		});

		it('should set active to true when activeVersionId exists', () => {
			const workflowData: IWorkflowBase = {
				id: 'workflow1',
				name: 'Test Workflow',
				nodes: [],
				connections: {},
				active: true,
				activeVersionId: 'version-123',
				settings: {},
				staticData: {},
				isArchived: false,
				createdAt: new Date(),
				updatedAt: new Date(),
			};

			const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData);

			expect(workflow.active).toBe(true);
		});

		it('should set active to false when activeVersionId is null', () => {
			const workflowData: IWorkflowBase = {
				id: 'workflow1',
				name: 'Test Workflow',
				nodes: [],
				connections: {},
				active: false,
				activeVersionId: null,
				settings: {},
				staticData: {},
				isArchived: false,
				createdAt: new Date(),
				updatedAt: new Date(),
			};

			const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData);

			expect(workflow.active).toBe(false);
		});
	});

	describe('HITL and inputOverride handling in executeWebhook', () => {
		it('should set rewireOutputLogTo for HITL nodes', async () => {
			/**
			 * Arrange
			 */
			const executionId = 'test-execution-id';
			const lastNodeExecuted = 'HitlToolNode';

			const mockExecution = mock<IExecutionResponse>({
				id: executionId,
				status: 'waiting',
				finished: false,
				mode: 'manual',
				data: {
					executionData: {
						nodeExecutionStack: [
							{
								node: {
									name: lastNodeExecuted,
									type: '@n8n/n8n-nodes-langchain.someHitlTool',
									typeVersion: 1,
									parameters: {},
									id: 'node-id',
									position: [0, 0],
									disabled: false,
								},
								data: {},
								source: null,
							},
						],
					},
					resultData: {
						runData: {
							[lastNodeExecuted]: [
								{
									startTime: 123,
									executionTime: 456,
									executionIndex: 0,
									source: [],
								},
							],
						},
						lastNodeExecuted,
					},
				},
				workflowData: {
					id: 'workflow-id',
					name: 'Test Workflow',
					nodes: [
						{
							name: lastNodeExecuted,
							type: '@n8n/n8n-nodes-langchain.someHitlTool',
							typeVersion: 1,
							parameters: {},
							id: 'node-id',
							position: [0, 0],
						},
					],
					connections: {},
					active: true,
					settings: {},
					staticData: {},
					isArchived: false,
					createdAt: new Date(),
					updatedAt: new Date(),
				},
			});

			// Explicitly set error to undefined to avoid mock function
			mockExecution.data.resultData.error = undefined;
			mockExecution.data.resumeToken = undefined;

			executionPersistence.findSingleExecution.mockResolvedValue(mockExecution);

			const mockReq = mock<WaitingWebhookRequest>({
				params: { path: executionId, suffix: undefined },
				method: 'POST',
			});
			const mockRes = mock<express.Response>();

			vi.spyOn(WebhookHelpers, 'executeWebhook').mockImplementation(
				async (
					_workflow,
					_webhookData,
					_workflowData,
					_workflowStartNode,
					_mode,
					_pushRef,
					_runExecutionData,
					_executionId,
					_req,
					_res,
					callback,
				) => {
					callback(null, { noWebhookResponse: true });
					return undefined;
				},
			);

			mockWebhookService.getNodeWebhooks.mockReturnValue([
				{
					httpMethod: 'POST',
					path: '',
					webhookDescription: {
						restartWebhook: true,
						httpMethod: 'POST',
						name: 'default',
						path: '',
						nodeType: undefined,
					} as any,
				},
			] as any);

			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);

			/**
			 * Act
			 */
			await waitingWebhooks.executeWebhook(mockReq, mockRes);

			/**
			 * Assert
			 */
			const nodeExecutionStack = mockExecution.data.executionData!.nodeExecutionStack;
			expect(nodeExecutionStack[0].node.rewireOutputLogTo).toBe('ai_tool');
		});

		it('rejects (does not hang) when executeWebhook throws before invoking the callback', async () => {
			const executionId = 'test-execution-id';
			const lastNodeExecuted = 'WaitNode';

			const mockExecution = mock<IExecutionResponse>({
				id: executionId,
				status: 'waiting',
				finished: false,
				mode: 'manual',
				data: {
					executionData: {
						nodeExecutionStack: [
							{
								node: {
									name: lastNodeExecuted,
									type: 'n8n-nodes-base.wait',
									typeVersion: 1,
									parameters: {},
									id: 'node-id',
									position: [0, 0],
									disabled: false,
								},
								data: {},
								source: null,
							},
						],
					},
					resultData: {
						runData: {
							[lastNodeExecuted]: [
								{ startTime: 123, executionTime: 456, executionIndex: 0, source: [] },
							],
						},
						lastNodeExecuted,
					},
				},
				workflowData: {
					id: 'workflow-id',
					name: 'Test Workflow',
					nodes: [
						{
							name: lastNodeExecuted,
							type: 'n8n-nodes-base.wait',
							typeVersion: 1,
							parameters: {},
							id: 'node-id',
							position: [0, 0],
						},
					],
					connections: {},
					active: true,
					settings: {},
					staticData: {},
				},
			});
			mockExecution.data.resultData.error = undefined;
			mockExecution.data.resumeToken = undefined;
			executionPersistence.findSingleExecution.mockResolvedValue(mockExecution);

			const preCallbackError = new Error('response option expression failed');
			vi.spyOn(WebhookHelpers, 'executeWebhook').mockRejectedValue(preCallbackError);

			mockWebhookService.getNodeWebhooks.mockReturnValue([
				{
					httpMethod: 'POST',
					path: '',
					webhookDescription: {
						restartWebhook: true,
						httpMethod: 'POST',
						name: 'default',
						path: '',
						nodeType: undefined,
					} as any,
				},
			] as any);
			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);

			const mockReq = mock<WaitingWebhookRequest>({
				params: { path: executionId, suffix: undefined },
				method: 'POST',
			});

			await expect(waitingWebhooks.executeWebhook(mockReq, mock<express.Response>())).rejects.toBe(
				preCallbackError,
			);
		});

		it('should preserve inputOverride for nodes that have it', async () => {
			/**
			 * Arrange
			 */
			const executionId = 'test-execution-id';
			const lastNodeExecuted = 'TestNode';
			const inputOverrideData = [{ json: { test: 'data' } }] as any;

			const mockExecution = mock<IExecutionResponse>({
				id: executionId,
				status: 'waiting',
				finished: false,
				mode: 'manual',
				data: {
					executionData: {
						nodeExecutionStack: [
							{
								node: {
									name: lastNodeExecuted,
									type: 'n8n-nodes-base.wait',
									typeVersion: 1,
									parameters: {},
									id: 'node-id',
									position: [0, 0],
									disabled: false,
								},
								data: {},
								source: null,
							},
						],
					},
					resultData: {
						runData: {
							[lastNodeExecuted]: [
								{
									startTime: 123,
									executionTime: 456,
									executionIndex: 0,
									source: [{ previousNode: 'PreviousNode' }],
									inputOverride: inputOverrideData,
								},
							],
						},
						lastNodeExecuted,
					},
				},
				workflowData: {
					id: 'workflow-id',
					name: 'Test Workflow',
					nodes: [
						{
							name: lastNodeExecuted,
							type: 'n8n-nodes-base.wait',
							typeVersion: 1,
							parameters: {},
							id: 'node-id',
							position: [0, 0],
						},
					],
					connections: {},
					active: true,
					settings: {},
					staticData: {},
					isArchived: false,
					createdAt: new Date(),
					updatedAt: new Date(),
				},
			});

			// Explicitly set error to undefined to avoid mock function
			mockExecution.data.resultData.error = undefined;
			mockExecution.data.resumeToken = undefined;

			executionPersistence.findSingleExecution.mockResolvedValue(mockExecution);

			const mockReq = mock<WaitingWebhookRequest>({
				params: { path: executionId, suffix: undefined },
				method: 'POST',
			});
			const mockRes = mock<express.Response>();

			vi.spyOn(WebhookHelpers, 'executeWebhook').mockImplementation(
				async (
					_workflow,
					_webhookData,
					_workflowData,
					_workflowStartNode,
					_mode,
					_pushRef,
					_runExecutionData,
					_executionId,
					_req,
					_res,
					callback,
				) => {
					callback(null, { noWebhookResponse: true });
					return undefined;
				},
			);

			// Mock the webhookService to return a valid webhook
			mockWebhookService.getNodeWebhooks.mockReturnValue([
				{
					httpMethod: 'POST',
					path: '',
					webhookDescription: {
						restartWebhook: true,
						httpMethod: 'POST',
						name: 'default',
						path: '',
						nodeType: undefined,
					} as any,
				},
			] as any);

			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);

			/**
			 * Act
			 */
			await waitingWebhooks.executeWebhook(mockReq, mockRes);

			/**
			 * Assert
			 */
			const runDataArray = mockExecution.data.resultData.runData[lastNodeExecuted];

			// Should have one entry (the placeholder with inputOverride)
			expect(runDataArray).toHaveLength(1);
			expect(runDataArray[0].inputOverride).toEqual(inputOverrideData);
			expect(runDataArray[0].startTime).toBe(0);
			expect(runDataArray[0].executionTime).toBe(0);
			expect(runDataArray[0].source[0]).toMatchObject({ previousNode: 'PreviousNode' });
		});

		it('should handle HITL node with inputOverride', async () => {
			/**
			 * Arrange
			 */
			const executionId = 'test-execution-id';
			const lastNodeExecuted = 'HitlToolNode';
			const inputOverrideData = [{ json: { toolInput: 'test' } }] as any;

			const mockExecution = mock<IExecutionResponse>({
				id: executionId,
				status: 'waiting',
				finished: false,
				mode: 'manual',
				data: {
					executionData: {
						nodeExecutionStack: [
							{
								node: {
									name: lastNodeExecuted,
									type: '@n8n/n8n-nodes-langchain.someHitlTool',
									typeVersion: 1,
									parameters: {},
									id: 'node-id',
									position: [0, 0],
									disabled: false,
								},
								data: {},
								source: null,
							},
						],
					},
					resultData: {
						runData: {
							[lastNodeExecuted]: [
								{
									startTime: 123,
									executionTime: 456,
									executionIndex: 0,
									source: [{ previousNode: 'Agent' }],
									inputOverride: inputOverrideData,
								},
							],
						},
						lastNodeExecuted,
					},
				},
				workflowData: {
					id: 'workflow-id',
					name: 'Test Workflow',
					nodes: [
						{
							name: lastNodeExecuted,
							type: '@n8n/n8n-nodes-langchain.someHitlTool',
							typeVersion: 1,
							parameters: {},
							id: 'node-id',
							position: [0, 0],
						},
					],
					connections: {},
					active: true,
					settings: {},
					staticData: {},
					isArchived: false,
					createdAt: new Date(),
					updatedAt: new Date(),
				},
			});

			// Explicitly set error to undefined to avoid mock function
			mockExecution.data.resultData.error = undefined;
			mockExecution.data.resumeToken = undefined;

			executionPersistence.findSingleExecution.mockResolvedValue(mockExecution);

			const mockReq = mock<WaitingWebhookRequest>({
				params: { path: executionId, suffix: undefined },
				method: 'POST',
			});
			const mockRes = mock<express.Response>();

			vi.spyOn(WebhookHelpers, 'executeWebhook').mockImplementation(
				async (
					_workflow,
					_webhookData,
					_workflowData,
					_workflowStartNode,
					_mode,
					_pushRef,
					_runExecutionData,
					_executionId,
					_req,
					_res,
					callback,
				) => {
					callback(null, { noWebhookResponse: true });
					return undefined;
				},
			);

			mockWebhookService.getNodeWebhooks.mockReturnValue([
				{
					httpMethod: 'POST',
					path: '',
					webhookDescription: {
						restartWebhook: true,
						httpMethod: 'POST',
						name: 'default',
						path: '',
						nodeType: undefined,
					} as any,
				},
			] as any);

			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);

			/**
			 * Act
			 */
			await waitingWebhooks.executeWebhook(mockReq, mockRes);

			/**
			 * Assert
			 */
			const nodeExecutionStack = mockExecution.data.executionData!.nodeExecutionStack;
			const runDataArray = mockExecution.data.resultData.runData[lastNodeExecuted];

			// Should set rewireOutputLogTo for HITL node
			expect(nodeExecutionStack[0].node.rewireOutputLogTo).toBe('ai_tool');

			// Should preserve inputOverride
			expect(runDataArray).toHaveLength(1);
			expect(runDataArray[0].inputOverride).toEqual(inputOverrideData);
			expect(runDataArray[0].source[0]).toMatchObject({ previousNode: 'Agent' });
		});

		it('should not preserve inputOverride when it is not present', async () => {
			/**
			 * Arrange
			 */
			const executionId = 'test-execution-id';
			const lastNodeExecuted = 'TestNode';

			const mockExecution = mock<IExecutionResponse>({
				id: executionId,
				status: 'waiting',
				finished: false,
				mode: 'manual',
				data: {
					executionData: {
						nodeExecutionStack: [
							{
								node: {
									name: lastNodeExecuted,
									type: 'n8n-nodes-base.wait',
									typeVersion: 1,
									parameters: {},
									id: 'node-id',
									position: [0, 0],
									disabled: false,
								},
								data: {},
								source: null,
							},
						],
					},
					resultData: {
						runData: {
							[lastNodeExecuted]: [
								{
									startTime: 123,
									executionTime: 456,
									executionIndex: 0,
									source: [],
									inputOverride: undefined,
								},
							],
						},
						lastNodeExecuted,
					},
				},
				workflowData: {
					id: 'workflow-id',
					name: 'Test Workflow',
					nodes: [
						{
							name: lastNodeExecuted,
							type: 'n8n-nodes-base.wait',
							typeVersion: 1,
							parameters: {},
							id: 'node-id',
							position: [0, 0],
						},
					],
					connections: {},
					active: true,
					settings: {},
					staticData: {},
					isArchived: false,
					createdAt: new Date(),
					updatedAt: new Date(),
				},
			});

			// Explicitly set error to undefined to avoid mock function
			mockExecution.data.resultData.error = undefined;
			mockExecution.data.resumeToken = undefined;

			executionPersistence.findSingleExecution.mockResolvedValue(mockExecution);

			const mockReq = mock<WaitingWebhookRequest>({
				params: { path: executionId, suffix: undefined },
				method: 'POST',
			});
			const mockRes = mock<express.Response>();

			vi.spyOn(WebhookHelpers, 'executeWebhook').mockImplementation(
				async (
					_workflow,
					_webhookData,
					_workflowData,
					_workflowStartNode,
					_mode,
					_pushRef,
					_runExecutionData,
					_executionId,
					_req,
					_res,
					callback,
				) => {
					callback(null, { noWebhookResponse: true });
					return undefined;
				},
			);

			mockWebhookService.getNodeWebhooks.mockReturnValue([
				{
					httpMethod: 'POST',
					path: '',
					webhookDescription: {
						restartWebhook: true,
						httpMethod: 'POST',
						name: 'default',
						path: '',
						nodeType: undefined,
					} as any,
				},
			] as any);

			// Mock WorkflowExecuteAdditionalData
			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);

			/**
			 * Act
			 */
			await waitingWebhooks.executeWebhook(mockReq, mockRes);

			/**
			 * Assert
			 */
			const runDataArray = mockExecution.data.resultData.runData[lastNodeExecuted];

			// Should have empty array after popping and not adding placeholder
			expect(runDataArray).toHaveLength(0);
		});

		it('should not set rewireOutputLogTo for non-HITL nodes', async () => {
			/**
			 * Arrange
			 */
			const executionId = 'test-execution-id';
			const lastNodeExecuted = 'WaitNode';

			const mockExecution = mock<IExecutionResponse>({
				id: executionId,
				status: 'waiting',
				finished: false,
				mode: 'manual',
				data: {
					executionData: {
						nodeExecutionStack: [
							{
								node: {
									name: lastNodeExecuted,
									type: 'n8n-nodes-base.wait',
									typeVersion: 1,
									parameters: {},
									id: 'node-id',
									position: [0, 0],
									disabled: false,
									rewireOutputLogTo: undefined,
								},
								data: {},
								source: null,
							},
						],
					},
					resultData: {
						runData: {
							[lastNodeExecuted]: [
								{
									startTime: 123,
									executionTime: 456,
									executionIndex: 0,
									source: [],
								},
							],
						},
						lastNodeExecuted,
					},
				},
				workflowData: {
					id: 'workflow-id',
					name: 'Test Workflow',
					nodes: [
						{
							name: lastNodeExecuted,
							type: 'n8n-nodes-base.wait',
							typeVersion: 1,
							parameters: {},
							id: 'node-id',
							position: [0, 0],
						},
					],
					connections: {},
					active: true,
					settings: {},
					staticData: {},
					isArchived: false,
					createdAt: new Date(),
					updatedAt: new Date(),
				},
			});

			// Explicitly set error to undefined to avoid mock function
			mockExecution.data.resultData.error = undefined;
			mockExecution.data.resumeToken = undefined;

			executionPersistence.findSingleExecution.mockResolvedValue(mockExecution);

			const mockReq = mock<WaitingWebhookRequest>({
				params: { path: executionId, suffix: undefined },
				method: 'POST',
			});
			const mockRes = mock<express.Response>();

			// Mock the WebhookHelpers.executeWebhook
			vi.spyOn(WebhookHelpers, 'executeWebhook').mockImplementation(
				async (
					_workflow,
					_webhookData,
					_workflowData,
					_workflowStartNode,
					_mode,
					_pushRef,
					_runExecutionData,
					_executionId,
					_req,
					_res,
					callback,
				) => {
					callback(null, { noWebhookResponse: true });
					return undefined;
				},
			);

			// Mock the webhookService to return a valid webhook
			mockWebhookService.getNodeWebhooks.mockReturnValue([
				{
					httpMethod: 'POST',
					path: '',
					webhookDescription: {
						restartWebhook: true,
						httpMethod: 'POST',
						name: 'default',
						path: '',
						nodeType: undefined,
					} as any,
				},
			] as any);

			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);

			/**
			 * Act
			 */
			await waitingWebhooks.executeWebhook(mockReq, mockRes);

			/**
			 * Assert
			 */
			const nodeExecutionStack = mockExecution.data.executionData!.nodeExecutionStack;
			expect(nodeExecutionStack[0].node.rewireOutputLogTo).toBeUndefined();
		});
	});

	describe('execution-resumed event', () => {
		function createMockExecution(overrides: {
			nodeType: string;
			nodeName: string;
			nodeId: string;
			typeVersion?: number;
			nodeParameters?: Record<string, string>;
		}) {
			const { nodeType, nodeName, nodeId, typeVersion = 1, nodeParameters = {} } = overrides;
			return mock<IExecutionResponse>({
				id: 'test-execution-id',
				status: 'waiting',
				finished: false,
				mode: 'manual',
				data: {
					resumeToken: undefined,
					executionData: {
						nodeExecutionStack: [
							{
								node: {
									name: nodeName,
									type: nodeType,
									typeVersion,
									parameters: nodeParameters,
									id: nodeId,
									position: [0, 0],
									disabled: false,
								},
								data: {},
								source: null,
							},
						],
					},
					resultData: {
						error: undefined,
						runData: {
							[nodeName]: [{ startTime: 0, executionTime: 0, executionIndex: 0, source: [] }],
						},
						lastNodeExecuted: nodeName,
					},
				},
				workflowData: {
					id: 'workflow-id',
					name: 'Test Workflow',
					nodes: [
						{
							name: nodeName,
							type: nodeType,
							typeVersion,
							parameters: nodeParameters,
							id: nodeId,
							position: [0, 0],
						},
					],
					connections: {},
					active: false,
					activeVersionId: null,
					settings: {},
					staticData: {},
					isArchived: false,
					createdAt: new Date(),
					updatedAt: new Date(),
				},
			});
		}

		function createMockRes(): express.Response {
			return mock<express.Response>({
				status: vi.fn().mockReturnThis(),
				json: vi.fn(),
				render: vi.fn(),
			});
		}

		beforeEach(() => {
			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);
			executionPersistence.findSingleExecution.mockResolvedValue(
				createMockExecution({
					nodeType: 'n8n-nodes-base.wait',
					nodeName: 'WaitNode',
					nodeId: 'node-id',
				}),
			);
		});

		it('should emit when execution successfully resumes', async () => {
			mockWebhookService.getNodeWebhooks.mockReturnValue([
				{
					httpMethod: 'POST',
					path: '',
					webhookDescription: {
						restartWebhook: true,
						httpMethod: 'POST',
						name: 'default',
						path: '',
						nodeType: undefined,
					} as any,
				},
			] as any);
			vi.spyOn(WebhookHelpers, 'executeWebhook').mockImplementation(
				async (_w, _wd, _wfd, _wsn, _m, _pr, _red, _eid, _req, _res, callback) => {
					callback(null, { noWebhookResponse: true });
					return undefined;
				},
			);

			const mockReq = mock<WaitingWebhookRequest>({
				params: { path: 'test-execution-id', suffix: undefined },
				method: 'POST',
			});
			await waitingWebhooks.executeWebhook(mockReq, createMockRes());

			expect(mockEventService.emit).toHaveBeenCalledWith(
				'execution-resumed',
				expect.objectContaining({ executionId: 'test-execution-id', workflowId: 'workflow-id' }),
			);
		});

		it('should not emit when no matching webhook found', async () => {
			mockWebhookService.getNodeWebhooks.mockReturnValue([]);

			const mockReq = mock<WaitingWebhookRequest>({
				params: { path: 'test-execution-id', suffix: undefined },
				method: 'POST',
			});
			await expect(waitingWebhooks.executeWebhook(mockReq, createMockRes())).rejects.toThrowError(
				NotFoundError,
			);

			expect(mockEventService.emit).not.toHaveBeenCalledWith(
				'execution-resumed',
				expect.anything(),
			);
		});

		it('should not emit for send-and-wait already-responded requests', async () => {
			const sendAndWaitNodeId = 'send-and-wait-node-id';
			executionPersistence.findSingleExecution.mockResolvedValue(
				createMockExecution({
					nodeType: 'n8n-nodes-base.sendAndWait',
					nodeName: 'SendAndWaitNode',
					nodeId: sendAndWaitNodeId,
					nodeParameters: { operation: SEND_AND_WAIT_OPERATION },
				}),
			);
			mockWebhookService.getNodeWebhooks.mockReturnValue([]);

			const mockReq = mock<WaitingWebhookRequest>({
				params: { path: 'test-execution-id', suffix: sendAndWaitNodeId },
				method: 'POST',
			});
			const result = await waitingWebhooks.executeWebhook(mockReq, createMockRes());

			expect(result).toEqual({ noWebhookResponse: true });
			expect(mockEventService.emit).not.toHaveBeenCalledWith(
				'execution-resumed',
				expect.anything(),
			);
		});

		it('should not emit for form-resume execution (redirected, not resumed here)', async () => {
			executionPersistence.findSingleExecution.mockResolvedValue(
				createMockExecution({
					nodeType: 'n8n-nodes-base.wait',
					nodeName: 'WaitNode',
					nodeId: 'node-id',
					nodeParameters: { resume: 'form' },
				}),
			);

			const mockReq = mock<WaitingWebhookRequest>({
				params: { path: 'test-execution-id', suffix: undefined },
				method: 'GET',
				originalUrl: '/webhook-waiting/test-execution-id?signature=abc',
			});
			await waitingWebhooks.executeWebhook(mockReq, createMockRes());

			expect(mockEventService.emit).not.toHaveBeenCalledWith(
				'execution-resumed',
				expect.anything(),
			);
		});

		it('should not emit for active send-and-wait webhook call', async () => {
			const sendAndWaitNodeId = 'send-and-wait-node-id';
			executionPersistence.findSingleExecution.mockResolvedValue(
				createMockExecution({
					nodeType: 'n8n-nodes-base.emailSend',
					nodeName: 'SendAndWaitNode',
					nodeId: sendAndWaitNodeId,
					typeVersion: 2,
					nodeParameters: { operation: SEND_AND_WAIT_OPERATION },
				}),
			);
			// Webhook IS found — simulates an active send-and-wait response
			mockWebhookService.getNodeWebhooks.mockReturnValue([
				{
					httpMethod: 'POST',
					path: sendAndWaitNodeId,
					webhookDescription: {
						restartWebhook: true,
						httpMethod: 'POST',
						name: 'default',
						path: sendAndWaitNodeId,
						nodeType: undefined,
					} as any,
				},
			] as any);
			vi.spyOn(WebhookHelpers, 'executeWebhook').mockImplementation(
				async (_w, _wd, _wfd, _wsn, _m, _pr, _red, _eid, _req, _res, done) => {
					done(null, { noWebhookResponse: true });
					return undefined;
				},
			);

			const mockReq = mock<WaitingWebhookRequest>({
				params: { path: 'test-execution-id', suffix: sendAndWaitNodeId },
				method: 'POST',
			});
			await waitingWebhooks.executeWebhook(mockReq, createMockRes());

			expect(mockEventService.emit).not.toHaveBeenCalledWith(
				'execution-resumed',
				expect.anything(),
			);
		});
	});
});
