/**
 * Tests for subnode utilities
 */

import { addNodeWithSubnodes } from './subnode-utils';
import type { GraphNode, NodeInstance } from '../types/base';

// Helper to create a minimal node instance
function createNode(
	overrides: Partial<NodeInstance<string, string, unknown>> = {},
): NodeInstance<string, string, unknown> {
	return {
		type: overrides.type ?? 'n8n-nodes-base.set',
		version: 'v1',
		id: overrides.id ?? 'node-id',
		name: overrides.name ?? 'Test Node',
		config: overrides.config ?? {},
		update: () => ({}) as NodeInstance<string, string, unknown>,
		then: () => ({}) as never,
		to: () => ({}) as never,
		input: () => ({
			_isInputTarget: true,
			node: {} as NodeInstance<string, string, unknown>,
			inputIndex: 0,
		}),
		output: () => ({}) as never,
		onError: () => ({}) as never,
		getConnections: () => [],
	} as NodeInstance<string, string, unknown>;
}

// Helper to create a subnode (model, memory, tool, etc.)
function createSubnode(
	overrides: Partial<NodeInstance<string, string, unknown>> = {},
): NodeInstance<string, string, unknown> {
	return {
		...createNode(overrides),
		_subnodeType: 'model', // Required for subnode types
	} as unknown as NodeInstance<string, string, unknown>;
}

describe('addNodeWithSubnodes', () => {
	describe('validation', () => {
		it('returns undefined for null node', () => {
			const nodes = new Map<string, GraphNode>();

			const result = addNodeWithSubnodes(
				nodes,
				null as unknown as NodeInstance<string, string, unknown>,
			);

			expect(result).toBeUndefined();
		});

		it('returns undefined for node without type', () => {
			const nodes = new Map<string, GraphNode>();
			const node = { name: 'Test' } as NodeInstance<string, string, unknown>;

			const result = addNodeWithSubnodes(nodes, node);

			expect(result).toBeUndefined();
		});

		it('returns undefined for node without name', () => {
			const nodes = new Map<string, GraphNode>();
			const node = { type: 'test' } as NodeInstance<string, string, unknown>;

			const result = addNodeWithSubnodes(nodes, node);

			expect(result).toBeUndefined();
		});
	});

	describe('adding nodes', () => {
		it('adds node to the map and returns its name', () => {
			const nodes = new Map<string, GraphNode>();
			const node = createNode({ name: 'My Node' });

			const result = addNodeWithSubnodes(nodes, node);

			expect(result).toBe('My Node');
			expect(nodes.has('My Node')).toBe(true);
			expect(nodes.get('My Node')?.instance).toBe(node);
		});

		it('returns existing key if same instance already in map', () => {
			const node = createNode({ name: 'Original Name' });
			const nodes = new Map<string, GraphNode>();
			nodes.set('Different Key', {
				instance: node,
				connections: new Map(),
			});

			const result = addNodeWithSubnodes(nodes, node);

			expect(result).toBe('Different Key');
			expect(nodes.size).toBe(1); // No new node added
		});

		it('generates unique name for duplicate node names', () => {
			const nodes = new Map<string, GraphNode>();
			const existingNode = createNode({ id: 'existing', name: 'Process' });
			nodes.set('Process', {
				instance: existingNode,
				connections: new Map(),
			});

			const newNode = createNode({ id: 'new', name: 'Process' });
			const result = addNodeWithSubnodes(nodes, newNode);

			expect(result).toBe('Process 1');
			expect(nodes.size).toBe(2);
			expect(nodes.get('Process 1')?.instance).toBe(newNode);
		});

		it('initializes main connections map', () => {
			const nodes = new Map<string, GraphNode>();
			const node = createNode({ name: 'Test' });

			addNodeWithSubnodes(nodes, node);

			const graphNode = nodes.get('Test');
			expect(graphNode?.connections.has('main')).toBe(true);
		});
	});

	describe('subnodes', () => {
		it('adds model subnode with ai_languageModel connection', () => {
			const nodes = new Map<string, GraphNode>();
			const modelSubnode = createSubnode({ name: 'GPT Model', type: 'lmChatOpenAi' });
			const agentNode = createNode({
				name: 'Agent',
				type: 'agent',
				config: {
					subnodes: {
						model: modelSubnode,
					},
				} as unknown as NodeInstance<string, string, unknown>['config'],
			});

			addNodeWithSubnodes(nodes, agentNode);

			expect(nodes.has('GPT Model')).toBe(true);
			const modelGraphNode = nodes.get('GPT Model');
			const aiConns = modelGraphNode?.connections.get('ai_languageModel');
			expect(aiConns?.get(0)).toContainEqual({
				node: 'Agent',
				type: 'ai_languageModel',
				index: 0,
			});
		});

		it('adds memory subnode with ai_memory connection', () => {
			const nodes = new Map<string, GraphNode>();
			const memorySubnode = createSubnode({ name: 'Buffer Memory', type: 'memoryBufferWindow' });
			const agentNode = createNode({
				name: 'Agent',
				config: {
					subnodes: {
						memory: memorySubnode,
					},
				} as unknown as NodeInstance<string, string, unknown>['config'],
			});

			addNodeWithSubnodes(nodes, agentNode);

			expect(nodes.has('Buffer Memory')).toBe(true);
			const memoryGraphNode = nodes.get('Buffer Memory');
			const aiConns = memoryGraphNode?.connections.get('ai_memory');
			expect(aiConns?.get(0)).toContainEqual({
				node: 'Agent',
				type: 'ai_memory',
				index: 0,
			});
		});

		it('adds tool subnodes with ai_tool connections all at index 0', () => {
			const nodes = new Map<string, GraphNode>();
			const tool1 = createSubnode({ name: 'Calculator', type: 'toolCalculator' });
			const tool2 = createSubnode({ name: 'Wikipedia', type: 'toolWikipedia' });
			const tool3 = createSubnode({ name: 'Code', type: 'toolCode' });
			const agentNode = createNode({
				name: 'Agent',
				config: {
					subnodes: {
						tools: [tool1, tool2, tool3],
					},
				} as unknown as NodeInstance<string, string, unknown>['config'],
			});

			addNodeWithSubnodes(nodes, agentNode);

			expect(nodes.has('Calculator')).toBe(true);
			expect(nodes.has('Wikipedia')).toBe(true);
			expect(nodes.has('Code')).toBe(true);

			// All tools should target index 0 (single ai_tool input on the agent)
			const calcConns = nodes.get('Calculator')?.connections.get('ai_tool');
			expect(calcConns?.get(0)).toContainEqual({
				node: 'Agent',
				type: 'ai_tool',
				index: 0,
			});

			const wikiConns = nodes.get('Wikipedia')?.connections.get('ai_tool');
			expect(wikiConns?.get(0)).toContainEqual({
				node: 'Agent',
				type: 'ai_tool',
				index: 0,
			});

			const codeConns = nodes.get('Code')?.connections.get('ai_tool');
			expect(codeConns?.get(0)).toContainEqual({
				node: 'Agent',
				type: 'ai_tool',
				index: 0,
			});
		});

		it('adds vectorStore subnode with ai_vectorStore connection', () => {
			const nodes = new Map<string, GraphNode>();
			const vectorStore = createSubnode({ name: 'Pinecone', type: 'vectorStorePinecone' });
			const retrieverNode = createNode({
				name: 'Retriever',
				config: {
					subnodes: {
						vectorStore,
					},
				} as unknown as NodeInstance<string, string, unknown>['config'],
			});

			addNodeWithSubnodes(nodes, retrieverNode);

			expect(nodes.has('Pinecone')).toBe(true);
			const vsConns = nodes.get('Pinecone')?.connections.get('ai_vectorStore');
			expect(vsConns?.get(0)).toContainEqual({
				node: 'Retriever',
				type: 'ai_vectorStore',
				index: 0,
			});
		});

		it('handles embedding subnode (singular key)', () => {
			const nodes = new Map<string, GraphNode>();
			const embedding = createSubnode({ name: 'OpenAI Embeddings', type: 'embeddingsOpenAi' });
			const vectorStore = createNode({
				name: 'Vector Store',
				config: {
					subnodes: {
						embedding,
					},
				} as unknown as NodeInstance<string, string, unknown>['config'],
			});

			addNodeWithSubnodes(nodes, vectorStore);

			expect(nodes.has('OpenAI Embeddings')).toBe(true);
			const embConns = nodes.get('OpenAI Embeddings')?.connections.get('ai_embedding');
			expect(embConns?.get(0)).toContainEqual({
				node: 'Vector Store',
				type: 'ai_embedding',
				index: 0,
			});
		});

		it('adds multiple embedding subnodes all at index 0', () => {
			const nodes = new Map<string, GraphNode>();
			const emb1 = createSubnode({ name: 'Embeddings 1', type: 'embeddingsOpenAi' });
			const emb2 = createSubnode({ name: 'Embeddings 2', type: 'embeddingsOpenAi' });
			const vectorStore = createNode({
				name: 'Vector Store',
				config: {
					subnodes: {
						embedding: [emb1, emb2],
					},
				} as unknown as NodeInstance<string, string, unknown>['config'],
			});

			addNodeWithSubnodes(nodes, vectorStore);

			const emb1Conns = nodes.get('Embeddings 1')?.connections.get('ai_embedding');
			expect(emb1Conns?.get(0)).toContainEqual({
				node: 'Vector Store',
				type: 'ai_embedding',
				index: 0,
			});

			const emb2Conns = nodes.get('Embeddings 2')?.connections.get('ai_embedding');
			expect(emb2Conns?.get(0)).toContainEqual({
				node: 'Vector Store',
				type: 'ai_embedding',
				index: 0,
			});
		});

		it('adds multiple document loader subnodes all at index 0', () => {
			const nodes = new Map<string, GraphNode>();
			const doc1 = createSubnode({ name: 'Doc Loader 1', type: 'documentDefaultDataLoader' });
			const doc2 = createSubnode({ name: 'Doc Loader 2', type: 'documentDefaultDataLoader' });
			const vectorStore = createNode({
				name: 'Vector Store',
				config: {
					subnodes: {
						documentLoader: [doc1, doc2],
					},
				} as unknown as NodeInstance<string, string, unknown>['config'],
			});

			addNodeWithSubnodes(nodes, vectorStore);

			const doc1Conns = nodes.get('Doc Loader 1')?.connections.get('ai_document');
			expect(doc1Conns?.get(0)).toContainEqual({
				node: 'Vector Store',
				type: 'ai_document',
				index: 0,
			});

			const doc2Conns = nodes.get('Doc Loader 2')?.connections.get('ai_document');
			expect(doc2Conns?.get(0)).toContainEqual({
				node: 'Vector Store',
				type: 'ai_document',
				index: 0,
			});
		});

		it('keeps ai_languageModel array index for primary/fallback', () => {
			const nodes = new Map<string, GraphNode>();
			const model1 = createSubnode({ name: 'Primary Model', type: 'lmChatOpenAi' });
			const model2 = createSubnode({ name: 'Fallback Model', type: 'lmChatOpenAi' });
			const agentNode = createNode({
				name: 'Agent',
				config: {
					subnodes: {
						model: [model1, model2],
					},
				} as unknown as NodeInstance<string, string, unknown>['config'],
			});

			addNodeWithSubnodes(nodes, agentNode);

			const primaryConns = nodes.get('Primary Model')?.connections.get('ai_languageModel');
			expect(primaryConns?.get(0)).toContainEqual({
				node: 'Agent',
				type: 'ai_languageModel',
				index: 0,
			});

			const fallbackConns = nodes.get('Fallback Model')?.connections.get('ai_languageModel');
			expect(fallbackConns?.get(0)).toContainEqual({
				node: 'Agent',
				type: 'ai_languageModel',
				index: 1,
			});
		});

		it('handles embeddings subnode (plural key)', () => {
			const nodes = new Map<string, GraphNode>();
			const embedding = createSubnode({ name: 'OpenAI Embeddings', type: 'embeddingsOpenAi' });
			const vectorStore = createNode({
				name: 'Vector Store',
				config: {
					subnodes: {
						embeddings: embedding,
					},
				} as unknown as NodeInstance<string, string, unknown>['config'],
			});

			addNodeWithSubnodes(nodes, vectorStore);

			expect(nodes.has('OpenAI Embeddings')).toBe(true);
		});

		it('merges AI connection when subnode already exists', () => {
			const nodes = new Map<string, GraphNode>();
			const modelSubnode = createSubnode({ name: 'Shared Model', type: 'lmChatOpenAi' });

			// First agent adds the model
			const agent1 = createNode({
				name: 'Agent 1',
				config: { subnodes: { model: modelSubnode } } as unknown as NodeInstance<
					string,
					string,
					unknown
				>['config'],
			});
			addNodeWithSubnodes(nodes, agent1);

			// Second agent shares the same model
			const agent2 = createNode({
				name: 'Agent 2',
				config: { subnodes: { model: modelSubnode } } as unknown as NodeInstance<
					string,
					string,
					unknown
				>['config'],
			});
			addNodeWithSubnodes(nodes, agent2);

			// Model should have connections to both agents
			const modelConns = nodes.get('Shared Model')?.connections.get('ai_languageModel');
			const connections = modelConns?.get(0) ?? [];
			expect(connections).toContainEqual({ node: 'Agent 1', type: 'ai_languageModel', index: 0 });
			expect(connections).toContainEqual({ node: 'Agent 2', type: 'ai_languageModel', index: 0 });
		});
	});

	describe('duplicate parent names with shared subnodes', () => {
		it('processes subnodes for auto-renamed nodes', () => {
			const nodes = new Map<string, GraphNode>();
			const sharedModel = createSubnode({ name: 'Shared Model', type: 'lmChatOpenAi' });

			// Two agents with IDENTICAL names, sharing the same model
			const agent1 = createNode({
				name: 'Agent',
				config: { subnodes: { model: sharedModel } } as unknown as NodeInstance<
					string,
					string,
					unknown
				>['config'],
			});
			const agent2 = createNode({
				name: 'Agent',
				config: { subnodes: { model: sharedModel } } as unknown as NodeInstance<
					string,
					string,
					unknown
				>['config'],
			});

			const key1 = addNodeWithSubnodes(nodes, agent1);
			const key2 = addNodeWithSubnodes(nodes, agent2);

			// First agent keeps its name, second gets renamed
			expect(key1).toBe('Agent');
			expect(key2).toBe('Agent 1');

			// Shared model should exist and connect to BOTH agents using their map keys
			const modelConns = nodes.get('Shared Model')?.connections.get('ai_languageModel');
			const connections = modelConns?.get(0) ?? [];
			expect(connections).toContainEqual({
				node: 'Agent',
				type: 'ai_languageModel',
				index: 0,
			});
			expect(connections).toContainEqual({
				node: 'Agent 1',
				type: 'ai_languageModel',
				index: 0,
			});
		});
	});

	describe('plain object subnodes (no top-level .name)', () => {
		it('derives name from config.name for plain object subnodes', () => {
			const nodes = new Map<string, GraphNode>();
			// Plain object subnode without top-level .name, simulating inline subnode definition
			const plainDocLoader = {
				type: '@n8n/n8n-nodes-langchain.documentDefaultDataLoader',
				version: '1.1',
				config: {
					name: 'Load My Company Data',
					parameters: {},
				},
			} as unknown as NodeInstance<string, string, unknown>;

			const vectorStore = createNode({
				name: 'Store Data',
				type: 'vectorStorePinecone',
				config: {
					subnodes: {
						documentLoader: plainDocLoader,
					},
				} as unknown as NodeInstance<string, string, unknown>['config'],
			});

			addNodeWithSubnodes(nodes, vectorStore);

			// Should NOT have an undefined key in the map
			expect(nodes.has(undefined as unknown as string)).toBe(false);
			// Should use config.name as the key
			expect(nodes.has('Load My Company Data')).toBe(true);
			// Should have the correct AI connection
			const docConns = nodes.get('Load My Company Data')?.connections.get('ai_document');
			expect(docConns?.get(0)).toContainEqual({
				node: 'Store Data',
				type: 'ai_document',
				index: 0,
			});
		});

		it('derives name from config.name for nested plain object subnodes', () => {
			const nodes = new Map<string, GraphNode>();
			// Plain object embedding nested inside a tool subnode
			const plainEmbedding = {
				type: '@n8n/n8n-nodes-langchain.embeddingsOpenAi',
				version: '1.2',
				config: {
					name: 'OpenAI Embeddings',
					parameters: { model: 'text-embedding-3-small' },
				},
			} as unknown as NodeInstance<string, string, unknown>;

			const toolSubnode = createSubnode({
				name: 'RAG Tool',
				type: 'vectorStorePinecone',
				config: {
					subnodes: {
						embedding: plainEmbedding,
					},
				} as unknown as NodeInstance<string, string, unknown>['config'],
			});

			const agentNode = createNode({
				name: 'Agent',
				config: {
					subnodes: {
						tools: [toolSubnode],
					},
				} as unknown as NodeInstance<string, string, unknown>['config'],
			});

			addNodeWithSubnodes(nodes, agentNode);

			// Should NOT have an undefined key
			expect(nodes.has(undefined as unknown as string)).toBe(false);
			// Nested plain object embedding should use config.name
			expect(nodes.has('OpenAI Embeddings')).toBe(true);
		});
	});

	describe('nested subnodes', () => {
		it('processes nested subnodes recursively', () => {
			const nodes = new Map<string, GraphNode>();
			const embedding = createSubnode({ name: 'Embedding', type: 'embeddingsOpenAi' });
			const vectorStore = createSubnode({
				name: 'Vector Store',
				type: 'vectorStorePinecone',
				config: {
					subnodes: {
						embedding,
					},
				} as unknown as NodeInstance<string, string, unknown>['config'],
			});
			const retrieverNode = createNode({
				name: 'Retriever',
				config: {
					subnodes: {
						vectorStore,
					},
				} as unknown as NodeInstance<string, string, unknown>['config'],
			});

			addNodeWithSubnodes(nodes, retrieverNode);

			// All three nodes should be in the map
			expect(nodes.has('Retriever')).toBe(true);
			expect(nodes.has('Vector Store')).toBe(true);
			expect(nodes.has('Embedding')).toBe(true);

			// Vector Store -> Retriever connection
			const vsConns = nodes.get('Vector Store')?.connections.get('ai_vectorStore');
			expect(vsConns?.get(0)).toContainEqual({
				node: 'Retriever',
				type: 'ai_vectorStore',
				index: 0,
			});

			// Embedding -> Vector Store connection
			const embConns = nodes.get('Embedding')?.connections.get('ai_embedding');
			expect(embConns?.get(0)).toContainEqual({
				node: 'Vector Store',
				type: 'ai_embedding',
				index: 0,
			});
		});
	});
});
