/* eslint-disable n8n-nodes-base/node-filename-against-convention */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */

/* eslint-disable @typescript-eslint/unbound-method */
import { ChatOpenAI } from '@langchain/openai';
import { makeN8nLlmFailedAttemptHandler, getProxyAgent } from '@n8n/ai-utilities';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
import type { Mock, Mocked } from 'vitest';

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

vi.mock('@langchain/openai');
vi.mock('@n8n/ai-utilities');

const MockedChatOpenAI = vi.mocked(ChatOpenAI);
const mockedMakeN8nLlmFailedAttemptHandler = vi.mocked(makeN8nLlmFailedAttemptHandler);
const mockedGetProxyAgent = vi.mocked(getProxyAgent);

describe('LmChatOpenRouter', () => {
	let node: LmChatOpenRouter;

	const mockNodeDef: INode = {
		id: '1',
		name: 'OpenRouter Chat Model',
		typeVersion: 1,
		type: 'n8n-nodes-langchain.lmChatOpenRouter',
		position: [0, 0],
		parameters: {},
	};

	const setupMockContext = (nodeOverrides: Partial<INode> = {}) => {
		const nodeDef = { ...mockNodeDef, ...nodeOverrides };
		const ctx = createMockExecuteFunction<ISupplyDataFunctions>(
			{},
			nodeDef,
		) as Mocked<ISupplyDataFunctions>;

		ctx.getCredentials = vi.fn().mockResolvedValue({
			apiKey: 'test-key',
			url: 'https://openrouter.ai/api/v1',
		});
		ctx.getNode = vi.fn().mockReturnValue(nodeDef);
		ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
			if (paramName === 'model') return 'anthropic/claude-sonnet-4-20250514';
			if (paramName === 'options') return {};
			return undefined;
		});

		mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(vi.fn());
		mockedGetProxyAgent.mockReturnValue({} as any);
		return ctx;
	};

	beforeEach(() => {
		node = new LmChatOpenRouter();
		vi.clearAllMocks();
	});

	describe('node description', () => {
		it('should have correct node properties', () => {
			expect(node.description).toMatchObject({
				displayName: 'OpenRouter Chat Model',
				name: 'lmChatOpenRouter',
				group: ['transform'],
				version: [1],
			});
		});

		it('should require openRouterApi credentials', () => {
			expect(node.description.credentials).toEqual([{ name: 'openRouterApi', required: true }]);
		});

		it('should output ai_languageModel', () => {
			expect(node.description.outputs).toEqual(['ai_languageModel']);
			expect(node.description.outputNames).toEqual(['Model']);
		});
	});

	describe('supplyData', () => {
		it('should create ChatOpenAI with basic configuration', async () => {
			const ctx = setupMockContext();

			const result = await node.supplyData.call(ctx, 0);

			expect(ctx.getCredentials).toHaveBeenCalledWith('openRouterApi');
			expect(MockedChatOpenAI).toHaveBeenCalledWith(
				expect.objectContaining({
					apiKey: 'test-key',
					model: 'anthropic/claude-sonnet-4-20250514',
					maxRetries: 2,
					callbacks: expect.arrayContaining([expect.any(Object)]),
					onFailedAttempt: expect.any(Function),
				}),
			);
			expect(result).toEqual({ response: expect.any(Object) });
		});

		it('should pass options to ChatOpenAI', async () => {
			const ctx = setupMockContext();
			ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
				if (paramName === 'model') return 'anthropic/claude-sonnet-4-20250514';
				if (paramName === 'options')
					return {
						temperature: 0.5,
						maxTokens: 2000,
						topP: 0.9,
						frequencyPenalty: 0.3,
						presencePenalty: 0.2,
						timeout: 60000,
						maxRetries: 5,
					};
				return undefined;
			});

			await node.supplyData.call(ctx, 0);

			expect(MockedChatOpenAI).toHaveBeenCalledWith(
				expect.objectContaining({
					temperature: 0.5,
					maxTokens: 2000,
					topP: 0.9,
					frequencyPenalty: 0.3,
					presencePenalty: 0.2,
					timeout: 60000,
					maxRetries: 5,
				}),
			);
		});

		it('should set response_format in modelKwargs when responseFormat is provided', async () => {
			const ctx = setupMockContext();
			ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
				if (paramName === 'model') return 'anthropic/claude-sonnet-4-20250514';
				if (paramName === 'options') return { responseFormat: 'json_object' };
				return undefined;
			});

			await node.supplyData.call(ctx, 0);

			expect(MockedChatOpenAI).toHaveBeenCalledWith(
				expect.objectContaining({
					modelKwargs: { response_format: { type: 'json_object' } },
				}),
			);
		});

		it('should not set modelKwargs when no responseFormat', async () => {
			const ctx = setupMockContext();

			await node.supplyData.call(ctx, 0);

			expect(MockedChatOpenAI).toHaveBeenCalledWith(
				expect.objectContaining({
					modelKwargs: undefined,
				}),
			);
		});

		it('should pass a custom fetch wrapper in configuration', async () => {
			const ctx = setupMockContext();

			await node.supplyData.call(ctx, 0);

			const callArgs = MockedChatOpenAI.mock.calls[0][0];
			expect(callArgs?.configuration?.fetch).toEqual(expect.any(Function));
		});
	});

	describe('fetch wrapper (empty tool call arguments fix)', () => {
		const origFetch = globalThis.fetch;

		afterEach(() => {
			globalThis.fetch = origFetch;
		});

		function jsonResponse(body: unknown): Response {
			const json = JSON.stringify(body);
			return new Response(json, {
				status: 200,
				headers: { 'content-type': 'application/json' },
			});
		}

		/**
		 * Sets up a mock fetch, calls supplyData to capture it in the wrapper,
		 * and returns the wrapper function from the ChatOpenAI constructor args.
		 */
		async function setupFetchWrapper(mockFetch: Mock): Promise<typeof globalThis.fetch> {
			globalThis.fetch = mockFetch;
			const ctx = setupMockContext();
			await node.supplyData.call(ctx, 0);
			return MockedChatOpenAI.mock.calls[0][0]?.configuration?.fetch as typeof fetch;
		}

		it.each<{ input: unknown; expected: string; label: string }>([
			{ input: '', expected: '{}', label: 'empty string' },
			{ input: '   ', expected: '{}', label: 'whitespace-only string' },
			{ input: null, expected: '{}', label: 'null' },
			{ input: [], expected: '{}', label: 'empty array' },
			{ input: [1, 2], expected: '{}', label: 'non-empty array' },
			{ input: {}, expected: '{}', label: 'empty object (stringified)' },
			{
				input: { location: 'NYC' },
				expected: '{"location":"NYC"}',
				label: 'plain object (stringified)',
			},
			{
				input: '{"location":"NYC"}',
				expected: '{"location":"NYC"}',
				label: 'valid JSON string (unchanged)',
			},
			{ input: '{}', expected: '{}', label: 'empty JSON object string (unchanged)' },
		])('should normalize arguments: $label → $expected', async ({ input, expected }) => {
			const mockFetch = vi.fn().mockResolvedValue(
				jsonResponse({
					choices: [
						{
							message: {
								tool_calls: [{ id: 'call_1', function: { name: 'tool', arguments: input } }],
							},
						},
					],
				}),
			);

			const wrappedFetch = await setupFetchWrapper(mockFetch);
			const response = await wrappedFetch('https://openrouter.ai/api/v1/chat/completions');
			const result = await response.json();

			expect(result.choices[0].message.tool_calls[0].function.arguments).toBe(expected);
		});

		it('should pass through non-JSON responses untouched', async () => {
			const textBody = 'plain text response';
			const mockFetch = vi.fn().mockResolvedValue(
				new Response(textBody, {
					status: 200,
					headers: { 'content-type': 'text/plain' },
				}),
			);

			const wrappedFetch = await setupFetchWrapper(mockFetch);
			const response = await wrappedFetch('https://openrouter.ai/api/v1/chat/completions');

			expect(await response.text()).toBe(textBody);
		});

		it('should pass through JSON responses without choices', async () => {
			const body = { models: ['a', 'b'] };
			const mockFetch = vi.fn().mockResolvedValue(jsonResponse(body));

			const wrappedFetch = await setupFetchWrapper(mockFetch);
			const response = await wrappedFetch('https://openrouter.ai/api/v1/models');

			expect(await response.json()).toEqual(body);
		});

		it('should pass through responses without tool_calls', async () => {
			const body = {
				choices: [{ message: { role: 'assistant', content: 'Hello!' } }],
			};
			const mockFetch = vi.fn().mockResolvedValue(jsonResponse(body));

			const wrappedFetch = await setupFetchWrapper(mockFetch);
			const response = await wrappedFetch('https://openrouter.ai/api/v1/chat/completions');

			expect(await response.json()).toEqual(body);
		});

		it('should fix only empty arguments in a mixed set of tool calls', async () => {
			const mockFetch = vi.fn().mockResolvedValue(
				jsonResponse({
					choices: [
						{
							message: {
								tool_calls: [
									{
										id: 'call_1',
										function: { name: 'get_weather', arguments: '{"city":"NYC"}' },
									},
									{ id: 'call_2', function: { name: 'get_time', arguments: '' } },
									{
										id: 'call_3',
										function: { name: 'get_date', arguments: '{"format":"iso"}' },
									},
								],
							},
						},
					],
				}),
			);

			const wrappedFetch = await setupFetchWrapper(mockFetch);
			const response = await wrappedFetch('https://openrouter.ai/api/v1/chat/completions');
			const result = await response.json();

			const toolCalls = result.choices[0].message.tool_calls;
			expect(toolCalls[0].function.arguments).toBe('{"city":"NYC"}');
			expect(toolCalls[1].function.arguments).toBe('{}');
			expect(toolCalls[2].function.arguments).toBe('{"format":"iso"}');
		});

		it('should only carry content-type header on modified responses', async () => {
			const mockFetch = vi.fn().mockResolvedValue(
				jsonResponse({
					choices: [
						{
							message: {
								tool_calls: [{ id: 'call_1', function: { name: 'get_time', arguments: '' } }],
							},
						},
					],
				}),
			);

			const wrappedFetch = await setupFetchWrapper(mockFetch);
			const response = await wrappedFetch('https://openrouter.ai/api/v1/chat/completions');

			expect(response.headers.get('content-type')).toBe('application/json');
			// Stale metadata headers (content-length, etag, etc.) are not carried over
			expect(response.headers.get('content-length')).toBeNull();
		});
	});
});
