import type { AgentAction, AgentFinish } from '@langchain/classic/agents';
import type { ToolsAgentAction } from '@langchain/classic/dist/agents/tool_calling/output_parser';
import type { Tool } from '@langchain/classic/tools';
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { HumanMessage, isDataContentBlock } from '@langchain/core/messages';
import type { BaseMessagePromptTemplateLike } from '@langchain/core/prompts';
import { FakeLLM, FakeStreamingChatModel } from '@langchain/core/utils/testing';
import { Buffer } from 'buffer';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { NodeOperationError, BINARY_ENCODING, NodeConnectionTypes } from 'n8n-workflow';
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { ZodType } from 'zod';
import { z } from 'zod';

import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';

import {
	getOutputParserSchema,
	extractBinaryMessages,
	fixEmptyContentMessage,
	handleParsedStepOutput,
	getChatModel,
	getOptionalMemory,
	prepareMessages,
	preparePrompt,
	getTools,
	getAgentStepsParser,
	handleAgentFinishOutput,
} from '../../agents/ToolsAgent/common';

function getFakeOutputParser(returnSchema?: ZodType): N8nOutputParser {
	const fakeOutputParser = mock<N8nOutputParser>();
	(fakeOutputParser.getSchema as Mock).mockReturnValue(returnSchema);
	return fakeOutputParser;
}

function createMockOutputParser(parseReturnValue?: Record<string, unknown>): N8nOutputParser {
	const mockParser = mock<N8nOutputParser>();
	(mockParser.parse as Mock).mockResolvedValue(parseReturnValue);

	return mockParser;
}

const mockHelpers = mock<IExecuteFunctions['helpers']>();
const mockContext = mock<IExecuteFunctions>({ helpers: mockHelpers });

beforeEach(() => vi.resetAllMocks());

describe('getOutputParserSchema', () => {
	it('should return a default schema if getSchema returns undefined', () => {
		const schema = getOutputParserSchema(getFakeOutputParser(undefined));
		// The default schema requires a "text" field.
		expect(() => schema.parse({})).toThrow();
		expect(schema.parse({ text: 'hello' })).toEqual({ text: 'hello' });
	});

	it('should return the custom schema if provided', () => {
		const customSchema = z.object({ custom: z.number() });

		const schema = getOutputParserSchema(getFakeOutputParser(customSchema));
		expect(() => schema.parse({ custom: 'not a number' })).toThrow();
		expect(schema.parse({ custom: 123 })).toEqual({ custom: 123 });
	});
});

describe('extractBinaryMessages', () => {
	it('should extract a binary message from the input data when no id is provided', async () => {
		const fakeItem = {
			json: {},
			binary: {
				img1: {
					mimeType: 'image/png',
					// simulate that data already includes 'base64'
					data: 'data:image/png;base64,sampledata',
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);

		const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0, {
			passthroughBinaryImages: true,
			passthroughBinaryPdfs: true,
		});
		// Expect the HumanMessage's content to be an array containing one binary message.
		expect(Array.isArray(humanMsg.content)).toBe(true);
		expect(humanMsg.content[0]).toEqual({
			type: 'image_url',
			image_url: { url: 'data:image/png;base64,sampledata' },
		});
	});

	it('should extract a binary message using binary stream if id is provided', async () => {
		const fakeItem = {
			json: {},
			binary: {
				img2: {
					mimeType: 'image/jpeg',
					id: '1234',
					data: 'nonsense',
				},
			},
		};

		mockHelpers.getBinaryStream.mockResolvedValue(mock());
		mockHelpers.binaryToBuffer.mockResolvedValue(Buffer.from('fakebufferdata'));
		mockContext.getInputData.mockReturnValue([fakeItem]);

		const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0, {
			passthroughBinaryImages: true,
			passthroughBinaryPdfs: true,
		});
		// eslint-disable-next-line @typescript-eslint/unbound-method
		expect(mockHelpers.getBinaryStream).toHaveBeenCalledWith('1234');
		// eslint-disable-next-line @typescript-eslint/unbound-method
		expect(mockHelpers.binaryToBuffer).toHaveBeenCalled();
		const expectedUrl = `data:image/jpeg;base64,${Buffer.from('fakebufferdata').toString(
			BINARY_ENCODING,
		)}`;
		expect(humanMsg.content[0]).toEqual({
			type: 'image_url',
			image_url: { url: expectedUrl },
		});
	});

	it('should extract markdown and CSV text files', async () => {
		const mdContent = '# Test Markdown\n\nThis is a test.';
		const csvContent = 'name,age\nJohn,30';
		const fakeItem = {
			json: {},
			binary: {
				markdown: {
					mimeType: 'text/markdown',
					fileName: 'test.md',
					data: `data:text/markdown;base64,${Buffer.from(mdContent).toString('base64')}`,
				},
				csv: {
					mimeType: 'text/csv',
					fileName: 'data.csv',
					data: `data:text/csv;base64,${Buffer.from(csvContent).toString('base64')}`,
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);

		const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0, {
			passthroughBinaryImages: true,
			passthroughBinaryPdfs: true,
		});

		expect(Array.isArray(humanMsg.content)).toBe(true);
		expect(humanMsg.content).toHaveLength(2);
		expect(humanMsg.content).toEqual(
			expect.arrayContaining([
				{ type: 'text', text: `File: test.md\nContent:\n${mdContent}` },
				{ type: 'text', text: `File: data.csv\nContent:\n${csvContent}` },
			]),
		);
	});

	it('should extract both images and text files together', async () => {
		const textContent = 'Some text content';
		const fakeItem = {
			json: {},
			binary: {
				image: {
					mimeType: 'image/png',
					fileName: 'test.png',
					data: 'imageData123',
				},
				text: {
					mimeType: 'text/plain',
					fileName: 'test.txt',
					data: `data:text/plain;base64,${Buffer.from(textContent).toString('base64')}`,
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);

		const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0, {
			passthroughBinaryImages: true,
			passthroughBinaryPdfs: true,
		});

		expect(Array.isArray(humanMsg.content)).toBe(true);
		expect(humanMsg.content).toHaveLength(2);
		expect(humanMsg.content).toEqual(
			expect.arrayContaining([
				{
					type: 'image_url',
					image_url: { url: 'data:image/png;base64,imageData123' },
				},
				{ type: 'text', text: `File: test.txt\nContent:\n${textContent}` },
			]),
		);
	});

	it('should extract a PDF binary message', async () => {
		const fakeItem = {
			json: {},
			binary: {
				doc1: {
					mimeType: 'application/pdf',
					data: 'data:application/pdf;base64,samplePdfData',
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);

		const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0, {
			passthroughBinaryImages: true,
			passthroughBinaryPdfs: true,
		});
		expect(Array.isArray(humanMsg.content)).toBe(true);
		expect(humanMsg.content[0]).toEqual({
			type: 'file',
			source_type: 'base64',
			mime_type: 'application/pdf',
			data: 'samplePdfData',
			metadata: { filename: 'attachment.pdf' },
		});
	});

	it('should produce a valid LangChain standard data content block for PDFs', async () => {
		// Contract check: the standard `file` block must satisfy isDataContentBlock so
		// provider converters (Gemini, Anthropic, OpenAI Completions) translate it
		// instead of rejecting it. The original `file_url` shape failed this check.
		const fakeItem = {
			json: {},
			binary: {
				doc1: {
					mimeType: 'application/pdf',
					fileName: 'report.pdf',
					data: 'data:application/pdf;base64,samplePdfData',
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);

		const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0, {
			passthroughBinaryImages: true,
			passthroughBinaryPdfs: true,
		});
		expect(isDataContentBlock(humanMsg.content[0] as object)).toBe(true);
	});

	it('should emit an OpenAI input_file block for PDFs when content format is openai-responses', async () => {
		const fakeItem = {
			json: {},
			binary: {
				doc1: {
					mimeType: 'application/pdf',
					fileName: 'report.pdf',
					data: 'data:application/pdf;base64,samplePdfData',
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);

		const humanMsg: HumanMessage = await extractBinaryMessages(
			mockContext,
			0,
			{ passthroughBinaryImages: true, passthroughBinaryPdfs: true },
			'openai-responses',
		);
		expect(humanMsg.content[0]).toEqual({
			type: 'input_file',
			file_data: 'data:application/pdf;base64,samplePdfData',
			filename: 'report.pdf',
		});
	});

	it('should keep images as image_url even for openai-responses format', async () => {
		const fakeItem = {
			json: {},
			binary: {
				img1: {
					mimeType: 'image/png',
					data: 'data:image/png;base64,imageData',
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);

		const humanMsg: HumanMessage = await extractBinaryMessages(
			mockContext,
			0,
			{ passthroughBinaryImages: true, passthroughBinaryPdfs: true },
			'openai-responses',
		);
		expect(humanMsg.content[0]).toEqual({
			type: 'image_url',
			image_url: { url: 'data:image/png;base64,imageData' },
		});
	});

	it('should extract both images and PDFs together', async () => {
		const fakeItem = {
			json: {},
			binary: {
				image: {
					mimeType: 'image/png',
					fileName: 'test.png',
					data: 'imageData123',
				},
				document: {
					mimeType: 'application/pdf',
					fileName: 'test.pdf',
					data: 'pdfData456',
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);

		const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0, {
			passthroughBinaryImages: true,
			passthroughBinaryPdfs: true,
		});
		expect(Array.isArray(humanMsg.content)).toBe(true);
		expect(humanMsg.content).toHaveLength(2);
		expect(humanMsg.content).toEqual(
			expect.arrayContaining([
				{
					type: 'image_url',
					image_url: { url: 'data:image/png;base64,imageData123' },
				},
				{
					type: 'file',
					source_type: 'base64',
					mime_type: 'application/pdf',
					data: 'pdfData456',
					metadata: { filename: 'test.pdf' },
				},
			]),
		);
	});

	it('should extract PDF using binary stream when id is provided', async () => {
		const fakeItem = {
			json: {},
			binary: {
				doc1: {
					mimeType: 'application/pdf',
					id: 'pdf-123',
					data: 'nonsense',
				},
			},
		};

		mockHelpers.getBinaryStream.mockResolvedValue(mock());
		mockHelpers.binaryToBuffer.mockResolvedValue(Buffer.from('fakepdfdata'));
		mockContext.getInputData.mockReturnValue([fakeItem]);

		const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0, {
			passthroughBinaryImages: true,
			passthroughBinaryPdfs: true,
		});
		expect(mockHelpers.getBinaryStream).toHaveBeenCalledWith('pdf-123');
		expect(mockHelpers.binaryToBuffer).toHaveBeenCalled();
		expect(humanMsg.content[0]).toEqual({
			type: 'file',
			source_type: 'base64',
			mime_type: 'application/pdf',
			data: Buffer.from('fakepdfdata').toString(BINARY_ENCODING),
			metadata: { filename: 'attachment.pdf' },
		});
	});

	it('should skip binary whose passthrough option is disabled', async () => {
		const fakeItem = {
			json: {},
			binary: {
				image: {
					mimeType: 'image/png',
					fileName: 'test.png',
					data: 'imageData123',
				},
				doc: {
					mimeType: 'application/pdf',
					fileName: 'test.pdf',
					data: 'pdfData456',
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);

		// Only PDFs enabled: the image must not be processed or attached.
		const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0, {
			passthroughBinaryImages: false,
			passthroughBinaryPdfs: true,
		});

		expect(humanMsg.content).toHaveLength(1);
		expect(humanMsg.content[0]).toEqual({
			type: 'file',
			source_type: 'base64',
			mime_type: 'application/pdf',
			data: 'pdfData456',
			metadata: { filename: 'test.pdf' },
		});
	});

	it('should throw when a binary attachment exceeds the size limit', async () => {
		// 68 MB of base64 decodes to ~51 MB, above the 50 MB guard.
		const oversizedBase64 = 'A'.repeat(68 * 1024 * 1024);
		const fakeItem = {
			json: {},
			binary: {
				doc1: {
					mimeType: 'application/pdf',
					fileName: 'huge.pdf',
					data: oversizedBase64,
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);

		await expect(
			extractBinaryMessages(mockContext, 0, { passthroughBinaryPdfs: true }),
		).rejects.toThrow(/exceeds the 50.0 MB limit/);
	});

	it('should decode base64-encoded text files without prefix', async () => {
		const textContent = 'Hello world!';
		const fakeItem = {
			json: {},
			binary: {
				text: {
					mimeType: 'text/plain',
					fileName: 'test.txt',
					// Default n8n binary format: base64 without data URL prefix
					data: Buffer.from(textContent).toString('base64'),
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);

		const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0, {
			passthroughBinaryImages: true,
			passthroughBinaryPdfs: true,
		});

		expect(Array.isArray(humanMsg.content)).toBe(true);
		expect(humanMsg.content).toHaveLength(1);
		expect(humanMsg.content[0]).toEqual({
			type: 'text',
			text: `File: test.txt\nContent:\n${textContent}`,
		});
	});
});

describe('fixEmptyContentMessage', () => {
	it('should replace empty string inputs with empty objects', () => {
		// Cast to any to bypass type issues with AgentFinish/AgentAction.
		const fakeSteps: ToolsAgentAction[] = [
			{
				messageLog: [
					{
						content: [{ input: '' }, { input: { already: 'object' } }],
					},
				],
			},
		] as unknown as ToolsAgentAction[];
		const fixed = fixEmptyContentMessage(fakeSteps) as ToolsAgentAction[];
		const messageContent = fixed?.[0]?.messageLog?.[0].content;

		// Type assertion needed since we're extending MessageContentComplex
		expect((messageContent?.[0] as unknown as { input: unknown })?.input).toEqual({});
		expect((messageContent?.[1] as unknown as { input: unknown })?.input).toEqual({
			already: 'object',
		});
	});
});

describe('handleParsedStepOutput', () => {
	it('should stringify the output if memory is provided', () => {
		const output = { key: 'value' };
		const fakeMemory = mock<BaseChatMemory>();
		const result = handleParsedStepOutput(output, fakeMemory);
		expect(result.returnValues).toEqual({ output: JSON.stringify(output) });
		expect(result.log).toEqual('Final response formatted');
	});

	it('should not stringify the output if memory is not provided', () => {
		const output = { key: 'value' };
		const result = handleParsedStepOutput(output);
		expect(result.returnValues).toEqual(output);
	});
});

describe('getChatModel', () => {
	it('should return the model if it is a valid chat model', async () => {
		// Cast fakeChatModel as any
		const fakeChatModel = mock<BaseChatModel>();
		fakeChatModel.bindTools = vi.fn();
		fakeChatModel.lc_namespace = ['chat_models'];
		mockContext.getInputConnectionData.mockResolvedValue(fakeChatModel);

		const model = await getChatModel(mockContext);
		expect(model).toEqual(fakeChatModel);
	});

	it('should throw if the model is not a valid chat model', async () => {
		const fakeInvalidModel = mock<BaseChatModel>(); // missing bindTools & lc_namespace
		fakeInvalidModel.lc_namespace = [];
		mockContext.getInputConnectionData.mockResolvedValue(fakeInvalidModel);
		mockContext.getNode.mockReturnValue(mock());
		await expect(getChatModel(mockContext)).rejects.toThrow(NodeOperationError);
	});

	it('should return the first model when multiple models are connected and no index specified', async () => {
		const fakeChatModel1 = new FakeStreamingChatModel({});
		const fakeChatModel2 = new FakeStreamingChatModel({});

		mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1, fakeChatModel2]);

		const model = await getChatModel(mockContext);
		expect(model).toEqual(fakeChatModel2); // Should return the last model (reversed array)
	});

	it('should return the model at specified index when multiple models are connected', async () => {
		const fakeChatModel1 = new FakeStreamingChatModel({});

		const fakeChatModel2 = new FakeStreamingChatModel({});

		mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1, fakeChatModel2]);

		const model = await getChatModel(mockContext, 0);
		expect(model).toEqual(fakeChatModel2); // Should return the first model after reversal (index 0)
	});

	it('should return the fallback model at index 1 when multiple models are connected', async () => {
		const fakeChatModel1 = new FakeStreamingChatModel({});
		const fakeChatModel2 = new FakeStreamingChatModel({});

		mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1, fakeChatModel2]);

		const model = await getChatModel(mockContext, 1);
		expect(model).toEqual(fakeChatModel1); // Should return the second model after reversal (index 1)
	});

	it('should return undefined when requested index is out of bounds', async () => {
		const fakeChatModel1 = mock<BaseChatModel>();
		fakeChatModel1.bindTools = vi.fn();
		fakeChatModel1.lc_namespace = ['chat_models'];

		mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1]);
		mockContext.getNode.mockReturnValue(mock());

		const result = await getChatModel(mockContext, 2);

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

	it('should throw error when single model does not support tools', async () => {
		const fakeInvalidModel = new FakeLLM({}); // doesn't support tool calls

		mockContext.getInputConnectionData.mockResolvedValue(fakeInvalidModel);
		mockContext.getNode.mockReturnValue(mock());

		await expect(getChatModel(mockContext)).rejects.toThrow(NodeOperationError);
		await expect(getChatModel(mockContext)).rejects.toThrow(
			'Tools Agent requires Chat Model which supports Tools calling',
		);
	});

	it('should throw error when model at specified index does not support tools', async () => {
		const fakeChatModel1 = new FakeStreamingChatModel({});
		const fakeInvalidModel = new FakeLLM({}); // doesn't support tool calls

		mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1, fakeInvalidModel]);
		mockContext.getNode.mockReturnValue(mock());

		await expect(getChatModel(mockContext, 0)).rejects.toThrow(NodeOperationError);
	});
});

describe('getOptionalMemory', () => {
	it('should return the memory if available', async () => {
		const fakeMemory = { some: 'memory' };
		mockContext.getInputConnectionData.mockResolvedValue(fakeMemory);

		const memory = await getOptionalMemory(mockContext);
		expect(memory).toEqual(fakeMemory);
	});
});

describe('getTools', () => {
	beforeEach(() => {
		const fakeTool = mock<Tool>();
		mockContext.getInputConnectionData
			.calledWith(NodeConnectionTypes.AiTool, 0)
			.mockResolvedValue([fakeTool]);
	});

	it('should retrieve tools without appending if outputParser is not provided', async () => {
		const tools = await getTools(mockContext);

		expect(tools.length).toEqual(1);
	});

	it('should retrieve tools and append the structured output parser tool if outputParser is provided', async () => {
		const fakeOutputParser = getFakeOutputParser(z.object({ text: z.string() }));
		const tools = await getTools(mockContext, fakeOutputParser);
		// Our fake getConnectedTools returns one tool; with outputParser, one extra is appended.
		expect(tools.length).toEqual(2);
		const dynamicTool = tools.find((t) => t.name === 'format_final_json_response');
		expect(dynamicTool).toBeDefined();
	});
});

describe('prepareMessages', () => {
	it('should include a binary message if binary data is present and passthroughBinaryImages is true', async () => {
		const fakeItem = {
			json: {},
			binary: {
				img1: {
					mimeType: 'image/png',
					data: 'data:image/png;base64,sampledata',
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);
		const messages = await prepareMessages(mockContext, 0, {
			systemMessage: 'Test system',
			passthroughBinaryImages: true,
		});
		// Check if any message is an instance of HumanMessage
		const hasBinaryMessage = messages.some(
			(m) => typeof m === 'object' && m instanceof HumanMessage,
		);
		expect(hasBinaryMessage).toBe(true);
	});

	it('should not include a binary message if no binary data is present', async () => {
		const fakeItem = { json: {} }; // no binary key
		mockContext.getInputData.mockReturnValue([fakeItem]);
		const messages = await prepareMessages(mockContext, 0, {
			systemMessage: 'Test system',
			passthroughBinaryImages: true,
		});
		const hasHumanMessage = messages.some((m) => m instanceof HumanMessage);
		expect(hasHumanMessage).toBe(false);
	});

	it('should not include a binary message if both passthrough options are off', async () => {
		const fakeItem = {
			json: {},
			binary: {
				doc1: {
					mimeType: 'application/pdf',
					data: 'data:application/pdf;base64,sampledata',
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);

		const messages = await prepareMessages(mockContext, 0, {
			systemMessage: 'Test system',
			passthroughBinaryImages: false,
			passthroughBinaryPdfs: false,
		});
		const hasHumanMessage = messages.some((m) => m instanceof HumanMessage);
		expect(hasHumanMessage).toBe(false);
	});

	it('should not include PDF when only passthroughBinaryImages is true', async () => {
		const fakeItem = {
			json: {},
			binary: {
				doc1: {
					mimeType: 'application/pdf',
					data: 'data:application/pdf;base64,sampledata',
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);
		mockContext.logger = {
			debug: vi.fn(),
			info: vi.fn(),
			warn: vi.fn(),
			error: vi.fn(),
		};

		const messages = await prepareMessages(mockContext, 0, {
			systemMessage: 'Test system',
			passthroughBinaryImages: true,
			passthroughBinaryPdfs: false,
		});
		const hasHumanMessage = messages.some((m) => m instanceof HumanMessage);
		expect(hasHumanMessage).toBe(false);
		expect(mockContext.logger.debug).toHaveBeenCalledTimes(1);
	});

	it('should include a binary message for PDF when passthroughBinaryPdfs is true', async () => {
		const fakeItem = {
			json: {},
			binary: {
				doc1: {
					mimeType: 'application/pdf',
					data: 'data:application/pdf;base64,samplePdfData',
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);

		const messages = await prepareMessages(mockContext, 0, {
			systemMessage: 'Test system',
			passthroughBinaryImages: false,
			passthroughBinaryPdfs: true,
		});
		const hasBinaryMessage = messages.some(
			(m) => typeof m === 'object' && m instanceof HumanMessage,
		);
		expect(hasBinaryMessage).toBe(true);
	});

	it('should emit input_file for PDFs when the connected model uses the OpenAI Responses API', async () => {
		const fakeItem = {
			json: {},
			binary: {
				doc1: {
					mimeType: 'application/pdf',
					fileName: 'report.pdf',
					data: 'data:application/pdf;base64,samplePdfData',
				},
			},
		};
		mockContext.getInputData.mockReturnValue([fakeItem]);

		// Stand-in for a ChatOpenAI configured against the Responses API.
		const responsesApiModel = mock<BaseChatModel>();
		(responsesApiModel as unknown as { _useResponsesApi: () => boolean })._useResponsesApi = () =>
			true;

		const messages = await prepareMessages(mockContext, 0, {
			systemMessage: 'Test system',
			passthroughBinaryImages: false,
			passthroughBinaryPdfs: true,
			model: responsesApiModel,
		});
		const binaryMessage = messages.find((m) => m instanceof HumanMessage) as HumanMessage;
		expect(binaryMessage).toBeDefined();
		expect(binaryMessage.content[0]).toEqual({
			type: 'input_file',
			file_data: 'data:application/pdf;base64,samplePdfData',
			filename: 'report.pdf',
		});
	});

	it('should not include system_message in prompt templates if not provided after version 1.9', async () => {
		const fakeItem = { json: {} };
		const mockNode = mock<INode>();
		mockNode.typeVersion = 1.9;
		mockContext.getInputData.mockReturnValue([fakeItem]);
		mockContext.getNode.mockReturnValue(mockNode);
		const messages = await prepareMessages(mockContext, 0, {});

		expect(messages.length).toBe(3);
		expect(messages).not.toContainEqual(['system', '{system_message}']);
	});

	it('should include system_message in prompt templates if provided after version 1.9', async () => {
		const fakeItem = { json: {} };
		const mockNode = mock<INode>();
		mockNode.typeVersion = 1.9;
		mockContext.getInputData.mockReturnValue([fakeItem]);
		mockContext.getNode.mockReturnValue(mockNode);

		const messages = await prepareMessages(mockContext, 0, { systemMessage: 'Hello' });

		expect(messages.length).toBe(4);
		expect(messages).toContainEqual(['system', '{system_message}']);
	});

	it('should include system_message in prompt templates if not provided before version 1.9', async () => {
		const fakeItem = { json: {} };
		const mockNode = mock<INode>();
		mockNode.typeVersion = 1.8;
		mockContext.getInputData.mockReturnValue([fakeItem]);
		mockContext.getNode.mockReturnValue(mockNode);

		const messages = await prepareMessages(mockContext, 0, {});

		expect(messages.length).toBe(4);
		expect(messages).toContainEqual(['system', '{system_message}']);
	});

	it('should include system_message with formatting_instructions in prompt templates if provided before version 1.9', async () => {
		const fakeItem = { json: {} };
		const mockNode = mock<INode>();
		mockNode.typeVersion = 1.8;
		mockContext.getInputData.mockReturnValue([fakeItem]);
		mockContext.getNode.mockReturnValue(mockNode);

		const messages = await prepareMessages(mockContext, 0, {
			systemMessage: 'Hello',
			outputParser: mock<N8nOutputParser>(),
		});

		expect(messages.length).toBe(4);
		expect(messages).toContainEqual(['system', '{system_message}\n\n{formatting_instructions}']);
	});

	it('should add formatting instructions when omitting system message after version 1.9', async () => {
		const fakeItem = { json: {} };
		const mockNode = mock<INode>();
		mockNode.typeVersion = 1.9;
		mockContext.getInputData.mockReturnValue([fakeItem]);
		mockContext.getNode.mockReturnValue(mockNode);

		const messages = await prepareMessages(mockContext, 0, {
			outputParser: mock<N8nOutputParser>(),
		});

		expect(messages.length).toBe(4);
		expect(messages).toContainEqual(['system', '{formatting_instructions}']);
	});
});

describe('preparePrompt', () => {
	it('should return a ChatPromptTemplate instance', () => {
		const sampleMessages: BaseMessagePromptTemplateLike[] = [
			['system', 'Test'],
			['human', 'Hello'],
		];
		const prompt = preparePrompt(sampleMessages);

		expect(prompt).toBeDefined();
	});
});

describe('getAgentStepsParser', () => {
	let mockMemory: BaseChatMemory;

	beforeEach(() => {
		mockMemory = mock<BaseChatMemory>();
	});

	describe('with format_final_json_response tool', () => {
		it('should parse output from format_final_json_response tool', async () => {
			const steps: AgentAction[] = [
				{
					tool: 'format_final_json_response',
					toolInput: { city: 'Berlin', temperature: 15 },
					log: '',
				},
			];

			const mockOutputParser = createMockOutputParser({
				city: 'Berlin',
				temperature: 15,
			});

			const parser = getAgentStepsParser(mockOutputParser, mockMemory);
			const result = await parser(steps);

			expect(mockOutputParser.parse).toHaveBeenCalledWith('{"city":"Berlin","temperature":15}');
			expect(result).toEqual({
				returnValues: { output: '{"city":"Berlin","temperature":15}' },
				log: 'Final response formatted',
			});
		});

		it('should stringify tool input if it is not an object', async () => {
			const steps: AgentAction[] = [
				{
					tool: 'format_final_json_response',
					toolInput: 'simple string',
					log: '',
				},
			];

			const mockOutputParser = createMockOutputParser({ text: 'simple string' });

			const parser = getAgentStepsParser(mockOutputParser, mockMemory);
			const result = await parser(steps);

			expect(mockOutputParser.parse).toHaveBeenCalledWith('simple string');
			expect(result).toEqual({
				returnValues: { output: '{"text":"simple string"}' },
				log: 'Final response formatted',
			});
		});
	});

	describe('manual parsing path', () => {
		it('should handle already wrapped output structure correctly', async () => {
			// Agent returns output that already has { output: {...} } structure
			const steps: AgentFinish = {
				returnValues: {
					output: '{"output":{"city":"Berlin","temperature":15}}',
				},
				log: '',
			};

			const mockOutputParser = createMockOutputParser({
				city: 'Berlin',
				temperature: 15,
			});

			const parser = getAgentStepsParser(mockOutputParser, mockMemory);
			const result = await parser(steps);

			// Should detect the existing wrapper and not double-wrap
			expect(mockOutputParser.parse).toHaveBeenCalledWith(
				'{"output":{"city":"Berlin","temperature":15}}',
			);
			expect(result).toEqual({
				returnValues: { output: '{"city":"Berlin","temperature":15}' },
				log: 'Final response formatted',
			});
		});

		it('should wrap output that is not already wrapped', async () => {
			// Agent returns plain data without { output: ... } wrapper
			const steps: AgentFinish = {
				returnValues: {
					output: '{"city":"Berlin","temperature":15}',
				},
				log: '',
			};

			const mockOutputParser = createMockOutputParser({
				city: 'Berlin',
				temperature: 15,
			});

			const parser = getAgentStepsParser(mockOutputParser, mockMemory);
			const result = await parser(steps);

			// Should wrap the data in { output: ... } for the parser
			expect(mockOutputParser.parse).toHaveBeenCalledWith(
				'{"output":{"city":"Berlin","temperature":15}}',
			);
			expect(result).toEqual({
				returnValues: { output: '{"city":"Berlin","temperature":15}' },
				log: 'Final response formatted',
			});
		});

		it('should handle output with additional properties correctly', async () => {
			// Output has more than just the "output" property
			const steps: AgentFinish = {
				returnValues: {
					output: '{"output":{"text":"Hello"},"metadata":{"source":"test"}}',
				},
				log: '',
			};

			const mockOutputParser = createMockOutputParser({
				text: 'Hello',
				metadata: { source: 'test' },
			});

			const parser = getAgentStepsParser(mockOutputParser, mockMemory);
			const result = await parser(steps);

			// Should wrap since it has multiple properties
			expect(mockOutputParser.parse).toHaveBeenCalledWith(
				'{"output":{"output":{"text":"Hello"},"metadata":{"source":"test"}}}',
			);
			expect(result).toEqual({
				returnValues: { output: '{"text":"Hello","metadata":{"source":"test"}}' },
				log: 'Final response formatted',
			});
		});

		it('should handle parse errors gracefully', async () => {
			const steps: AgentFinish = {
				returnValues: {
					output: 'invalid json',
				},
				log: '',
			};

			const mockOutputParser = createMockOutputParser({ text: 'invalid json' });

			const parser = getAgentStepsParser(mockOutputParser, mockMemory);
			const result = await parser(steps);

			// Should fallback to raw output when JSON parsing fails
			expect(mockOutputParser.parse).toHaveBeenCalledWith('invalid json');
			expect(result).toEqual({
				returnValues: { output: '{"text":"invalid json"}' },
				log: 'Final response formatted',
			});
		});

		it('should handle null output correctly', async () => {
			const steps: AgentFinish = {
				returnValues: {
					output: 'null',
				},
				log: '',
			};

			const mockOutputParser = createMockOutputParser({ result: null });

			const parser = getAgentStepsParser(mockOutputParser, mockMemory);
			const result = await parser(steps);

			// Should wrap null in { output: null }
			expect(mockOutputParser.parse).toHaveBeenCalledWith('{"output":null}');
			expect(result).toEqual({
				returnValues: { output: '{"result":null}' },
				log: 'Final response formatted',
			});
		});

		it('should handle undefined-like values correctly', async () => {
			const steps: AgentFinish = {
				returnValues: {
					output: 'undefined',
				},
				log: '',
			};

			const mockOutputParser = createMockOutputParser({ text: 'undefined' });

			const parser = getAgentStepsParser(mockOutputParser, mockMemory);
			const result = await parser(steps);

			// Should fallback to raw string since "undefined" is not valid JSON
			expect(mockOutputParser.parse).toHaveBeenCalledWith('undefined');
			expect(result).toEqual({
				returnValues: { output: '{"text":"undefined"}' },
				log: 'Final response formatted',
			});
		});

		it('should return output as-is without memory', async () => {
			const steps: AgentFinish = {
				returnValues: {
					output: '{"city":"Berlin","temperature":15}',
				},
				log: '',
			};

			const mockOutputParser = createMockOutputParser({
				city: 'Berlin',
				temperature: 15,
			});

			const parser = getAgentStepsParser(mockOutputParser, undefined);
			const result = await parser(steps);

			expect(result).toEqual({
				returnValues: { city: 'Berlin', temperature: 15 },
				log: 'Final response formatted',
			});
		});
	});

	describe('without output parser', () => {
		it('should pass through agent finish steps unchanged', async () => {
			const steps: AgentFinish = {
				returnValues: { output: 'Final answer' },
				log: '',
			};

			const parser = getAgentStepsParser(undefined, undefined);
			const result = await parser(steps);

			expect(result).toEqual({
				log: '',
				returnValues: { output: 'Final answer' },
			});
		});

		it('should handle array of agent actions', async () => {
			const steps: AgentAction[] = [
				{ tool: 'some_tool', toolInput: { query: 'test' }, log: '' },
				{ tool: 'another_tool', toolInput: { data: 'value' }, log: '' },
			];

			const parser = getAgentStepsParser(undefined, undefined);
			const result = await parser(steps);

			expect(result).toEqual(steps);
		});
	});
});

describe('handleAgentFinishOutput', () => {
	it('should merge multi-output text arrays into a single string', () => {
		const steps: AgentFinish = {
			returnValues: {
				output: [
					{ index: 0, type: 'text', text: 'First part' },
					{ index: 1, type: 'text', text: 'Second part' },
				],
			},
			log: '',
		};

		const result = handleAgentFinishOutput(steps);

		expect(result).toEqual({
			log: '',
			returnValues: {
				output: 'First part\nSecond part',
			},
		});
	});

	it('should not modify non-text multi-output arrays', () => {
		const steps: AgentFinish = {
			returnValues: {
				output: [
					{ index: 0, type: 'text', text: 'Text part' },
					{ index: 1, type: 'image', url: 'http://example.com/image.png' },
				],
			},
			log: '',
		};

		const result = handleAgentFinishOutput(steps);

		expect(result).toEqual(steps);
	});

	it('should not modify simple string output', () => {
		const steps: AgentFinish = {
			returnValues: {
				output: 'Simple string output',
			},
			log: '',
		};

		const result = handleAgentFinishOutput(steps);

		expect(result).toEqual(steps);
	});

	it('should handle agent action arrays unchanged', () => {
		const steps: AgentAction[] = [
			{
				tool: 'tool1',
				toolInput: {},
				log: '',
			},
			{
				tool: 'tool2',
				toolInput: {},
				log: '',
			},
		];

		const result = handleAgentFinishOutput(steps);

		expect(result).toEqual(steps);
	});

	it('should filter out thinking blocks and return only text blocks', () => {
		const steps: AgentFinish = {
			returnValues: {
				output: [
					{ index: 0, type: 'thinking', thinking: 'Internal reasoning...' },
					{ index: 1, type: 'text', text: 'User-facing output' },
				],
			},
			log: '',
		};

		const result = handleAgentFinishOutput(steps) as AgentFinish;

		expect(result.returnValues.output).toBe('User-facing output');
	});

	it('should return thinking content when no text blocks exist', () => {
		const steps: AgentFinish = {
			returnValues: {
				output: [
					{ index: 0, type: 'thinking', thinking: 'Only thinking content' },
					{ index: 1, type: 'thinking', thinking: 'More thinking' },
				],
			},
			log: '',
		};

		const result = handleAgentFinishOutput(steps) as AgentFinish;

		expect(result.returnValues.output).toBe('Only thinking content\nMore thinking');
	});

	it('should return empty string when no text or thinking blocks exist', () => {
		const steps: AgentFinish = {
			returnValues: {
				output: [{ index: 0, type: 'unknown' }],
			},
			log: '',
		};

		const result = handleAgentFinishOutput(steps) as AgentFinish;

		expect(result.returnValues.output).toBe('');
	});
});
