import merge from 'lodash/merge';
import {
	type IExecuteFunctions,
	type IDataObject,
	type INodeExecutionData,
	type INodeType,
	type INodeTypeBaseDescription,
	type INodeTypeDescription,
	type IPairedItemData,
	NodeConnectionTypes,
} from 'n8n-workflow';

import { preparePairedItemDataArray } from '@utils/utilities';

import { optionsDescription } from './descriptions';
import type {
	ClashResolveOptions,
	MatchFieldsJoinMode,
	MatchFieldsOptions,
	MatchFieldsOutput,
} from './interfaces';
import {
	addSourceField,
	addSuffixToEntriesKeys,
	checkInput,
	checkMatchFieldsInput,
	findMatches,
	mergeMatched,
	selectMergeMethod,
} from './utils';

export class MergeV2 implements INodeType {
	description: INodeTypeDescription;

	constructor(baseDescription: INodeTypeBaseDescription) {
		this.description = {
			...baseDescription,
			version: [2, 2.1],
			defaults: {
				name: 'Merge',
			},

			inputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
			outputs: [NodeConnectionTypes.Main],
			inputNames: ['Input 1', 'Input 2'],
			// If mode is chooseBranch data from both branches is required
			// to continue, else data from any input suffices
			requiredInputs: '={{ $parameter["mode"] === "chooseBranch" ? [0, 1] : 1 }}',
			properties: [
				{
					displayName: 'Mode',
					name: 'mode',
					type: 'options',
					options: [
						{
							name: 'Append',
							value: 'append',
							description: 'All items of input 1, then all items of input 2',
						},
						{
							name: 'Combine',
							value: 'combine',
							description: 'Merge matching items together',
						},
						{
							name: 'Choose Branch',
							value: 'chooseBranch',
							description: 'Output input data, without modifying it',
						},
					],
					default: 'append',
					description: 'How data of branches should be merged',
				},
				{
					displayName: 'Combination Mode',
					name: 'combinationMode',
					type: 'options',
					options: [
						{
							name: 'Merge By Fields',
							value: 'mergeByFields',
							description: 'Combine items with the same field values',
						},
						{
							name: 'Merge By Position',
							value: 'mergeByPosition',
							description: 'Combine items based on their order',
						},
						{
							name: 'Multiplex',
							value: 'multiplex',
							description: 'All possible item combinations (cross join)',
						},
					],
					default: 'mergeByFields',
					displayOptions: {
						show: {
							mode: ['combine'],
						},
					},
				},
				// mergeByFields ------------------------------------------------------------------
				{
					displayName: 'Fields to Match',
					name: 'mergeByFields',
					type: 'fixedCollection',
					placeholder: 'Add Fields to Match',
					default: { values: [{ field1: '', field2: '' }] },
					typeOptions: {
						multipleValues: true,
					},
					options: [
						{
							displayName: 'Values',
							name: 'values',
							values: [
								{
									displayName: 'Input 1 Field',
									name: 'field1',
									type: 'string',
									default: '',
									// eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id
									placeholder: 'e.g. id',
									hint: ' Enter the field name as text',
									requiresDataPath: 'single',
								},
								{
									displayName: 'Input 2 Field',
									name: 'field2',
									type: 'string',
									default: '',
									// eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id
									placeholder: 'e.g. id',
									hint: ' Enter the field name as text',
									requiresDataPath: 'single',
								},
							],
						},
					],
					displayOptions: {
						show: {
							mode: ['combine'],
							combinationMode: ['mergeByFields'],
						},
					},
				},
				{
					displayName: 'Output Type',
					name: 'joinMode',
					type: 'options',
					// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
					options: [
						{
							name: 'Keep Matches',
							value: 'keepMatches',
							description: 'Items that match, merged together (inner join)',
						},
						{
							name: 'Keep Non-Matches',
							value: 'keepNonMatches',
							description: "Items that don't match",
						},
						{
							name: 'Keep Everything',
							value: 'keepEverything',
							description:
								"Items that match merged together, plus items that don't match (outer join)",
						},
						{
							name: 'Enrich Input 1',
							value: 'enrichInput1',
							description: 'All of input 1, with data from input 2 added in (left join)',
						},
						{
							name: 'Enrich Input 2',
							value: 'enrichInput2',
							description: 'All of input 2, with data from input 1 added in (right join)',
						},
					],
					default: 'keepMatches',
					displayOptions: {
						show: {
							mode: ['combine'],
							combinationMode: ['mergeByFields'],
						},
					},
				},
				{
					displayName: 'Output Data From',
					name: 'outputDataFrom',
					type: 'options',
					options: [
						{
							name: 'Both Inputs Merged Together',
							value: 'both',
						},
						{
							name: 'Input 1',
							value: 'input1',
						},
						{
							name: 'Input 2',
							value: 'input2',
						},
					],
					default: 'both',
					displayOptions: {
						show: {
							mode: ['combine'],
							combinationMode: ['mergeByFields'],
							joinMode: ['keepMatches'],
						},
					},
				},
				{
					displayName: 'Output Data From',
					name: 'outputDataFrom',
					type: 'options',
					options: [
						{
							name: 'Both Inputs Appended Together',
							value: 'both',
						},
						{
							name: 'Input 1',
							value: 'input1',
						},
						{
							name: 'Input 2',
							value: 'input2',
						},
					],
					default: 'both',
					displayOptions: {
						show: {
							mode: ['combine'],
							combinationMode: ['mergeByFields'],
							joinMode: ['keepNonMatches'],
						},
					},
				},

				// chooseBranch -----------------------------------------------------------------
				{
					displayName: 'Output Type',
					name: 'chooseBranchMode',
					type: 'options',
					options: [
						{
							name: 'Wait for Both Inputs to Arrive',
							value: 'waitForBoth',
						},
					],
					default: 'waitForBoth',
					displayOptions: {
						show: {
							mode: ['chooseBranch'],
						},
					},
				},
				{
					displayName: 'Output',
					name: 'output',
					type: 'options',
					options: [
						{
							name: 'Input 1 Data',
							value: 'input1',
						},
						{
							name: 'Input 2 Data',
							value: 'input2',
						},
						{
							name: 'A Single, Empty Item',
							value: 'empty',
						},
					],
					default: 'input1',
					displayOptions: {
						show: {
							mode: ['chooseBranch'],
							chooseBranchMode: ['waitForBoth'],
						},
					},
				},

				...optionsDescription,
			],
		};
	}

	async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
		let returnData: INodeExecutionData[] = [];

		const mode = this.getNodeParameter('mode', 0) as string;

		if (mode === 'append') {
			for (let i = 0; i < 2; i++) {
				returnData.push.apply(returnData, this.getInputData(i));
			}
		}

		if (mode === 'combine') {
			const combinationMode = this.getNodeParameter('combinationMode', 0) as string;

			if (combinationMode === 'multiplex') {
				const clashHandling = this.getNodeParameter(
					'options.clashHandling.values',
					0,
					{},
				) as ClashResolveOptions;

				let input1 = this.getInputData(0);
				let input2 = this.getInputData(1);

				if (clashHandling.resolveClash === 'preferInput1') {
					[input1, input2] = [input2, input1];
				}

				if (clashHandling.resolveClash === 'addSuffix') {
					input1 = addSuffixToEntriesKeys(input1, '1');
					input2 = addSuffixToEntriesKeys(input2, '2');
				}

				const mergeIntoSingleObject = selectMergeMethod(clashHandling);

				if (!input1 || !input2) {
					return [returnData];
				}

				let entry1: INodeExecutionData;
				let entry2: INodeExecutionData;

				for (entry1 of input1) {
					for (entry2 of input2) {
						returnData.push({
							json: {
								...mergeIntoSingleObject(entry1.json, entry2.json),
							},
							binary: {
								...merge({}, entry1.binary, entry2.binary),
							},
							pairedItem: [
								entry1.pairedItem as IPairedItemData,
								entry2.pairedItem as IPairedItemData,
							],
						});
					}
				}
				return [returnData];
			}

			if (combinationMode === 'mergeByPosition') {
				const clashHandling = this.getNodeParameter(
					'options.clashHandling.values',
					0,
					{},
				) as ClashResolveOptions;
				const includeUnpaired = this.getNodeParameter(
					'options.includeUnpaired',
					0,
					false,
				) as boolean;

				let input1 = this.getInputData(0);
				let input2 = this.getInputData(1);

				if (input1?.length === 0 || input2?.length === 0) {
					// If data of any input is missing, return the data of
					// the input that contains data
					return [[...input1, ...input2]];
				}

				if (clashHandling.resolveClash === 'preferInput1') {
					[input1, input2] = [input2, input1];
				}

				if (clashHandling.resolveClash === 'addSuffix') {
					input1 = addSuffixToEntriesKeys(input1, '1');
					input2 = addSuffixToEntriesKeys(input2, '2');
				}

				if (input1 === undefined || input1.length === 0) {
					if (includeUnpaired) {
						return [input2];
					}
					return [returnData];
				}

				if (input2 === undefined || input2.length === 0) {
					if (includeUnpaired) {
						return [input1];
					}
					return [returnData];
				}

				let numEntries: number;
				if (includeUnpaired) {
					numEntries = Math.max(input1.length, input2.length);
				} else {
					numEntries = Math.min(input1.length, input2.length);
				}

				const mergeIntoSingleObject = selectMergeMethod(clashHandling);

				for (let i = 0; i < numEntries; i++) {
					if (i >= input1.length) {
						returnData.push(input2[i]);
						continue;
					}
					if (i >= input2.length) {
						returnData.push(input1[i]);
						continue;
					}

					const entry1 = input1[i];
					const entry2 = input2[i];

					returnData.push({
						json: {
							...mergeIntoSingleObject(entry1.json, entry2.json),
						},
						binary: {
							...merge({}, entry1.binary, entry2.binary),
						},
						pairedItem: [
							entry1.pairedItem as IPairedItemData,
							entry2.pairedItem as IPairedItemData,
						],
					});
				}
			}

			if (combinationMode === 'mergeByFields') {
				const matchFields = checkMatchFieldsInput(
					this.getNodeParameter('mergeByFields.values', 0, []) as IDataObject[],
				);

				const joinMode = this.getNodeParameter('joinMode', 0) as MatchFieldsJoinMode;
				const outputDataFrom = this.getNodeParameter(
					'outputDataFrom',
					0,
					'both',
				) as MatchFieldsOutput;
				const options = this.getNodeParameter('options', 0, {}) as MatchFieldsOptions;

				options.joinMode = joinMode;
				options.outputDataFrom = outputDataFrom;

				const nodeVersion = this.getNode().typeVersion;

				let input1 = this.getInputData(0);
				let input2 = this.getInputData(1);

				if (nodeVersion < 2.1) {
					input1 = checkInput(
						this.getInputData(0),
						matchFields.map((pair) => pair.field1),
						options.disableDotNotation || false,
						'Input 1',
					);
					if (!input1) return [returnData];

					input2 = checkInput(
						this.getInputData(1),
						matchFields.map((pair) => pair.field2),
						options.disableDotNotation || false,
						'Input 2',
					);
				} else {
					if (!input1) return [returnData];
				}

				if (input1?.length === 0 || input2?.length === 0) {
					if (!input1?.length && joinMode === 'keepNonMatches' && outputDataFrom === 'input1')
						return [returnData];
					if (!input2?.length && joinMode === 'keepNonMatches' && outputDataFrom === 'input2')
						return [returnData];

					if (joinMode === 'keepMatches') {
						// Stop the execution
						return [[]];
					} else if (joinMode === 'enrichInput1' && input1?.length === 0) {
						// No data to enrich so stop
						return [[]];
					} else if (joinMode === 'enrichInput2' && input2?.length === 0) {
						// No data to enrich so stop
						return [[]];
					} else {
						// Return the data of any of the inputs that contains data
						return [[...input1, ...input2]];
					}
				}

				if (!input1) return [returnData];

				if (!input2 || !matchFields.length) {
					if (
						joinMode === 'keepMatches' ||
						joinMode === 'keepEverything' ||
						joinMode === 'enrichInput2'
					) {
						return [returnData];
					}
					return [input1];
				}

				const matches = findMatches(input1, input2, matchFields, options);

				if (joinMode === 'keepMatches' || joinMode === 'keepEverything') {
					let output: INodeExecutionData[] = [];
					const clashResolveOptions = this.getNodeParameter(
						'options.clashHandling.values',
						0,
						{},
					) as ClashResolveOptions;

					if (outputDataFrom === 'input1') {
						output = matches.matched.map((match) => match.entry);
					}
					if (outputDataFrom === 'input2') {
						output = matches.matched2;
					}
					if (outputDataFrom === 'both') {
						output = mergeMatched(matches.matched, clashResolveOptions);
					}

					if (joinMode === 'keepEverything') {
						let unmatched1 = matches.unmatched1;
						let unmatched2 = matches.unmatched2;
						if (clashResolveOptions.resolveClash === 'addSuffix') {
							unmatched1 = addSuffixToEntriesKeys(unmatched1, '1');
							unmatched2 = addSuffixToEntriesKeys(unmatched2, '2');
						}
						output = [...output, ...unmatched1, ...unmatched2];
					}

					returnData = returnData.concat(output);
				}

				if (joinMode === 'keepNonMatches') {
					if (outputDataFrom === 'input1') {
						return [matches.unmatched1];
					}
					if (outputDataFrom === 'input2') {
						return [matches.unmatched2];
					}
					if (outputDataFrom === 'both') {
						let output: INodeExecutionData[] = [];
						output = output.concat(addSourceField(matches.unmatched1, 'input1'));
						output = output.concat(addSourceField(matches.unmatched2, 'input2'));
						return [output];
					}
				}

				if (joinMode === 'enrichInput1' || joinMode === 'enrichInput2') {
					const clashResolveOptions = this.getNodeParameter(
						'options.clashHandling.values',
						0,
						{},
					) as ClashResolveOptions;

					const mergedEntries = mergeMatched(matches.matched, clashResolveOptions, joinMode);

					if (joinMode === 'enrichInput1') {
						if (clashResolveOptions.resolveClash === 'addSuffix') {
							returnData = returnData.concat(
								mergedEntries,
								addSuffixToEntriesKeys(matches.unmatched1, '1'),
							);
						} else {
							returnData = returnData.concat(mergedEntries, matches.unmatched1);
						}
					} else {
						if (clashResolveOptions.resolveClash === 'addSuffix') {
							returnData = returnData.concat(
								mergedEntries,
								addSuffixToEntriesKeys(matches.unmatched2, '2'),
							);
						} else {
							returnData = returnData.concat(mergedEntries, matches.unmatched2);
						}
					}
				}
			}
		}

		if (mode === 'chooseBranch') {
			const chooseBranchMode = this.getNodeParameter('chooseBranchMode', 0) as string;

			if (chooseBranchMode === 'waitForBoth') {
				const output = this.getNodeParameter('output', 0) as string;

				if (output === 'input1') {
					returnData.push.apply(returnData, this.getInputData(0));
				}
				if (output === 'input2') {
					returnData.push.apply(returnData, this.getInputData(1));
				}
				if (output === 'empty') {
					const pairedItem = [
						...this.getInputData(0).map((inputData) => inputData.pairedItem),
						...this.getInputData(1).map((inputData) => inputData.pairedItem),
					].flatMap(preparePairedItemDataArray);

					returnData.push({
						json: {},
						pairedItem,
					});
				}
			}
		}

		return [returnData];
	}
}
