import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { DynamicTool } from '@langchain/core/tools';
import {
	CredentialsRepository,
	SharedCredentialsRepository,
	type CredentialsEntity,
	type SharedCredentials,
} from '@n8n/db';
import { StructuredToolkit } from 'n8n-core';
import {
	Expression,
	NodeConnectionTypes,
	type IExecuteFunctions,
	type INodeCredentialsDetails,
	type INodeType,
	type INodeTypeDescription,
	type ISupplyDataFunctions,
	type IWorkflowExecuteAdditionalData,
} from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';

import { NodeTypes } from '@/node-types';

import {
	AGENT_PROVIDER_NODE_WHITELIST,
	AGENT_TOOL_NODE_DENYLIST,
	EphemeralNodeExecutor,
	isAgentProviderNode,
	isUsableAsAgentTool,
} from '../ephemeral-node-executor';

// Assign overrides onto an empty mock instead of passing them to mock():
// mock(overrides) deep-wraps nested objects in proxies and mutates shared
// fixtures (e.g. `toolDescription`) in place, stacking a proxy layer per call.
// Assigning also sidesteps DeepPartial narrowing of nested objects like `defaults`.
const mockNodeType = (overrides: object = {}) => Object.assign(mock<INodeType>(), overrides);

const mockGetBase = vi.fn();

vi.mock('@/workflow-execute-additional-data', () => ({
	getBase: (...args: unknown[]) => mockGetBase(...args),
}));

describe('isUsableAsAgentTool', () => {
	it('accepts a standard node with usableAsTool: true', () => {
		expect(isUsableAsAgentTool({ usableAsTool: true, outputs: ['main'] })).toBe(true);
	});

	it('accepts a native tool node that outputs AiTool (string form)', () => {
		expect(isUsableAsAgentTool({ outputs: [NodeConnectionTypes.AiTool] })).toBe(true);
	});

	it('accepts a native tool node that outputs AiTool (object form)', () => {
		expect(isUsableAsAgentTool({ outputs: [{ type: NodeConnectionTypes.AiTool }] })).toBe(true);
	});

	it('rejects a non-tool node with only main outputs and no flag', () => {
		expect(isUsableAsAgentTool({ outputs: ['main'] })).toBe(false);
	});

	it('rejects a description without outputs or flag', () => {
		expect(isUsableAsAgentTool({})).toBe(false);
	});

	it('ignores outputs that are not an array (e.g. expression form)', () => {
		expect(isUsableAsAgentTool({ outputs: '={{$json.out}}' })).toBe(false);
	});
});

describe('isAgentProviderNode', () => {
	it('accepts whitelisted provider nodes (OpenAI, Anthropic, etc.)', () => {
		expect(isAgentProviderNode('@n8n/n8n-nodes-langchain.openAi')).toBe(true);
		expect(isAgentProviderNode('@n8n/n8n-nodes-langchain.anthropic')).toBe(true);
		expect(isAgentProviderNode('@n8n/n8n-nodes-langchain.googleGemini')).toBe(true);
	});

	it('rejects non-provider langchain nodes (lm chat models, agents, summarization)', () => {
		expect(isAgentProviderNode('@n8n/n8n-nodes-langchain.lmChatOpenAi')).toBe(false);
		expect(isAgentProviderNode('@n8n/n8n-nodes-langchain.agent')).toBe(false);
		expect(isAgentProviderNode('@n8n/n8n-nodes-langchain.chainSummarization')).toBe(false);
	});

	it('rejects unrelated nodes', () => {
		expect(isAgentProviderNode('n8n-nodes-base.httpRequest')).toBe(false);
		expect(isAgentProviderNode('')).toBe(false);
	});

	it('exposes the whitelist as a stable Set', () => {
		expect(AGENT_PROVIDER_NODE_WHITELIST).toBeInstanceOf(Set);
		expect(AGENT_PROVIDER_NODE_WHITELIST.has('@n8n/n8n-nodes-langchain.openAi')).toBe(true);
	});
});

describe('EphemeralNodeExecutor', () => {
	const nodeTypes = mockInstance(NodeTypes);
	const credentialsRepository = mockInstance(CredentialsRepository);
	const sharedCredentialsRepository = mockInstance(SharedCredentialsRepository);
	const logger = mockInstance(Logger);
	// Node execution constructs `SSHClientsManager` via DI, whose constructor does
	// `logger.scoped(...)` and registers a `process.on('exit')` shutdown handler. Without a real
	// return here, `scoped()` is undefined and the handler throws at worker teardown — an uncaught
	// exception that fails the run even though every test passes.
	logger.scoped.mockReturnValue(logger);

	const executor = new EphemeralNodeExecutor(
		nodeTypes,
		credentialsRepository,
		sharedCredentialsRepository,
		logger,
	);

	const toolDescription: INodeTypeDescription = {
		displayName: 'Tool',
		name: 'testTool',
		group: ['output'],
		version: 1,
		description: 'Test tool',
		defaults: { name: 'Test' },
		inputs: [],
		outputs: [NodeConnectionTypes.AiTool],
		properties: [],
		usableAsTool: true,
	};

	beforeEach(() => {
		vi.clearAllMocks();
		mockGetBase.mockResolvedValue({});
	});

	describe('validateNodeForExecution (via executeInline)', () => {
		it('returns a structured error when the node is not usable as a tool', async () => {
			const nonToolDescription: INodeTypeDescription = {
				...toolDescription,
				usableAsTool: undefined,
				outputs: ['main'],
			};
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mockNodeType({ description: nonToolDescription }),
			);

			const result = await executor.executeInline({
				nodeType: 'n8n-nodes-base.notATool',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [],
				projectId: 'p-1',
			});

			expect(result.status).toBe('error');
			expect(result.error).toContain('Node is not usable as a tool');
			expect(result.error).toContain('n8n-nodes-base.notATool');
			expect(result.data).toEqual([]);
		});

		it('returns a structured error when the node is a trigger', async () => {
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mockNodeType({ description: { ...toolDescription, group: ['trigger'] } }),
			);

			const result = await executor.executeInline({
				nodeType: 'n8n-nodes-base.triggerNode',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [],
				projectId: 'p-1',
			});

			expect(result.status).toBe('error');
			expect(result.error).toContain('Trigger nodes cannot be executed standalone');
		});

		it('admits a whitelisted provider node even without `usableAsTool`', async () => {
			const providerDescription: INodeTypeDescription = {
				...toolDescription,
				usableAsTool: undefined,
				outputs: ['main'],
			};
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mockNodeType({ description: providerDescription }),
			);

			const result = await executor.executeInline({
				nodeType: '@n8n/n8n-nodes-langchain.openAi',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [],
				projectId: 'p-1',
			});

			expect(result.error).not.toMatch(/not usable as a tool/);
		});

		it.each([...AGENT_TOOL_NODE_DENYLIST])(
			'rejects the denylisted node type "%s" even when it looks like a usable tool',
			async (nodeType) => {
				nodeTypes.getByNameAndVersion.mockReturnValue(
					mockNodeType({ description: toolDescription }),
				);

				const result = await executor.executeInline({
					nodeType,
					nodeTypeVersion: 1,
					nodeParameters: {},
					inputData: [],
					projectId: 'p-1',
				});

				expect(result.status).toBe('error');
				expect(result.error).toContain('not permitted for agent tool execution');
				// The denylist check must run before the node-types lookup for the
				// base type, otherwise resolveToolNodeType's `*Tool` remapping in
				// the caller could dodge it via a type that doesn't exist yet.
				expect(nodeTypes.getByNameAndVersion).not.toHaveBeenCalled();
			},
		);

		it.each([...AGENT_TOOL_NODE_DENYLIST])(
			'rejects the `*Tool`-suffixed variant of the denylisted node type "%s"',
			async (nodeType) => {
				const result = await executor.executeInline({
					nodeType: `${nodeType}Tool`,
					nodeTypeVersion: 1,
					nodeParameters: {},
					inputData: [],
					projectId: 'p-1',
				});

				expect(result.status).toBe('error');
				expect(result.error).toContain('not permitted for agent tool execution');
			},
		);

		it('still executes a normal tool node that is not on the denylist', async () => {
			const execute = vi.fn().mockResolvedValue([[{ json: { ok: true } }]]);
			nodeTypes.getByNameAndVersion.mockReturnValue({
				description: toolDescription,
				execute,
			} as unknown as INodeType);

			const result = await executor.executeInline({
				nodeType: 'n8n-nodes-base.slack',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [],
				projectId: 'p-1',
			});

			expect(result.status).toBe('success');
		});

		it.each(['sendAndWait', 'dispatchAndWait'])(
			'returns a structured error when operation %s is unsupported',
			async (operation) => {
				nodeTypes.getByNameAndVersion.mockReturnValue(
					mockNodeType({ description: toolDescription }),
				);

				const result = await executor.executeInline({
					nodeType: 'n8n-nodes-base.slack',
					nodeTypeVersion: 1,
					nodeParameters: { operation },
					inputData: [],
					projectId: 'p-1',
				});

				expect(result.status).toBe('error');
				expect(result.error).toMatch(/not supported for agent tool execution/);
			},
		);
	});

	describe('resolveInlineCredentials (via executeInline)', () => {
		function mockToolNodeWithSupplyData() {
			// Short-circuit past the execute path — we only want to assert credential resolution.
			const supplyData = vi.fn().mockResolvedValue({
				response: { invoke: vi.fn().mockResolvedValue('ok') },
			});
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mockNodeType({
					description: toolDescription,
					supplyData,
				}),
			);
			return supplyData;
		}

		it('throws when no matching credential is found for the project', async () => {
			mockToolNodeWithSupplyData();
			credentialsRepository.findAllCredentialsForProject.mockResolvedValue([]);

			await expect(
				executor.executeInline({
					nodeType: 'n8n-nodes-base.slack',
					nodeTypeVersion: 1,
					nodeParameters: {},
					credentials: { slackApi: 'Prod Slack' },
					inputData: [],
					projectId: 'p-1',
				}),
			).rejects.toThrow(/No accessible credential found/);
		});

		it('throws when multiple credentials match the same type + name (case-insensitive)', async () => {
			mockToolNodeWithSupplyData();
			credentialsRepository.findAllCredentialsForProject.mockResolvedValue([
				mock<CredentialsEntity>({ id: 'c1', name: 'Prod Slack', type: 'slackApi' }),
				mock<CredentialsEntity>({ id: 'c2', name: 'prod slack', type: 'slackApi' }),
			]);

			await expect(
				executor.executeInline({
					nodeType: 'n8n-nodes-base.slack',
					nodeTypeVersion: 1,
					nodeParameters: {},
					credentials: { slackApi: 'Prod Slack' },
					inputData: [],
					projectId: 'p-1',
				}),
			).rejects.toThrow(/Multiple credentials match/);
		});

		it('skips resolution when no credentials are requested', async () => {
			mockToolNodeWithSupplyData();

			await executor.executeInline({
				nodeType: 'n8n-nodes-base.slack',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [],
				projectId: 'p-1',
			});

			expect(credentialsRepository.findAllCredentialsForProject).not.toHaveBeenCalled();
		});
	});

	describe('verifyCredentialDetailsForProject (via executeInline)', () => {
		function mockToolNodeWithSupplyData() {
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mockNodeType({
					description: toolDescription,
					supplyData: vi.fn().mockResolvedValue({
						response: { invoke: vi.fn().mockResolvedValue('ok') },
					}),
				}),
			);
		}

		it('throws when credentialDetails are missing an id', async () => {
			mockToolNodeWithSupplyData();

			await expect(
				executor.executeInline({
					nodeType: 'n8n-nodes-base.slack',
					nodeTypeVersion: 1,
					nodeParameters: {},
					// Intentionally missing `id` to exercise the validation path.
					credentialDetails: { slackApi: { name: 'Prod Slack' } as INodeCredentialsDetails },
					inputData: [],
					projectId: 'p-1',
				}),
			).rejects.toThrow(/missing an id/);
		});

		it('throws when the credential is not shared with the project', async () => {
			mockToolNodeWithSupplyData();
			sharedCredentialsRepository.findOne.mockResolvedValue(null);

			await expect(
				executor.executeInline({
					nodeType: 'n8n-nodes-base.slack',
					nodeTypeVersion: 1,
					nodeParameters: {},
					credentialDetails: { slackApi: { id: 'c1', name: 'Prod Slack' } },
					inputData: [],
					projectId: 'p-1',
				}),
			).rejects.toThrow(/not accessible or does not exist/);
		});

		it('throws when the resolved credential has a different type than the slot', async () => {
			mockToolNodeWithSupplyData();
			sharedCredentialsRepository.findOne.mockResolvedValue(
				mock<SharedCredentials>({
					credentials: mock<CredentialsEntity>({
						id: 'c1',
						name: 'Prod Slack',
						type: 'gmailOAuth2', // wrong type for slackApi slot
					}),
				}),
			);

			await expect(
				executor.executeInline({
					nodeType: 'n8n-nodes-base.slack',
					nodeTypeVersion: 1,
					nodeParameters: {},
					credentialDetails: { slackApi: { id: 'c1', name: 'Prod Slack' } },
					inputData: [],
					projectId: 'p-1',
				}),
			).rejects.toThrow(/has type .* but the node expects credential slot/);
		});

		it('passes an n8n Connect managed credential through without a project lookup', async () => {
			mockToolNodeWithSupplyData();

			const result = await executor.executeInline({
				nodeType: 'n8n-nodes-base.slack',
				nodeTypeVersion: 1,
				nodeParameters: {},
				credentialDetails: {
					slackApi: { id: null, name: 'n8n credits', __aiGatewayManaged: true },
				},
				inputData: [],
				projectId: 'p-1',
			});

			expect(result.status).toBe('success');
			// Managed credentials are minted per execution (CredentialsHelper.getDecrypted),
			// so there is no stored row to resolve — the project lookup must be skipped.
			expect(sharedCredentialsRepository.findOne).not.toHaveBeenCalled();
		});
	});

	describe('executeInline routing', () => {
		it('invokes the LangChain tool when the node implements supplyData', async () => {
			const invoke = vi.fn().mockResolvedValue('wiki-result');
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mockNodeType({
					description: toolDescription,
					supplyData: vi.fn().mockResolvedValue({ response: { invoke } }),
				}),
			);

			const result = await executor.executeInline({
				nodeType: '@n8n/n8n-nodes-langchain.toolWikipedia',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [{ json: { query: 'n8n' } }],
				projectId: 'p-1',
			});

			expect(invoke).toHaveBeenCalledWith({ query: 'n8n' });
			expect(result).toEqual({
				status: 'success',
				data: [{ json: { response: 'wiki-result' } }],
			});
		});

		it('returns an error result when the supplyData tool invocation throws', async () => {
			const invoke = vi.fn().mockRejectedValue(new Error('upstream 500'));
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mockNodeType({
					description: toolDescription,
					supplyData: vi.fn().mockResolvedValue({ response: { invoke } }),
				}),
			);

			const result = await executor.executeInline({
				nodeType: '@n8n/n8n-nodes-langchain.toolWikipedia',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [{ json: {} }],
				projectId: 'p-1',
			});

			expect(result.status).toBe('error');
			expect(result.error).toBe('upstream 500');
		});

		it('returns an error result when the node does not expose a valid LangChain tool', async () => {
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mockNodeType({
					description: toolDescription,
					supplyData: vi.fn().mockResolvedValue({ response: undefined }),
				}),
			);

			const result = await executor.executeInline({
				nodeType: '@n8n/n8n-nodes-langchain.brokenTool',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [],
				projectId: 'p-1',
			});

			expect(result.status).toBe('error');
			expect(result.error).toMatch(/did not return a valid LangChain tool/);
		});

		it('returns an error result when a direct-execute node has no execute method', async () => {
			// Plain object (not mock<>) — vitest-mock-extended auto-proxies *every*
			// property access as a callable, which would make the supplyData
			// routing check match and send us down the wrong path.
			nodeTypes.getByNameAndVersion.mockReturnValue({
				description: toolDescription,
			} as unknown as INodeType);

			const result = await executor.executeInline({
				nodeType: 'n8n-nodes-base.slack',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [],
				projectId: 'p-1',
			});

			expect(result.status).toBe('error');
			expect(result.error).toMatch(/does not have an execute method/);
		});

		it('surfaces a clear error when supplyData returns a StructuredToolkit (multi-tool dispatch unsupported)', async () => {
			// MCP client nodes legitimately return a toolkit wrapping N tools;
			// the ephemeral runtime treats one ref as one invocable target, so
			// this path must fail with an explicit message rather than silently
			// invoke a non-existent `.invoke` on the toolkit.
			const toolkit = new StructuredToolkit([
				mock<StructuredToolkit['tools'][number]>({ name: 'list-docs' }),
				mock<StructuredToolkit['tools'][number]>({ name: 'read-doc' }),
			]);
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mockNodeType({
					description: toolDescription,
					supplyData: vi.fn().mockResolvedValue({ response: toolkit }),
				}),
			);

			const result = await executor.executeInline({
				nodeType: '@n8n/n8n-nodes-langchain.mcpClientTool',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [{ json: {} }],
				projectId: 'p-1',
			});

			expect(result.status).toBe('error');
			expect(result.error).toMatch(/StructuredToolkit.*multi-tool dispatch/);
		});
	});

	describe('executeInline → executeNodeDirectly (nodes without supplyData)', () => {
		// Use plain objects so `typeof nodeType.supplyData === 'function'`
		// reliably returns false — vitest-mock-extended auto-proxies every
		// property as callable, which would route us to the supplyData path.

		it('passes the project ID to data table helpers', async () => {
			const getDataTableProxy = vi.fn().mockResolvedValue({});
			mockGetBase.mockResolvedValue({
				'data-table': { dataTableProxyProvider: { getDataTableProxy } },
			});
			const execute = vi.fn(async function (this: IExecuteFunctions) {
				await this.helpers.getDataTableProxy?.('table-id');
				return [[{ json: { ok: true } }]];
			});
			nodeTypes.getByNameAndVersion.mockReturnValue({
				description: toolDescription,
				execute,
			} as unknown as INodeType);

			await executor.executeInline({
				nodeType: 'n8n-nodes-base.dataTableTool',
				nodeTypeVersion: 1.1,
				nodeParameters: {},
				inputData: [{ json: {} }],
				projectId: 'p-1',
			});

			expect(getDataTableProxy).toHaveBeenCalledWith(
				expect.anything(),
				expect.anything(),
				'table-id',
				'p-1',
			);
		});

		it('does not add data table project context for other node types', async () => {
			const additionalData = {};
			mockGetBase.mockResolvedValue(additionalData);
			nodeTypes.getByNameAndVersion.mockReturnValue({
				description: toolDescription,
				execute: vi.fn().mockResolvedValue([[{ json: { ok: true } }]]),
			} as unknown as INodeType);

			await executor.executeInline({
				nodeType: 'n8n-nodes-base.slack',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [{ json: {} }],
				projectId: 'p-1',
			});

			expect(additionalData).not.toHaveProperty('dataTableProjectId');
		});

		it('runs nodeType.execute and returns its first output batch on success', async () => {
			const execute = vi.fn().mockResolvedValue([[{ json: { ok: true, count: 3 } }]]);
			nodeTypes.getByNameAndVersion.mockReturnValue({
				description: toolDescription,
				execute,
			} as unknown as INodeType);

			const result = await executor.executeInline({
				nodeType: 'n8n-nodes-base.slack',
				nodeTypeVersion: 1,
				nodeParameters: { channel: '#general', text: 'hi' },
				inputData: [{ json: { userId: 'u-1' } }],
				projectId: 'p-1',
			});

			expect(execute).toHaveBeenCalledTimes(1);
			expect(result).toEqual({ status: 'success', data: [{ json: { ok: true, count: 3 } }] });
		});

		it('returns an error result when nodeType.execute throws', async () => {
			const execute = vi.fn().mockRejectedValue(new Error('upstream 500'));
			nodeTypes.getByNameAndVersion.mockReturnValue({
				description: toolDescription,
				execute,
			} as unknown as INodeType);

			const result = await executor.executeInline({
				nodeType: 'n8n-nodes-base.slack',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [],
				projectId: 'p-1',
			});

			expect(result.status).toBe('error');
			expect(result.error).toBe('upstream 500');
		});

		it('returns an error when execute resolves without an output array', async () => {
			// Downstream consumers expect NodeExecutionData[] — resolving with
			// `null` or a truthy-but-not-array value must not leak through as
			// `status: 'success'`.
			const execute = vi.fn().mockResolvedValue(null);
			nodeTypes.getByNameAndVersion.mockReturnValue({
				description: toolDescription,
				execute,
			} as unknown as INodeType);

			const result = await executor.executeInline({
				nodeType: 'n8n-nodes-base.slack',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [],
				projectId: 'p-1',
			});

			expect(result.status).toBe('error');
			expect(result.error).toMatch(/No output data/);
		});

		it('returns an error when execute resolves with an empty output array', async () => {
			const execute = vi.fn().mockResolvedValue([]);
			nodeTypes.getByNameAndVersion.mockReturnValue({
				description: toolDescription,
				execute,
			} as unknown as INodeType);

			const result = await executor.executeInline({
				nodeType: 'n8n-nodes-base.slack',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [],
				projectId: 'p-1',
			});

			expect(result.status).toBe('error');
			expect(result.error).toMatch(/No output data/);
		});
	});

	describe('introspectSupplyDataToolSchema', () => {
		it('returns the schema a structured tool exposes', async () => {
			const schema = { type: 'object', properties: { query: { type: 'string' } } };
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mockNodeType({
					description: toolDescription,
					supplyData: vi.fn().mockResolvedValue({
						response: { invoke: vi.fn(), schema },
					}),
				}),
			);

			const result = await executor.introspectSupplyDataToolSchema({
				projectId: 'p-1',
				nodeType: '@n8n/n8n-nodes-langchain.toolWikipedia',
				nodeTypeVersion: 1,
				nodeParameters: {},
			});

			expect(result).toBe(schema);
		});

		it('returns null when the tool has no structured schema (base Tool/DynamicTool)', async () => {
			const dynamicTool = new DynamicTool({
				name: 'thinking_tool',
				description: 'Think about something',
				func: async (input) => input,
			});
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mockNodeType({
					description: toolDescription,
					supplyData: vi.fn().mockResolvedValue({
						response: dynamicTool,
					}),
				}),
			);

			const result = await executor.introspectSupplyDataToolSchema({
				projectId: 'p-1',
				nodeType: '@n8n/n8n-nodes-langchain.toolBasic',
				nodeTypeVersion: 1,
				nodeParameters: {},
			});

			expect(result).toBeNull();
		});

		it('returns null for a StructuredToolkit response (per-method introspection not yet wired)', async () => {
			// Toolkit carries N tools, each with its own schema — there's no
			// single schema to advertise, so we fall back to the factory's
			// `{ input: string }` default rather than crash on `.schema` lookup.
			const toolkit = new StructuredToolkit([
				mock<StructuredToolkit['tools'][number]>({ name: 'list-docs' }),
			]);
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mockNodeType({
					description: toolDescription,
					supplyData: vi.fn().mockResolvedValue({ response: toolkit }),
				}),
			);

			const result = await executor.introspectSupplyDataToolSchema({
				projectId: 'p-1',
				nodeType: '@n8n/n8n-nodes-langchain.mcpClientTool',
				nodeTypeVersion: 1,
				nodeParameters: {},
			});

			expect(result).toBeNull();
		});

		it('swallows introspection errors and returns null (keeps tool registration robust)', async () => {
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mockNodeType({
					description: toolDescription,
					supplyData: vi.fn().mockRejectedValue(new Error('MCP server unreachable')),
				}),
			);

			const result = await executor.introspectSupplyDataToolSchema({
				projectId: 'p-1',
				nodeType: '@n8n/n8n-nodes-langchain.mcpClientTool',
				nodeTypeVersion: 1,
				nodeParameters: {},
			});

			expect(result).toBeNull();
			// Warn-level so MCP/credential introspection bugs surface in dev — a
			// silent failure here would let the LLM be told a different schema
			// than the one it's invoked against.
			expect(logger.warn).toHaveBeenCalledWith(
				'supplyData tool introspection failed',
				expect.objectContaining({ error: 'MCP server unreachable' }),
			);
		});
	});

	describe('executeInline → eval instrumentation', () => {
		it('applies the additionalData decoration and node name override when set', async () => {
			let executedNodeName: string | undefined;
			const execute = vi.fn().mockImplementation(async function (this: {
				getNode(): { name: string };
			}) {
				executedNodeName = this.getNode().name;
				return [[{ json: { ok: true } }]];
			});
			nodeTypes.getByNameAndVersion.mockReturnValue({
				description: toolDescription,
				execute,
			} as unknown as INodeType);

			const base: Record<string, unknown> = { credentialsHelper: {} };
			mockGetBase.mockResolvedValue(base);
			const configureAdditionalData = vi.fn((additionalData: Record<string, unknown>) => {
				additionalData.evalLlmMockHandler = vi.fn();
			});

			const result = await executor.executeInline({
				nodeType: 'n8n-nodes-base.slack',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [],
				projectId: 'p-1',
				nodeName: 'Slack_Tool',
				configureAdditionalData: configureAdditionalData as unknown as (
					additionalData: IWorkflowExecuteAdditionalData,
				) => void,
			});

			expect(result.status).toBe('success');
			expect(configureAdditionalData).toHaveBeenCalledTimes(1);
			expect(configureAdditionalData).toHaveBeenCalledWith(base);
			expect(base.evalLlmMockHandler).toBeDefined();
			expect(executedNodeName).toBe('Slack_Tool');
		});

		it('keeps the default node name and untouched additionalData when unset', async () => {
			let executedNodeName: string | undefined;
			const execute = vi.fn().mockImplementation(async function (this: {
				getNode(): { name: string };
			}) {
				executedNodeName = this.getNode().name;
				return [[{ json: {} }]];
			});
			nodeTypes.getByNameAndVersion.mockReturnValue({
				description: toolDescription,
				execute,
			} as unknown as INodeType);

			const base: Record<string, unknown> = {};
			mockGetBase.mockResolvedValue(base);

			const result = await executor.executeInline({
				nodeType: 'n8n-nodes-base.slack',
				nodeTypeVersion: 1,
				nodeParameters: {},
				inputData: [],
				projectId: 'p-1',
			});

			expect(result.status).toBe('success');
			expect(executedNodeName).toBe('Target Node');
			expect(base.evalLlmMockHandler).toBeUndefined();
		});
	});

	it('resolves expressions when invoking a supplyData tool with the VM engine', async () => {
		await Expression.initExpressionEngine({
			engine: 'vm',
			bridgeTimeout: 1000,
			bridgeMemoryLimit: 128,
			poolSize: 1,
			maxCodeCacheSize: 10,
		});

		try {
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mockNodeType({
					description: {
						...toolDescription,
						properties: [
							{
								displayName: 'Value',
								name: 'value',
								type: 'string',
								default: '',
							},
						],
					},
					async supplyData(this: ISupplyDataFunctions) {
						const resolvedValue = this.getNodeParameter('value', 0);
						return {
							response: {
								invoke: async () => resolvedValue,
							},
						};
					},
				}),
			);

			const result = await executor.executeInline({
				nodeType: '@n8n/n8n-nodes-langchain.toolHttpRequest',
				nodeTypeVersion: 1,
				nodeParameters: { value: '={{ 1 + 1 }}' },
				inputData: [{ json: {} }],
				projectId: 'p-1',
			});

			expect(result).toEqual({
				status: 'success',
				data: [{ json: { response: 2 } }],
			});
		} finally {
			await Expression.disposeExpressionEngine();
		}
	});
});
