import { stringify } from 'flatted';
import { mock } from 'jest-mock-extended';
import {
	BinaryDataService,
	ErrorReporter,
	InstanceSettings,
	Logger,
	ExecutionLifecycleHooks,
	BinaryDataConfig,
} from 'n8n-core';
import { ExpressionError } from 'n8n-workflow';
import type {
	IRunExecutionData,
	ITaskData,
	Workflow,
	IDataObject,
	IRun,
	INode,
	IWorkflowBase,
	WorkflowExecuteMode,
	ITaskStartedData,
} from 'n8n-workflow';

import type { Project } from '@/databases/entities/project';
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks';
import { Push } from '@/push';
import { OwnershipService } from '@/services/ownership.service';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
import { mockInstance } from '@test/mocking';

import {
	getLifecycleHooksForSubExecutions,
	getLifecycleHooksForRegularMain,
	getLifecycleHooksForScalingWorker,
	getLifecycleHooksForScalingMain,
} from '../execution-lifecycle-hooks';

describe('Execution Lifecycle Hooks', () => {
	mockInstance(Logger);
	mockInstance(InstanceSettings);
	const errorReporter = mockInstance(ErrorReporter);
	const eventService = mockInstance(EventService);
	const executionRepository = mockInstance(ExecutionRepository);
	const externalHooks = mockInstance(ExternalHooks);
	const push = mockInstance(Push);
	const workflowStaticDataService = mockInstance(WorkflowStaticDataService);
	const workflowStatisticsService = mockInstance(WorkflowStatisticsService);
	const binaryDataService = mockInstance(BinaryDataService);
	const ownershipService = mockInstance(OwnershipService);
	const workflowExecutionService = mockInstance(WorkflowExecutionService);

	const nodeName = 'Test Node';
	const node = mock<INode>();
	const workflowId = 'test-workflow-id';
	const executionId = 'test-execution-id';
	const workflowData: IWorkflowBase = {
		id: workflowId,
		name: 'Test Workflow',
		active: true,
		connections: {},
		nodes: [],
		settings: {},
		createdAt: new Date(),
		updatedAt: new Date(),
	};
	const workflow = mock<Workflow>();
	const staticData = mock<IDataObject>();
	const taskStartedData = mock<ITaskStartedData>();
	const taskData = mock<ITaskData>();
	const runExecutionData = mock<IRunExecutionData>();
	const successfulRun = mock<IRun>({
		status: 'success',
		finished: true,
		waitTill: undefined,
	});
	const failedRun = mock<IRun>({
		status: 'error',
		finished: true,
		waitTill: undefined,
	});
	const waitingRun = mock<IRun>({
		finished: true,
		status: 'waiting',
		waitTill: new Date(),
	});
	const expressionError = new ExpressionError('Error');
	const pushRef = 'test-push-ref';
	const retryOf = 'test-retry-of';
	const userId = 'test-user-id';

	const now = new Date('2025-01-13T18:25:50.267Z');
	jest.useFakeTimers({ now });

	let lifecycleHooks: ExecutionLifecycleHooks;

	beforeEach(() => {
		jest.clearAllMocks();
		workflowData.settings = {};
		successfulRun.data = {
			resultData: {
				runData: {},
			},
		};
		failedRun.data = {
			resultData: {
				runData: {},
				error: expressionError,
			},
		};
	});

	const workflowEventTests = (expectedUserId?: string) => {
		describe('workflowExecuteBefore', () => {
			it('should emit workflow-pre-execute events', async () => {
				await lifecycleHooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]);

				expect(eventService.emit).toHaveBeenCalledWith('workflow-pre-execute', {
					executionId,
					data: workflowData,
				});
			});
		});

		describe('workflowExecuteAfter', () => {
			it('should emit workflow-post-execute events', async () => {
				await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]);

				expect(eventService.emit).toHaveBeenCalledWith('workflow-post-execute', {
					executionId,
					runData: successfulRun,
					workflow: workflowData,
					userId: expectedUserId,
				});
			});

			it('should not emit workflow-post-execute events for waiting executions', async () => {
				await lifecycleHooks.runHook('workflowExecuteAfter', [waitingRun, {}]);

				expect(eventService.emit).not.toHaveBeenCalledWith('workflow-post-execute');
			});
		});
	};

	const nodeEventsTests = () => {
		describe('nodeExecuteBefore', () => {
			it('should emit node-pre-execute event', async () => {
				await lifecycleHooks.runHook('nodeExecuteBefore', [nodeName, taskStartedData]);

				expect(eventService.emit).toHaveBeenCalledWith('node-pre-execute', {
					executionId,
					workflow: workflowData,
					nodeName,
				});
			});
		});

		describe('nodeExecuteAfter', () => {
			it('should emit node-post-execute event', async () => {
				await lifecycleHooks.runHook('nodeExecuteAfter', [nodeName, taskData, runExecutionData]);

				expect(eventService.emit).toHaveBeenCalledWith('node-post-execute', {
					executionId,
					workflow: workflowData,
					nodeName,
				});
			});
		});
	};

	const externalHooksTests = () => {
		describe('workflowExecuteBefore', () => {
			it('should run workflow.preExecute hook', async () => {
				await lifecycleHooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]);

				expect(externalHooks.run).toHaveBeenCalledWith('workflow.preExecute', [workflow, 'manual']);
			});
		});

		describe('workflowExecuteAfter', () => {
			it('should run workflow.postExecute hook', async () => {
				await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]);

				expect(externalHooks.run).toHaveBeenCalledWith('workflow.postExecute', [
					successfulRun,
					workflowData,
					executionId,
				]);
			});
		});
	};

	const statisticsTests = () => {
		describe('statistics events', () => {
			it('workflowExecuteAfter should emit workflowExecutionCompleted statistics event', async () => {
				await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]);

				expect(workflowStatisticsService.emit).toHaveBeenCalledWith('workflowExecutionCompleted', {
					workflowData,
					fullRunData: successfulRun,
				});
			});

			it('nodeFetchedData should handle nodeFetchedData statistics event', async () => {
				await lifecycleHooks.runHook('nodeFetchedData', [workflowId, node]);

				expect(workflowStatisticsService.emit).toHaveBeenCalledWith('nodeFetchedData', {
					workflowId,
					node,
				});
			});
		});
	};

	describe('getLifecycleHooksForRegularMain', () => {
		const createHooks = (executionMode: WorkflowExecuteMode = 'manual') =>
			getLifecycleHooksForRegularMain(
				{ executionMode, workflowData, pushRef, retryOf, userId },
				executionId,
			);

		beforeEach(() => {
			lifecycleHooks = createHooks();
		});

		workflowEventTests(userId);
		nodeEventsTests();
		externalHooksTests();
		statisticsTests();

		it('should setup the correct set of hooks', () => {
			expect(lifecycleHooks).toBeInstanceOf(ExecutionLifecycleHooks);
			expect(lifecycleHooks.mode).toBe('manual');
			expect(lifecycleHooks.executionId).toBe(executionId);
			expect(lifecycleHooks.workflowData).toEqual(workflowData);

			const { handlers } = lifecycleHooks;
			expect(handlers.nodeExecuteBefore).toHaveLength(2);
			expect(handlers.nodeExecuteAfter).toHaveLength(2);
			expect(handlers.workflowExecuteBefore).toHaveLength(3);
			expect(handlers.workflowExecuteAfter).toHaveLength(5);
			expect(handlers.nodeFetchedData).toHaveLength(1);
			expect(handlers.sendResponse).toHaveLength(0);
		});

		describe('nodeExecuteBefore', () => {
			it('should send nodeExecuteBefore push event', async () => {
				await lifecycleHooks.runHook('nodeExecuteBefore', [nodeName, taskStartedData]);

				expect(push.send).toHaveBeenCalledWith(
					{ type: 'nodeExecuteBefore', data: { executionId, nodeName, data: taskStartedData } },
					pushRef,
				);
			});
		});

		describe('nodeExecuteAfter', () => {
			it('should send nodeExecuteAfter push event', async () => {
				await lifecycleHooks.runHook('nodeExecuteAfter', [nodeName, taskData, runExecutionData]);

				expect(push.send).toHaveBeenCalledWith(
					{ type: 'nodeExecuteAfter', data: { executionId, nodeName, data: taskData } },
					pushRef,
				);
			});

			it('should save execution progress when enabled', async () => {
				workflowData.settings = { saveExecutionProgress: true };
				lifecycleHooks = createHooks();

				expect(lifecycleHooks.handlers.nodeExecuteAfter).toHaveLength(3);

				await lifecycleHooks.runHook('nodeExecuteAfter', [nodeName, taskData, runExecutionData]);

				expect(executionRepository.findSingleExecution).toHaveBeenCalledWith(executionId, {
					includeData: true,
					unflattenData: true,
				});
			});

			it('should not save execution progress when disabled', async () => {
				workflowData.settings = { saveExecutionProgress: false };
				lifecycleHooks = createHooks();

				expect(lifecycleHooks.handlers.nodeExecuteAfter).toHaveLength(2);

				await lifecycleHooks.runHook('nodeExecuteAfter', [nodeName, taskData, runExecutionData]);

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

		describe('workflowExecuteBefore', () => {
			it('should send executionStarted push event', async () => {
				await lifecycleHooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]);

				expect(push.send).toHaveBeenCalledWith(
					{
						type: 'executionStarted',
						data: {
							executionId,
							mode: 'manual',
							retryOf,
							workflowId: 'test-workflow-id',
							workflowName: 'Test Workflow',
							startedAt: now,
							flattedRunData: '[{}]',
						},
					},
					pushRef,
				);
			});

			it('should run workflow.preExecute external hook', async () => {
				await lifecycleHooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]);

				expect(externalHooks.run).toHaveBeenCalledWith('workflow.preExecute', [workflow, 'manual']);
			});
		});

		describe('workflowExecuteAfter', () => {
			it('should send executionFinished push event', async () => {
				await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]);
				expect(push.send).toHaveBeenCalledWith(
					{
						type: 'executionFinished',
						data: {
							executionId,
							rawData: stringify(successfulRun.data),
							status: 'success',
							workflowId: 'test-workflow-id',
						},
					},
					pushRef,
				);
			});

			it('should send executionWaiting push event', async () => {
				await lifecycleHooks.runHook('workflowExecuteAfter', [waitingRun, {}]);

				expect(push.send).toHaveBeenCalledWith(
					{
						type: 'executionWaiting',
						data: { executionId },
					},
					pushRef,
				);
			});

			describe('saving static data', () => {
				it('should skip saving static data for manual executions', async () => {
					await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, staticData]);

					expect(workflowStaticDataService.saveStaticDataById).not.toHaveBeenCalled();
				});

				it('should save static data for prod executions', async () => {
					lifecycleHooks = createHooks('trigger');

					await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, staticData]);

					expect(workflowStaticDataService.saveStaticDataById).toHaveBeenCalledWith(
						workflowId,
						staticData,
					);
				});

				it('should handle static data saving errors', async () => {
					lifecycleHooks = createHooks('trigger');

					const error = new Error('Static data save failed');
					workflowStaticDataService.saveStaticDataById.mockRejectedValueOnce(error);

					await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, staticData]);

					expect(errorReporter.error).toHaveBeenCalledWith(error);
				});
			});

			describe('saving execution data', () => {
				it('should update execution with proper data', async () => {
					await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]);

					expect(executionRepository.updateExistingExecution).toHaveBeenCalledWith(
						executionId,
						expect.objectContaining({
							finished: true,
							status: 'success',
						}),
					);
				});

				it('should not delete unfinished executions', async () => {
					const unfinishedRun = mock<IRun>({ finished: false, status: 'running' });

					await lifecycleHooks.runHook('workflowExecuteAfter', [unfinishedRun, {}]);

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

				it('should not delete waiting executions', async () => {
					await lifecycleHooks.runHook('workflowExecuteAfter', [waitingRun, {}]);

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

				it('should soft delete manual executions when manual saving is disabled', async () => {
					lifecycleHooks.workflowData.settings = { saveManualExecutions: false };
					lifecycleHooks = createHooks();

					await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]);

					expect(executionRepository.softDelete).toHaveBeenCalledWith(executionId);
				});

				it('should not soft delete manual executions with waitTill', async () => {
					lifecycleHooks.workflowData.settings = { saveManualExecutions: false };
					lifecycleHooks = createHooks();

					await lifecycleHooks.runHook('workflowExecuteAfter', [waitingRun, {}]);

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

			describe('error workflow', () => {
				it('should not execute error workflow for manual executions', async () => {
					await lifecycleHooks.runHook('workflowExecuteAfter', [failedRun, {}]);

					expect(workflowExecutionService.executeErrorWorkflow).not.toHaveBeenCalled();
				});

				it('should execute error workflow for failed non-manual executions', async () => {
					lifecycleHooks = createHooks('trigger');

					const errorWorkflow = 'error-workflow-id';
					workflowData.settings = { errorWorkflow };
					const project = mock<Project>();
					ownershipService.getWorkflowProjectCached
						.calledWith(workflowId)
						.mockResolvedValue(project);

					await lifecycleHooks.runHook('workflowExecuteAfter', [failedRun, {}]);

					expect(workflowExecutionService.executeErrorWorkflow).toHaveBeenCalledWith(
						errorWorkflow,
						{
							workflow: {
								id: workflowId,
								name: workflowData.name,
							},
							execution: {
								id: executionId,
								error: expressionError,
								mode: 'trigger',
								retryOf,
								lastNodeExecuted: undefined,
								url: `http://localhost:5678/workflow/${workflowId}/executions/${executionId}`,
							},
						},
						project,
					);
				});
			});

			it('should restore binary data IDs after workflow execution for webhooks', async () => {
				mockInstance(BinaryDataConfig, { mode: 'filesystem' });
				lifecycleHooks = createHooks('webhook');

				(successfulRun.data.resultData.runData = {
					[nodeName]: [
						{
							startTime: 1,
							executionIndex: 0,
							executionTime: 1,
							source: [],
							data: {
								main: [
									[
										{
											json: {},
											binary: {
												data: {
													id: `filesystem-v2:workflows/${workflowId}/executions/temp/binary_data/123`,
													data: '',
													mimeType: 'text/plain',
												},
											},
										},
									],
								],
							},
						},
					],
				}),
					await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]);

				expect(binaryDataService.rename).toHaveBeenCalledWith(
					'workflows/test-workflow-id/executions/temp/binary_data/123',
					'workflows/test-workflow-id/executions/test-execution-id/binary_data/123',
				);
			});
		});

		describe("when pushRef isn't set", () => {
			beforeEach(() => {
				lifecycleHooks = getLifecycleHooksForRegularMain(
					{ executionMode: 'manual', workflowData, retryOf },
					executionId,
				);
			});

			it('should not setup any push hooks', async () => {
				const { handlers } = lifecycleHooks;
				expect(handlers.nodeExecuteBefore).toHaveLength(1);
				expect(handlers.nodeExecuteAfter).toHaveLength(1);
				expect(handlers.workflowExecuteBefore).toHaveLength(2);
				expect(handlers.workflowExecuteAfter).toHaveLength(4);

				await lifecycleHooks.runHook('nodeExecuteBefore', [nodeName, taskStartedData]);
				await lifecycleHooks.runHook('nodeExecuteAfter', [nodeName, taskData, runExecutionData]);
				await lifecycleHooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]);
				await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]);

				expect(push.send).not.toHaveBeenCalled();
			});
		});
	});

	describe('getLifecycleHooksForScalingMain', () => {
		beforeEach(() => {
			lifecycleHooks = getLifecycleHooksForScalingMain(
				{
					executionMode: 'manual',
					workflowData,
					pushRef,
					retryOf,
					userId,
				},
				executionId,
			);
		});

		workflowEventTests(userId);
		externalHooksTests();

		it('should setup the correct set of hooks', () => {
			expect(lifecycleHooks).toBeInstanceOf(ExecutionLifecycleHooks);
			expect(lifecycleHooks.mode).toBe('manual');
			expect(lifecycleHooks.executionId).toBe(executionId);
			expect(lifecycleHooks.workflowData).toEqual(workflowData);

			const { handlers } = lifecycleHooks;
			expect(handlers.nodeExecuteBefore).toHaveLength(0);
			expect(handlers.nodeExecuteAfter).toHaveLength(0);
			expect(handlers.workflowExecuteBefore).toHaveLength(2);
			expect(handlers.workflowExecuteAfter).toHaveLength(4);
			expect(handlers.nodeFetchedData).toHaveLength(0);
			expect(handlers.sendResponse).toHaveLength(0);
		});

		describe('workflowExecuteBefore', () => {
			it('should run the workflow.preExecute external hook', async () => {
				await lifecycleHooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]);

				expect(externalHooks.run).toHaveBeenCalledWith('workflow.preExecute', [workflow, 'manual']);
			});
		});

		describe('workflowExecuteAfter', () => {
			it('should delete successful executions when success saving is disabled', async () => {
				workflowData.settings = {
					saveDataSuccessExecution: 'none',
					saveDataErrorExecution: 'all',
				};
				const lifecycleHooks = getLifecycleHooksForScalingMain(
					{
						executionMode: 'webhook',
						workflowData,
						pushRef,
						retryOf,
					},
					executionId,
				);

				await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]);

				expect(executionRepository.hardDelete).toHaveBeenCalledWith({
					workflowId,
					executionId,
				});
			});

			it('should delete failed executions when error saving is disabled', async () => {
				workflowData.settings = {
					saveDataSuccessExecution: 'all',
					saveDataErrorExecution: 'none',
				};
				const lifecycleHooks = getLifecycleHooksForScalingMain(
					{
						executionMode: 'webhook',
						workflowData,
						pushRef,
						retryOf,
					},
					executionId,
				);

				await lifecycleHooks.runHook('workflowExecuteAfter', [failedRun, {}]);

				expect(executionRepository.hardDelete).toHaveBeenCalledWith({
					workflowId,
					executionId,
				});
			});
		});
	});

	describe('getLifecycleHooksForScalingWorker', () => {
		const createHooks = (executionMode: WorkflowExecuteMode = 'manual') =>
			getLifecycleHooksForScalingWorker(
				{ executionMode, workflowData, pushRef, retryOf },
				executionId,
			);

		beforeEach(() => {
			lifecycleHooks = createHooks();
		});

		nodeEventsTests();
		externalHooksTests();
		statisticsTests();

		it('should setup the correct set of hooks', () => {
			expect(lifecycleHooks).toBeInstanceOf(ExecutionLifecycleHooks);
			expect(lifecycleHooks.mode).toBe('manual');
			expect(lifecycleHooks.executionId).toBe(executionId);
			expect(lifecycleHooks.workflowData).toEqual(workflowData);

			const { handlers } = lifecycleHooks;
			expect(handlers.nodeExecuteBefore).toHaveLength(2);
			expect(handlers.nodeExecuteAfter).toHaveLength(2);
			expect(handlers.workflowExecuteBefore).toHaveLength(2);
			expect(handlers.workflowExecuteAfter).toHaveLength(4);
			expect(handlers.nodeFetchedData).toHaveLength(1);
			expect(handlers.sendResponse).toHaveLength(0);
		});

		describe('saving static data', () => {
			it('should skip saving static data for manual executions', async () => {
				await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, staticData]);

				expect(workflowStaticDataService.saveStaticDataById).not.toHaveBeenCalled();
			});

			it('should save static data for prod executions', async () => {
				lifecycleHooks = createHooks('trigger');

				await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, staticData]);

				expect(workflowStaticDataService.saveStaticDataById).toHaveBeenCalledWith(
					workflowId,
					staticData,
				);
			});

			it('should handle static data saving errors', async () => {
				lifecycleHooks = createHooks('trigger');
				const error = new Error('Static data save failed');
				workflowStaticDataService.saveStaticDataById.mockRejectedValueOnce(error);

				await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, staticData]);

				expect(errorReporter.error).toHaveBeenCalledWith(error);
			});
		});

		describe('error workflow', () => {
			it('should not execute error workflow for manual executions', async () => {
				await lifecycleHooks.runHook('workflowExecuteAfter', [failedRun, {}]);

				expect(workflowExecutionService.executeErrorWorkflow).not.toHaveBeenCalled();
			});

			it('should execute error workflow for failed non-manual executions', async () => {
				lifecycleHooks = createHooks('trigger');
				const errorWorkflow = 'error-workflow-id';
				workflowData.settings = { errorWorkflow };
				const project = mock<Project>();
				ownershipService.getWorkflowProjectCached.calledWith(workflowId).mockResolvedValue(project);

				await lifecycleHooks.runHook('workflowExecuteAfter', [failedRun, {}]);

				expect(workflowExecutionService.executeErrorWorkflow).toHaveBeenCalledWith(
					errorWorkflow,
					{
						workflow: {
							id: workflowId,
							name: workflowData.name,
						},
						execution: {
							id: executionId,
							error: expressionError,
							mode: 'trigger',
							retryOf,
							lastNodeExecuted: undefined,
							url: `http://localhost:5678/workflow/${workflowId}/executions/${executionId}`,
						},
					},
					project,
				);
			});
		});
	});

	describe('getLifecycleHooksForSubExecutions', () => {
		beforeEach(() => {
			lifecycleHooks = getLifecycleHooksForSubExecutions(
				'manual',
				executionId,
				workflowData,
				undefined,
			);
		});

		workflowEventTests();
		nodeEventsTests();
		externalHooksTests();
		statisticsTests();

		it('should setup the correct set of hooks', () => {
			expect(lifecycleHooks).toBeInstanceOf(ExecutionLifecycleHooks);
			expect(lifecycleHooks.mode).toBe('manual');
			expect(lifecycleHooks.executionId).toBe(executionId);
			expect(lifecycleHooks.workflowData).toEqual(workflowData);

			const { handlers } = lifecycleHooks;
			expect(handlers.nodeExecuteBefore).toHaveLength(1);
			expect(handlers.nodeExecuteAfter).toHaveLength(1);
			expect(handlers.workflowExecuteBefore).toHaveLength(2);
			expect(handlers.workflowExecuteAfter).toHaveLength(4);
			expect(handlers.nodeFetchedData).toHaveLength(1);
			expect(handlers.sendResponse).toHaveLength(0);
		});
	});
});
