import { mockDeep } from 'jest-mock-extended';
import type {
	IExecuteFunctions,
	ILoadOptionsFunctions,
	INodeTypeBaseDescription,
} from 'n8n-workflow';
import { NodeOperationError, ApplicationError } from 'n8n-workflow';

import { SwitchV3 } from '../SwitchV3.node';

describe('SwitchV3 Node', () => {
	let switchNode: SwitchV3;
	let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
	let mockLoadOptionsFunctions: jest.Mocked<ILoadOptionsFunctions>;

	const baseDescription: INodeTypeBaseDescription = {
		displayName: 'Switch',
		name: 'n8n-nodes-base.switch',
		group: ['transform'],
		description: 'Route items to different outputs',
	};

	beforeEach(() => {
		switchNode = new SwitchV3(baseDescription);
		mockExecuteFunctions = mockDeep<IExecuteFunctions>();
		mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
		jest.clearAllMocks();
	});

	describe('Version-specific behavior', () => {
		it('should have two numberOutputs parameters with different version conditions', () => {
			const switchNode = new SwitchV3(baseDescription);
			const numberOutputsParams = switchNode.description.properties.filter(
				(prop) => prop.name === 'numberOutputs',
			);

			expect(numberOutputsParams).toHaveLength(2);
		});

		it('should have noDataExpression: true for version 3.3+ numberOutputs parameter', () => {
			const switchNode = new SwitchV3(baseDescription);
			const numberOutputsParamWithNoExpression = switchNode.description.properties.find(
				(prop) => prop.name === 'numberOutputs' && prop.noDataExpression === true,
			);

			expect(numberOutputsParamWithNoExpression).toBeDefined();
			expect(numberOutputsParamWithNoExpression?.noDataExpression).toBe(true);
			expect(numberOutputsParamWithNoExpression?.displayOptions?.show?.['@version']).toEqual([
				{ _cnd: { gte: 3.3 } },
			]);
		});

		it('should have numberOutputs parameter without noDataExpression for older versions', () => {
			const switchNode = new SwitchV3(baseDescription);
			const numberOutputsParamWithoutNoExpression = switchNode.description.properties.find(
				(prop) => prop.name === 'numberOutputs' && !prop.noDataExpression,
			);

			expect(numberOutputsParamWithoutNoExpression).toBeDefined();
			expect(numberOutputsParamWithoutNoExpression?.noDataExpression).toBeUndefined();
			expect(numberOutputsParamWithoutNoExpression?.displayOptions?.show?.['@version']).toEqual([
				{ _cnd: { lt: 3.3 } },
			]);
		});

		it('should include version 3.3 in supported versions', () => {
			const switchNode = new SwitchV3(baseDescription);
			expect(switchNode.description.version).toContain(3.3);
		});

		it('should document SDK-discoverable fallback output metadata', () => {
			const switchNode = new SwitchV3(baseDescription);
			const sdkExample = switchNode.description.builderHint?.extraTypeDefContent?.[0].content ?? '';
			const optionsProperty = switchNode.description.properties.find(
				(prop) => prop.name === 'options',
			);
			const optionParameters = (optionsProperty?.options ?? []) as Array<{
				name: string;
				builderHint?: { propertyHint?: string };
			}>;
			const fallbackOutput = optionParameters.find((option) => option.name === 'fallbackOutput');
			const renameFallbackOutput = optionParameters.find(
				(option) => option.name === 'renameFallbackOutput',
			);

			expect(sdkExample).toContain("fallbackOutput: 'extra'");
			expect(sdkExample).toContain('.onCase(2, archive)');
			expect(sdkExample).not.toContain('.onDefault(');
			expect(sdkExample).not.toMatch(/\.onCase\(['"]/);
			expect(fallbackOutput?.builderHint?.propertyHint).toContain('rules.values.length');
			expect(fallbackOutput?.builderHint?.propertyHint).toContain('Numeric values');
			expect(renameFallbackOutput?.builderHint?.propertyHint).toContain("fallbackOutput: 'extra'");
		});
	});

	describe('Expression Mode Execution', () => {
		beforeEach(() => {
			mockExecuteFunctions.getNode.mockReturnValue({
				id: 'switch-node',
				name: 'Switch',
				type: 'n8n-nodes-base.switch',
				typeVersion: 3.3,
				position: [0, 0],
				parameters: {},
			});
		});

		it('should route items to correct output in expression mode', async () => {
			const inputData = [{ json: { value: 1 } }, { json: { value: 2 } }, { json: { value: 3 } }];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation(
				(paramName: string, itemIndex: number) => {
					const params: Record<string, any> = {
						mode: 'expression',
						numberOutputs: 3,
						output: itemIndex % 3,
					};
					return params[paramName];
				},
			);

			const result = await switchNode.execute.call(mockExecuteFunctions);

			expect(result).toHaveLength(3);
			expect(result[0]).toHaveLength(1);
			expect(result[0][0].json).toEqual({ value: 1 });
			expect(result[1]).toHaveLength(1);
			expect(result[1][0].json).toEqual({ value: 2 });
			expect(result[2]).toHaveLength(1);
			expect(result[2][0].json).toEqual({ value: 3 });
		});

		it('should handle multiple items routed to same output', async () => {
			const inputData = [{ json: { value: 1 } }, { json: { value: 2 } }, { json: { value: 3 } }];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
				const params: Record<string, any> = {
					mode: 'expression',
					numberOutputs: 2,
					output: 0,
				};
				return params[paramName];
			});

			const result = await switchNode.execute.call(mockExecuteFunctions);

			expect(result).toHaveLength(2);
			expect(result[0]).toHaveLength(3);
			expect(result[1]).toHaveLength(0);
		});

		it('should throw error for invalid output index', async () => {
			const inputData = [{ json: { value: 1 } }];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
				const params: Record<string, any> = {
					mode: 'expression',
					numberOutputs: 2,
					output: 5,
				};
				return params[paramName];
			});

			await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
				NodeOperationError,
			);
		});

		it('should throw error for negative output index', async () => {
			const inputData = [{ json: { value: 1 } }];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
				const params: Record<string, any> = {
					mode: 'expression',
					numberOutputs: 2,
					output: -1,
				};
				return params[paramName];
			});

			await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
				NodeOperationError,
			);
		});

		it('should handle empty input data', async () => {
			mockExecuteFunctions.getInputData.mockReturnValue([]);
			mockExecuteFunctions.getNodeParameter.mockReturnValue('expression');

			const result = await switchNode.execute.call(mockExecuteFunctions);

			expect(result).toEqual([[]]);
		});
	});

	describe('Rules Mode Execution', () => {
		beforeEach(() => {
			mockExecuteFunctions.getNode.mockReturnValue({
				id: 'switch-node',
				name: 'Switch',
				type: 'n8n-nodes-base.switch',
				typeVersion: 3.3,
				position: [0, 0],
				parameters: {},
			});
		});

		it('should route items based on matching rules', async () => {
			const inputData = [
				{ json: { status: 'active' } },
				{ json: { status: 'inactive' } },
				{ json: { status: 'pending' } },
			];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
				if (paramName === 'mode') return 'rules';
				if (paramName === 'rules.values') {
					return [
						{
							conditions: {
								conditions: [
									{
										leftValue: '={{$json.status}}',
										rightValue: 'active',
										operator: { type: 'string', operation: 'equals' },
									},
								],
								combinator: 'and',
							},
						},
						{
							conditions: {
								conditions: [
									{
										leftValue: '={{$json.status}}',
										rightValue: 'inactive',
										operator: { type: 'string', operation: 'equals' },
									},
								],
								combinator: 'and',
							},
						},
					];
				}
				if (paramName === 'options') return {};
				return false;
			});

			mockExecuteFunctions.getNodeParameter.mockImplementation(
				(paramName: string, itemIndex: number, defaultValue: any, options?: any) => {
					if (paramName === 'mode') return 'rules';
					if (paramName === 'rules.values') {
						return [
							{ conditions: { conditions: [], combinator: 'and' } },
							{ conditions: { conditions: [], combinator: 'and' } },
						];
					}
					if (paramName === 'options') return {};
					if (paramName.includes('conditions') && options?.extractValue) {
						if (itemIndex === 0) return true; // active matches first rule
						if (itemIndex === 1) return false; // inactive doesn't match first rule
						return false;
					}
					return defaultValue;
				},
			);

			const result = await switchNode.execute.call(mockExecuteFunctions);

			expect(result).toHaveLength(2);
			expect(result[0]).toHaveLength(1);
			expect(result[0][0].json).toEqual({ status: 'active' });
			expect(result[1]).toHaveLength(0);
		});

		it('should handle fallback output when no rules match', async () => {
			const inputData = [{ json: { status: 'unknown' } }];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation(
				(paramName: string, _itemIndex: number, defaultValue: any, options?: any) => {
					if (paramName === 'mode') return 'rules';
					if (paramName === 'rules.values') {
						return [{ conditions: { conditions: [], combinator: 'and' } }];
					}
					if (paramName === 'options') return { fallbackOutput: 'extra' };
					if (paramName.includes('conditions') && options?.extractValue) {
						return false; // No rule matches
					}
					return defaultValue;
				},
			);

			const result = await switchNode.execute.call(mockExecuteFunctions);

			expect(result).toHaveLength(2); // One rule output + one fallback output
			expect(result[0]).toHaveLength(0); // No matches for rule
			expect(result[1]).toHaveLength(1); // Item goes to fallback
			expect(result[1][0].json).toEqual({ status: 'unknown' });
		});

		it('should handle allMatchingOutputs option', async () => {
			const inputData = [{ json: { value: 10 } }];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation(
				(paramName: string, _itemIndex: number, defaultValue: any, options?: any) => {
					if (paramName === 'mode') return 'rules';
					if (paramName === 'rules.values') {
						return [
							{ conditions: { conditions: [], combinator: 'and' } },
							{ conditions: { conditions: [], combinator: 'and' } },
						];
					}
					if (paramName === 'options') return { allMatchingOutputs: true };
					if (paramName.includes('conditions') && options?.extractValue) {
						return true; // Both rules match
					}
					return defaultValue;
				},
			);

			const result = await switchNode.execute.call(mockExecuteFunctions);

			expect(result).toHaveLength(2);
			expect(result[0]).toHaveLength(1);
			expect(result[1]).toHaveLength(1);
			expect(result[0][0].json).toEqual({ value: 10 });
			expect(result[1][0].json).toEqual({ value: 10 });
		});

		it('should skip items when no rules are defined', async () => {
			const inputData = [{ json: { value: 1 } }];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
				if (paramName === 'mode') return 'rules';
				if (paramName === 'rules.values') return [];
				if (paramName === 'options') return {};
				return undefined;
			});

			const result = await switchNode.execute.call(mockExecuteFunctions);

			expect(result).toEqual([[]]);
		});
	});

	describe('Error Handling', () => {
		beforeEach(() => {
			mockExecuteFunctions.getNode.mockReturnValue({
				id: 'switch-node',
				name: 'Switch',
				type: 'n8n-nodes-base.switch',
				typeVersion: 3.3,
				position: [0, 0],
				parameters: {},
			});
		});

		it('should handle errors with continueOnFail', async () => {
			const inputData = [{ json: { value: 1 } }];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
				if (paramName === 'mode') return 'expression';
				if (paramName === 'numberOutputs') return 1;
				if (paramName === 'output') {
					throw new Error('Parameter error');
				}
				return undefined;
			});
			mockExecuteFunctions.continueOnFail.mockReturnValue(true);

			const result = await switchNode.execute.call(mockExecuteFunctions);

			expect(result[0][0].json).toHaveProperty('error', 'Parameter error');
		});

		it('should rethrow NodeOperationError', async () => {
			const inputData = [{ json: { value: 1 } }];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation(() => {
				throw new NodeOperationError(mockExecuteFunctions.getNode(), 'Invalid parameter');
			});

			await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
				NodeOperationError,
			);
		});

		it('should handle ApplicationError with context', async () => {
			const inputData = [{ json: { value: 1 } }];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation(() => {
				const error = new ApplicationError('Application error');
				throw error;
			});

			await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(ApplicationError);
		});

		it('should wrap generic errors in NodeOperationError', async () => {
			const inputData = [{ json: { value: 1 } }];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
				if (paramName === 'mode') return 'expression';
				if (paramName === 'numberOutputs') return 1;
				if (paramName === 'output') {
					throw new Error('Generic error');
				}
				return undefined;
			});

			await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
				NodeOperationError,
			);
		});
	});

	describe('Load Options', () => {
		it('should load fallback output options with no rules', async () => {
			mockLoadOptionsFunctions.getCurrentNodeParameter.mockReturnValue([]);

			const result =
				await switchNode.methods.loadOptions.getFallbackOutputOptions.call(
					mockLoadOptionsFunctions,
				);

			expect(result).toEqual([
				{
					// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
					name: 'None (default)',
					value: 'none',
					description: 'Items will be ignored',
				},
				{
					name: 'Extra Output',
					value: 'extra',
					description: 'Items will be sent to the extra, separate, output',
				},
			]);
		});

		it('should load fallback output options with rules', async () => {
			const rules = [
				{ outputKey: 'Rule 1' },
				{ outputKey: 'Rule 2' },
				{}, // Rule without outputKey
			];

			mockLoadOptionsFunctions.getCurrentNodeParameter.mockReturnValue(rules);

			const result =
				await switchNode.methods.loadOptions.getFallbackOutputOptions.call(
					mockLoadOptionsFunctions,
				);

			expect(result).toEqual([
				{
					// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
					name: 'None (default)',
					value: 'none',
					description: 'Items will be ignored',
				},
				{
					name: 'Extra Output',
					value: 'extra',
					description: 'Items will be sent to the extra, separate, output',
				},
				{
					name: 'Output Rule 1',
					value: 0,
					description: 'Items will be sent to the same output as when matched rule 1',
				},
				{
					name: 'Output Rule 2',
					value: 1,
					description: 'Items will be sent to the same output as when matched rule 2',
				},
				{
					name: 'Output 2',
					value: 2,
					description: 'Items will be sent to the same output as when matched rule 3',
				},
			]);
		});

		it('should handle null rules parameter', async () => {
			mockLoadOptionsFunctions.getCurrentNodeParameter.mockReturnValue(null);

			const result =
				await switchNode.methods.loadOptions.getFallbackOutputOptions.call(
					mockLoadOptionsFunctions,
				);

			expect(result).toEqual([
				{
					// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
					name: 'None (default)',
					value: 'none',
					description: 'Items will be ignored',
				},
				{
					name: 'Extra Output',
					value: 'extra',
					description: 'Items will be sent to the extra, separate, output',
				},
			]);
		});
	});

	describe('Edge Cases', () => {
		beforeEach(() => {
			mockExecuteFunctions.getNode.mockReturnValue({
				id: 'switch-node',
				name: 'Switch',
				type: 'n8n-nodes-base.switch',
				typeVersion: 3.3,
				position: [0, 0],
				parameters: {},
			});
		});

		it('should handle items with pairedItem already set', async () => {
			const inputData = [{ json: { value: 1 }, pairedItem: { item: 5 } }];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
				const params: Record<string, any> = {
					mode: 'expression',
					numberOutputs: 1,
					output: 0,
				};
				return params[paramName];
			});

			const result = await switchNode.execute.call(mockExecuteFunctions);

			expect(result[0][0].pairedItem).toEqual({ item: 0 });
		});

		it('should handle output index equal to returnData length', async () => {
			const inputData = [{ json: { value: 1 } }];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
				const params: Record<string, any> = {
					mode: 'expression',
					numberOutputs: 2,
					output: 2, // Equal to returnData length
				};
				return params[paramName];
			});

			await expect(switchNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
				NodeOperationError,
			);
		});

		it('should handle fallback output to existing rule output', async () => {
			const inputData = [{ json: { status: 'unknown' } }];

			mockExecuteFunctions.getInputData.mockReturnValue(inputData);
			mockExecuteFunctions.getNodeParameter.mockImplementation(
				(paramName: string, _itemIndex: number, defaultValue: any, options?: any) => {
					if (paramName === 'mode') return 'rules';
					if (paramName === 'rules.values') {
						return [{ conditions: { conditions: [], combinator: 'and' } }];
					}
					if (paramName === 'options') return { fallbackOutput: 0 };
					if (paramName.includes('conditions') && options?.extractValue) {
						return false; // No rule matches
					}
					return defaultValue;
				},
			);

			const result = await switchNode.execute.call(mockExecuteFunctions);

			expect(result).toHaveLength(1);
			expect(result[0]).toHaveLength(1);
			expect(result[0][0].json).toEqual({ status: 'unknown' });
		});
	});
});
