import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime';
import { ChatBedrockConverse } from '@langchain/aws';
import { makeN8nLlmFailedAttemptHandler, getNodeProxyAgent } from '@n8n/ai-utilities';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import {
	UserError,
	type ILoadOptionsFunctions,
	type INode,
	type ISupplyDataFunctions,
} from 'n8n-workflow';
import type { Mocked } from 'vitest';

import { resolveAwsCredentials } from '@utils/aws/resolveAwsCredentials';

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

vi.mock('@langchain/aws', () => ({
	ChatBedrockConverse: vi.fn(),
}));
vi.mock('@n8n/ai-utilities', () => ({
	getConnectionHintNoticeField: vi
		.fn()
		.mockReturnValue({ displayName: '', name: 'notice', type: 'notice', default: '' }),
	makeN8nLlmFailedAttemptHandler: vi.fn(),
	N8nLlmTracing: vi.fn(),
	getNodeProxyAgent: vi.fn(),
}));
vi.mock('@utils/aws/resolveAwsCredentials', () => ({
	resolveAwsCredentials: vi.fn(),
}));
vi.mock('@aws-sdk/client-bedrock-runtime', () => ({
	BedrockRuntimeClient: vi.fn(),
}));
vi.mock('@smithy/node-http-handler', () => ({
	NodeHttpHandler: vi.fn(),
}));

const MockedBedrockRuntimeClient = vi.mocked(BedrockRuntimeClient);
const MockedNodeHttpHandler = vi.mocked(NodeHttpHandler);
const MockedChatBedrockConverse = vi.mocked(ChatBedrockConverse);
const mockedMakeN8nLlmFailedAttemptHandler = vi.mocked(makeN8nLlmFailedAttemptHandler);
const mockedGetNodeProxyAgent = vi.mocked(getNodeProxyAgent);
const mockedResolveAwsCredentials = vi.mocked(resolveAwsCredentials);

const keepAliveAgentOptions = { keepAlive: true, keepAliveMsecs: 30_000 };

describe('LmChatAwsBedrock', () => {
	let node: LmChatAwsBedrock;
	let mockContext: Mocked<ISupplyDataFunctions>;

	const mockNode: INode = {
		id: '1',
		name: 'AWS Bedrock Chat Model',
		typeVersion: 1.1,
		type: 'n8n-nodes-langchain.lmChatAwsBedrock',
		position: [0, 0],
		parameters: {},
	};

	const defaultCredentials = {
		region: 'us-east-1',
		secretAccessKey: 'test-secret',
		accessKeyId: 'test-key',
		sessionToken: '',
	};

	const setupMockContext = (overrides: { credentials?: Record<string, unknown> } = {}) => {
		mockContext = createMockExecuteFunction<ISupplyDataFunctions>(
			{},
			mockNode,
		) as Mocked<ISupplyDataFunctions>;

		mockContext.getCredentials = vi
			.fn()
			.mockResolvedValue(overrides.credentials ?? defaultCredentials);
		mockContext.getNode = vi.fn().mockReturnValue(mockNode);
		//@ts-expect-error - Mocking
		mockContext.getNodeParameter = vi.fn();

		mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(vi.fn());
		mockedGetNodeProxyAgent.mockReturnValue(undefined);
		MockedBedrockRuntimeClient.mockImplementation(function () {
			return {};
		} as unknown as typeof BedrockRuntimeClient);
		MockedChatBedrockConverse.mockImplementation(function () {
			return {};
		} as unknown as typeof ChatBedrockConverse);
		const defaults = overrides.credentials ?? defaultCredentials;
		mockedResolveAwsCredentials.mockResolvedValue({
			region: defaults.region as string,
			credentials: {
				accessKeyId: defaults.accessKeyId as string,
				secretAccessKey: defaults.secretAccessKey as string,
			},
		});

		return mockContext;
	};

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

	describe('supplyData', () => {
		it('should use credential region for standard model IDs', async () => {
			const ctx = setupMockContext();
			ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
				if (paramName === 'model') return 'amazon.nova-pro-v1:0';
				if (paramName === 'options') return {};
				return undefined;
			});

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

			expect(MockedBedrockRuntimeClient).toHaveBeenCalledWith(
				expect.objectContaining({ region: 'us-east-1' }),
			);
			expect(MockedChatBedrockConverse).toHaveBeenCalledWith(
				expect.objectContaining({ region: 'us-east-1' }),
			);
		});

		it('should use credential region for inference profile IDs (not ARNs)', async () => {
			const ctx = setupMockContext();
			ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
				if (paramName === 'model') return 'eu.amazon.nova-pro-v1:0';
				if (paramName === 'options') return {};
				return undefined;
			});

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

			expect(MockedBedrockRuntimeClient).toHaveBeenCalledWith(
				expect.objectContaining({ region: 'us-east-1' }),
			);
			expect(MockedChatBedrockConverse).toHaveBeenCalledWith(
				expect.objectContaining({ region: 'us-east-1' }),
			);
		});

		it('should extract region from inference profile ARN and use it', async () => {
			const ctx = setupMockContext();
			ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
				if (paramName === 'model')
					return 'arn:aws:bedrock:eu-west-3:851725222089:inference-profile/eu.amazon.nova-pro-v1:0';
				if (paramName === 'options') return {};
				return undefined;
			});

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

			expect(MockedBedrockRuntimeClient).toHaveBeenCalledWith(
				expect.objectContaining({ region: 'eu-west-3' }),
			);
			expect(MockedChatBedrockConverse).toHaveBeenCalledWith(
				expect.objectContaining({ region: 'eu-west-3' }),
			);
		});

		it('should extract region from foundation model ARN', async () => {
			const ctx = setupMockContext();
			ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
				if (paramName === 'model')
					return 'arn:aws:bedrock:ap-southeast-1::foundation-model/anthropic.claude-v2';
				if (paramName === 'options') return {};
				return undefined;
			});

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

			expect(MockedBedrockRuntimeClient).toHaveBeenCalledWith(
				expect.objectContaining({ region: 'ap-southeast-1' }),
			);
			expect(MockedChatBedrockConverse).toHaveBeenCalledWith(
				expect.objectContaining({ region: 'ap-southeast-1' }),
			);
		});

		it('throws when the ARN-extracted region is not supported', async () => {
			const ctx = setupMockContext();
			ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
				if (paramName === 'model')
					return 'arn:aws:bedrock:not-a-region:123456789012:inference-profile/eu.amazon.nova-pro-v1:0';
				if (paramName === 'options') return {};
				return undefined;
			});

			const call = node.supplyData.call(ctx, 0);
			await expect(call).rejects.toThrow(UserError);
			await expect(call).rejects.toThrow('Unsupported AWS region');
			// Must reject before any client is constructed.
			expect(mockedGetNodeProxyAgent).not.toHaveBeenCalled();
			expect(MockedBedrockRuntimeClient).not.toHaveBeenCalled();
			expect(MockedChatBedrockConverse).not.toHaveBeenCalled();
		});

		it('extracts the region from a China (aws-cn) partition ARN and targets the .com.cn endpoint', async () => {
			const ctx = setupMockContext();
			ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
				if (paramName === 'model')
					return 'arn:aws-cn:bedrock:cn-north-1:123456789012:inference-profile/cn.amazon.nova-pro-v1:0';
				if (paramName === 'options') return {};
				return undefined;
			});

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

			expect(mockedGetNodeProxyAgent).toHaveBeenCalledWith(
				'https://bedrock-runtime.cn-north-1.amazonaws.com.cn',
				keepAliveAgentOptions,
			);
			expect(MockedBedrockRuntimeClient).toHaveBeenCalledWith(
				expect.objectContaining({ region: 'cn-north-1' }),
			);
			expect(MockedChatBedrockConverse).toHaveBeenCalledWith(
				expect.objectContaining({ region: 'cn-north-1' }),
			);
		});

		it('extracts the region from a GovCloud (aws-us-gov) partition ARN', async () => {
			const ctx = setupMockContext();
			ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
				if (paramName === 'model')
					return 'arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:inference-profile/us-gov.amazon.nova-pro-v1:0';
				if (paramName === 'options') return {};
				return undefined;
			});

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

			expect(mockedGetNodeProxyAgent).toHaveBeenCalledWith(
				'https://bedrock-runtime.us-gov-west-1.amazonaws.com',
				keepAliveAgentOptions,
			);
			expect(MockedBedrockRuntimeClient).toHaveBeenCalledWith(
				expect.objectContaining({ region: 'us-gov-west-1' }),
			);
		});

		it('should pass model name and options to ChatBedrockConverse', async () => {
			const ctx = setupMockContext();
			ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
				if (paramName === 'model') return 'amazon.nova-pro-v1:0';
				if (paramName === 'options') return { temperature: 0.5, maxTokensToSample: 1000 };
				return undefined;
			});

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

			expect(MockedChatBedrockConverse).toHaveBeenCalledWith(
				expect.objectContaining({
					model: 'amazon.nova-pro-v1:0',
					temperature: 0.5,
					maxTokens: 1000,
				}),
			);
		});

		describe('inference parameters', () => {
			it('forwards all set inference options to ChatBedrockConverse', async () => {
				const ctx = setupMockContext();
				ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
					if (paramName === 'model') return 'amazon.nova-pro-v1:0';
					if (paramName === 'options')
						return {
							topP: 0.9,
							maxRetries: 5,
							latency: 'optimized',
							additionalModelRequestFields: '{"top_k": 50}',
							guardrail: {
								values: {
									guardrailIdentifier: 'gr-123',
									guardrailVersion: '2',
									trace: 'enabled',
								},
							},
						};
					return undefined;
				});

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

				expect(MockedChatBedrockConverse).toHaveBeenCalledWith(
					expect.objectContaining({
						topP: 0.9,
						performanceConfig: { latency: 'optimized' },
						guardrailConfig: {
							guardrailIdentifier: 'gr-123',
							guardrailVersion: '2',
							trace: 'enabled',
						},
						additionalModelRequestFields: { top_k: 50 },
					}),
				);
				// Retries are configured on the SDK client, since ChatBedrockConverse
				// bypasses LangChain's retrying caller.
				expect(MockedBedrockRuntimeClient).toHaveBeenCalledWith(
					expect.objectContaining({ maxAttempts: 6 }),
				);
			});

			it('maps Max Retries 0 to a single SDK attempt', async () => {
				const ctx = setupMockContext();
				ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
					if (paramName === 'model') return 'amazon.nova-pro-v1:0';
					if (paramName === 'options') return { maxRetries: 0 };
					return undefined;
				});

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

				expect(MockedBedrockRuntimeClient).toHaveBeenCalledWith(
					expect.objectContaining({ maxAttempts: 1 }),
				);
			});

			it('omits unset inference options so model defaults are preserved', async () => {
				const ctx = setupMockContext();
				ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
					if (paramName === 'model') return 'amazon.nova-pro-v1:0';
					if (paramName === 'options') return {};
					return undefined;
				});

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

				const config = MockedChatBedrockConverse.mock.calls.at(-1)?.[0] ?? {};
				expect(config).not.toHaveProperty('topP');
				expect(config).not.toHaveProperty('maxRetries');
				expect(config).not.toHaveProperty('performanceConfig');
				expect(config).not.toHaveProperty('guardrailConfig');
				expect(config).not.toHaveProperty('additionalModelRequestFields');
				// Unset Max Retries keeps the SDK's default retry strategy.
				const clientConfig = MockedBedrockRuntimeClient.mock.calls.at(-1)?.[0] ?? {};
				expect(clientConfig).not.toHaveProperty('maxAttempts');
			});

			it('throws a UserError when additionalModelRequestFields is invalid JSON', async () => {
				const ctx = setupMockContext();
				ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
					if (paramName === 'model') return 'amazon.nova-pro-v1:0';
					if (paramName === 'options') return { additionalModelRequestFields: 'not json' };
					return undefined;
				});

				const call = node.supplyData.call(ctx, 0);
				await expect(call).rejects.toThrow(UserError);
				await expect(call).rejects.toThrow('Additional Model Request Fields must be valid JSON');
				expect(MockedChatBedrockConverse).not.toHaveBeenCalled();
			});

			it('does not forward additionalModelRequestFields when left as the default empty object', async () => {
				const ctx = setupMockContext();
				ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
					if (paramName === 'model') return 'amazon.nova-pro-v1:0';
					if (paramName === 'options') return { additionalModelRequestFields: '{}' };
					return undefined;
				});

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

				const config = MockedChatBedrockConverse.mock.calls.at(-1)?.[0] ?? {};
				expect(config).not.toHaveProperty('additionalModelRequestFields');
			});

			it('does not set guardrailConfig when no guardrail identifier is provided', async () => {
				const ctx = setupMockContext();
				ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
					if (paramName === 'model') return 'amazon.nova-pro-v1:0';
					if (paramName === 'options')
						return { guardrail: { values: { guardrailIdentifier: '', trace: 'enabled' } } };
					return undefined;
				});

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

				const config = MockedChatBedrockConverse.mock.calls.at(-1)?.[0] ?? {};
				expect(config).not.toHaveProperty('guardrailConfig');
			});

			it.each([
				['left blank', ''],
				['whitespace-only', '   '],
			])('defaults the guardrail version to DRAFT when %s', async (_case, version) => {
				const ctx = setupMockContext();
				ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
					if (paramName === 'model') return 'amazon.nova-pro-v1:0';
					if (paramName === 'options')
						return {
							guardrail: { values: { guardrailIdentifier: 'gr-123', guardrailVersion: version } },
						};
					return undefined;
				});

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

				expect(MockedChatBedrockConverse).toHaveBeenCalledWith(
					expect.objectContaining({
						guardrailConfig: { guardrailIdentifier: 'gr-123', guardrailVersion: 'DRAFT' },
					}),
				);
			});

			it('passes an inference-profile ARN model ID through verbatim without building a URL', async () => {
				const arn =
					'arn:aws:bedrock:eu-west-3:123456789012:inference-profile/eu.amazon.nova-pro-v1:0';
				const ctx = setupMockContext();
				ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
					if (paramName === 'model') return arn;
					if (paramName === 'options') return {};
					return undefined;
				});

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

				expect(MockedChatBedrockConverse).toHaveBeenCalledWith(
					expect.objectContaining({ model: arn }),
				);
				// Model ID flows only through the model config, never a hand-built endpoint URL.
				const clientConfig = MockedBedrockRuntimeClient.mock.calls.at(-1)?.[0] ?? {};
				expect(clientConfig).not.toHaveProperty('endpoint');
				expect(mockedGetNodeProxyAgent).toHaveBeenCalledWith(
					'https://bedrock-runtime.eu-west-3.amazonaws.com',
					keepAliveAgentOptions,
				);
			});
		});

		describe('request handler (keepalive, timeout & proxy)', () => {
			const setNodeParameters = (options: Record<string, unknown>) => {
				mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
					if (paramName === 'model') return 'amazon.nova-pro-v1:0';
					if (paramName === 'options') return options;
					return undefined;
				});
			};

			it('creates a keepalive handler with requestTimeout when timeout is set and no proxy is configured', async () => {
				const ctx = setupMockContext();
				setNodeParameters({ timeout: 120000 });

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

				expect(MockedNodeHttpHandler).toHaveBeenCalledWith({
					httpAgent: keepAliveAgentOptions,
					httpsAgent: keepAliveAgentOptions,
					requestTimeout: 120000,
					throwOnRequestTimeout: true,
				});
				const clientConfig = MockedBedrockRuntimeClient.mock.calls.at(-1)?.[0];
				expect(clientConfig?.requestHandler).toBe(MockedNodeHttpHandler.mock.instances.at(-1));
			});

			it('merges timeout and proxy agents into a single handler', async () => {
				const ctx = setupMockContext();
				const fakeAgent = { fake: 'agent' } as unknown as ReturnType<typeof getNodeProxyAgent>;
				mockedGetNodeProxyAgent.mockReturnValue(fakeAgent);
				setNodeParameters({ timeout: 30000 });

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

				expect(MockedNodeHttpHandler).toHaveBeenCalledTimes(1);
				expect(MockedNodeHttpHandler).toHaveBeenCalledWith({
					httpAgent: fakeAgent,
					httpsAgent: fakeAgent,
					requestTimeout: 30000,
					throwOnRequestTimeout: true,
				});
			});

			it('requests keepalive on the proxy agent', async () => {
				const ctx = setupMockContext();
				const fakeAgent = { fake: 'agent' } as unknown as ReturnType<typeof getNodeProxyAgent>;
				mockedGetNodeProxyAgent.mockReturnValue(fakeAgent);
				setNodeParameters({});

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

				expect(mockedGetNodeProxyAgent).toHaveBeenCalledWith(
					'https://bedrock-runtime.us-east-1.amazonaws.com',
					keepAliveAgentOptions,
				);
			});

			it('creates a proxy-only handler without timeout keys when timeout is unset', async () => {
				const ctx = setupMockContext();
				const fakeAgent = { fake: 'agent' } as unknown as ReturnType<typeof getNodeProxyAgent>;
				mockedGetNodeProxyAgent.mockReturnValue(fakeAgent);
				setNodeParameters({});

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

				expect(MockedNodeHttpHandler).toHaveBeenCalledTimes(1);
				const handlerOptions = MockedNodeHttpHandler.mock.calls.at(-1)?.[0] ?? {};
				expect(handlerOptions).toEqual({ httpAgent: fakeAgent, httpsAgent: fakeAgent });
				expect(handlerOptions).not.toHaveProperty('requestTimeout');
				expect(handlerOptions).not.toHaveProperty('throwOnRequestTimeout');
			});

			it('installs a keepalive handler even when neither timeout nor proxy is configured', async () => {
				const ctx = setupMockContext();
				setNodeParameters({});

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

				expect(MockedNodeHttpHandler).toHaveBeenCalledTimes(1);
				const handlerOptions = MockedNodeHttpHandler.mock.calls.at(-1)?.[0] ?? {};
				expect(handlerOptions).toEqual({
					httpAgent: keepAliveAgentOptions,
					httpsAgent: keepAliveAgentOptions,
				});
				expect(handlerOptions).not.toHaveProperty('requestTimeout');
				expect(handlerOptions).not.toHaveProperty('throwOnRequestTimeout');
				const clientConfig = MockedBedrockRuntimeClient.mock.calls.at(-1)?.[0];
				expect(clientConfig?.requestHandler).toBe(MockedNodeHttpHandler.mock.instances.at(-1));
			});

			it('passes timeout 0 through as a disabled requestTimeout', async () => {
				const ctx = setupMockContext();
				setNodeParameters({ timeout: 0 });

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

				expect(MockedNodeHttpHandler).toHaveBeenCalledWith(
					expect.objectContaining({
						requestTimeout: 0,
						throwOnRequestTimeout: true,
					}),
				);
			});
		});

		describe('AssumeRole wiring', () => {
			it('constructs BedrockRuntimeClient with the provider returned by resolveAwsCredentials', async () => {
				const ctx = setupMockContext();
				ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
					if (paramName === 'model') return 'anthropic.claude-3-sonnet-20240229-v1:0';
					if (paramName === 'options') return {};
					return undefined;
				});
				const fakeProvider = vi.fn().mockResolvedValue({
					accessKeyId: 'ASIA_STUB',
					secretAccessKey: 'secret',
					sessionToken: 'token',
				});
				mockedResolveAwsCredentials.mockResolvedValue({
					region: 'us-east-1',
					credentials: fakeProvider,
				});

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

				expect(mockedResolveAwsCredentials).toHaveBeenCalledTimes(1);
				const lastConfig = MockedBedrockRuntimeClient.mock.calls.at(-1)?.[0];
				expect(lastConfig?.credentials).toBe(fakeProvider);
				expect(lastConfig?.region).toBe('us-east-1');
			});

			it('passes the supply item index to resolveAwsCredentials', async () => {
				const ctx = setupMockContext();
				ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
					if (paramName === 'model') return 'anthropic.claude-3-sonnet-20240229-v1:0';
					if (paramName === 'options') return {};
					return undefined;
				});

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

				expect(mockedResolveAwsCredentials).toHaveBeenCalledWith(ctx, 4);
			});

			it('wires the concrete Bedrock endpoint into getNodeProxyAgent', async () => {
				const ctx = setupMockContext();
				ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
					if (paramName === 'model') return 'anthropic.claude-3-sonnet-20240229-v1:0';
					if (paramName === 'options') return {};
					return undefined;
				});
				mockedResolveAwsCredentials.mockResolvedValue({
					region: 'eu-central-1',
					credentials: { accessKeyId: 'a', secretAccessKey: 'b' },
				});

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

				expect(mockedGetNodeProxyAgent).toHaveBeenCalledWith(
					'https://bedrock-runtime.eu-central-1.amazonaws.com',
					keepAliveAgentOptions,
				);
			});
		});

		describe('runtime endpoint override', () => {
			const useModel = (ctx: Mocked<ISupplyDataFunctions>) => {
				ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
					if (paramName === 'model') return 'amazon.nova-pro-v1:0';
					if (paramName === 'options') return {};
					return undefined;
				});
			};

			it('routes inference and the proxy agent to the override endpoint when set', async () => {
				const ctx = setupMockContext();
				useModel(ctx);
				mockedResolveAwsCredentials.mockResolvedValue({
					region: 'us-east-1',
					credentials: { accessKeyId: 'a', secretAccessKey: 'b' },
					bedrockRuntimeEndpoint: 'https://vpce-abc.bedrock-runtime.us-east-1.vpce.amazonaws.com',
				});

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

				const expected = 'https://vpce-abc.bedrock-runtime.us-east-1.vpce.amazonaws.com';
				expect(mockedGetNodeProxyAgent).toHaveBeenCalledWith(expected, keepAliveAgentOptions);
				expect(MockedBedrockRuntimeClient.mock.calls.at(-1)?.[0]?.endpoint).toBe(expected);
			});

			it('leaves the SDK endpoint unset when no override is present', async () => {
				const ctx = setupMockContext();
				useModel(ctx);

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

				expect(MockedBedrockRuntimeClient.mock.calls.at(-1)?.[0]?.endpoint).toBeUndefined();
				expect(mockedGetNodeProxyAgent).toHaveBeenCalledWith(
					'https://bedrock-runtime.us-east-1.amazonaws.com',
					keepAliveAgentOptions,
				);
			});

			it('throws a UserError for an invalid override', async () => {
				const ctx = setupMockContext();
				useModel(ctx);
				mockedResolveAwsCredentials.mockResolvedValue({
					region: 'us-east-1',
					credentials: { accessKeyId: 'a', secretAccessKey: 'b' },
					bedrockRuntimeEndpoint: 'ftp://bedrock-runtime.us-east-1.amazonaws.com',
				});

				await expect(node.supplyData.call(ctx, 0)).rejects.toThrow(UserError);
			});
		});
	});

	describe('listModels', () => {
		const foundationResponse = {
			modelSummaries: [
				{
					modelId: 'amazon.nova-pro-v1:0',
					modelName: 'Nova Pro',
					modelArn: 'arn:aws:bedrock:eu-central-1::foundation-model/amazon.nova-pro-v1:0',
				},
				{
					modelId: 'anthropic.claude-3-haiku-20240307-v1:0',
					modelName: 'Claude 3 Haiku',
					modelArn: 'arn:aws:bedrock:eu-central-1::foundation-model/anthropic.claude-3-haiku',
				},
			],
		};
		const profilesResponse = {
			inferenceProfileSummaries: [
				{
					inferenceProfileId: 'eu.anthropic.claude-sonnet-4-6-v1:0',
					inferenceProfileName: 'EU Anthropic Claude Sonnet 4.6',
					inferenceProfileArn:
						'arn:aws:bedrock:eu-central-1:123456789012:inference-profile/eu.anthropic.claude-sonnet-4-6-v1:0',
					description: 'Routes requests across EU regions',
				},
			],
		};

		const createLoadOptionsContext = (
			authentication: 'iam' | 'assumeRole',
			httpMock: ReturnType<typeof vi.fn>,
		) =>
			({
				getNodeParameter: vi.fn().mockReturnValue(authentication),
				getCredentials: vi.fn().mockResolvedValue({ region: 'eu-central-1' }),
				helpers: { httpRequestWithAuthentication: httpMock },
				logger: { warn: vi.fn() },
			}) as unknown as ILoadOptionsFunctions;

		const applicationProfilesResponse = {
			inferenceProfileSummaries: [
				{
					inferenceProfileId: 'kqtghn90uu4g',
					inferenceProfileName: 'Cost Centre Claude',
					inferenceProfileArn:
						'arn:aws:bedrock:eu-central-1:123456789012:application-inference-profile/kqtghn90uu4g',
				},
			],
		};

		const httpMockFor = (
			foundation: Promise<unknown> | unknown,
			profiles: Promise<unknown> | unknown,
			applicationProfiles: Promise<unknown> | unknown = { inferenceProfileSummaries: [] },
		) =>
			vi.fn().mockImplementation(async (_credentialsType: string, options: { url: string }) => {
				if (options.url.startsWith('/foundation-models')) return await foundation;
				return options.url.includes('type=APPLICATION')
					? await applicationProfiles
					: await profiles;
			});

		it('merges foundation models and inference profiles into one sorted list', async () => {
			const httpMock = httpMockFor(
				foundationResponse,
				profilesResponse,
				applicationProfilesResponse,
			);
			const ctx = createLoadOptionsContext('iam', httpMock);

			const options = await node.methods.loadOptions.listModels.call(ctx);

			expect(options).toEqual([
				{
					name: 'Claude 3 Haiku',
					value: 'anthropic.claude-3-haiku-20240307-v1:0',
					// eslint-disable-next-line n8n-nodes-base/node-param-description-lowercase-first-char
					description: 'arn:aws:bedrock:eu-central-1::foundation-model/anthropic.claude-3-haiku',
				},
				{
					name: 'Cost Centre Claude',
					value: 'kqtghn90uu4g',
					// eslint-disable-next-line n8n-nodes-base/node-param-description-lowercase-first-char
					description:
						'arn:aws:bedrock:eu-central-1:123456789012:application-inference-profile/kqtghn90uu4g',
				},
				{
					name: 'EU Anthropic Claude Sonnet 4.6',
					value: 'eu.anthropic.claude-sonnet-4-6-v1:0',
					description: 'Routes requests across EU regions',
				},
				{
					name: 'Nova Pro',
					value: 'amazon.nova-pro-v1:0',
					// eslint-disable-next-line n8n-nodes-base/node-param-description-lowercase-first-char
					description: 'arn:aws:bedrock:eu-central-1::foundation-model/amazon.nova-pro-v1:0',
				},
			]);
			expect(httpMock).toHaveBeenCalledWith(
				'aws',
				expect.objectContaining({
					baseURL: 'https://bedrock.eu-central-1.amazonaws.com',
					url: '/foundation-models?&byOutputModality=TEXT&byInferenceType=ON_DEMAND',
				}),
			);
			expect(httpMock).toHaveBeenCalledWith(
				'aws',
				expect.objectContaining({ url: '/inference-profiles?maxResults=1000' }),
			);
			expect(httpMock).toHaveBeenCalledWith(
				'aws',
				expect.objectContaining({
					url: '/inference-profiles?maxResults=1000&type=APPLICATION',
				}),
			);
		});

		it('uses the awsAssumeRole credential when authentication is assumeRole', async () => {
			const httpMock = httpMockFor(foundationResponse, profilesResponse);
			const ctx = createLoadOptionsContext('assumeRole', httpMock);

			await node.methods.loadOptions.listModels.call(ctx);

			expect(ctx.getCredentials).toHaveBeenCalledWith('awsAssumeRole');
			expect(httpMock).toHaveBeenCalledWith('awsAssumeRole', expect.anything());
		});

		it('still lists foundation models and warns when the inference-profiles requests fail', async () => {
			const failure = new Error('AccessDenied');
			const httpMock = httpMockFor(
				foundationResponse,
				Promise.reject(failure),
				Promise.reject(failure),
			);
			const ctx = createLoadOptionsContext('iam', httpMock);

			const options = await node.methods.loadOptions.listModels.call(ctx);

			expect(options.map((o) => o.value)).toEqual([
				'anthropic.claude-3-haiku-20240307-v1:0',
				'amazon.nova-pro-v1:0',
			]);
			expect(ctx.logger.warn).toHaveBeenCalledWith(
				'Bedrock model listing: inference-profiles request failed',
				{ error: failure },
			);
		});

		it('still lists inference profiles and warns when the foundation-models request fails', async () => {
			const failure = new Error('AccessDenied');
			const httpMock = httpMockFor(Promise.reject(failure), profilesResponse);
			const ctx = createLoadOptionsContext('iam', httpMock);

			const options = await node.methods.loadOptions.listModels.call(ctx);

			expect(options.map((o) => o.value)).toEqual(['eu.anthropic.claude-sonnet-4-6-v1:0']);
			expect(ctx.logger.warn).toHaveBeenCalledWith(
				'Bedrock model listing: foundation-models request failed',
				{ error: failure },
			);
		});

		it('throws when both list requests fail', async () => {
			const failure = new Error('AccessDenied');
			const httpMock = httpMockFor(
				Promise.reject(failure),
				Promise.reject(new Error('other')),
				Promise.reject(new Error('other')),
			);
			const ctx = createLoadOptionsContext('iam', httpMock);

			await expect(node.methods.loadOptions.listModels.call(ctx)).rejects.toThrow('AccessDenied');
		});

		it('still lists application profiles when only the system-defined request fails', async () => {
			const failure = new Error('Throttled');
			const httpMock = httpMockFor(
				foundationResponse,
				Promise.reject(failure),
				applicationProfilesResponse,
			);
			const ctx = createLoadOptionsContext('iam', httpMock);

			const options = await node.methods.loadOptions.listModels.call(ctx);

			expect(options.map((o) => o.value)).toEqual([
				'anthropic.claude-3-haiku-20240307-v1:0',
				'kqtghn90uu4g',
				'amazon.nova-pro-v1:0',
			]);
			expect(ctx.logger.warn).toHaveBeenCalledWith(
				'Bedrock model listing: inference-profiles request failed',
				{ error: failure, profileType: 'SYSTEM_DEFINED' },
			);
		});

		it('rejects an unsupported credential region before any request is made', async () => {
			const httpMock = httpMockFor(foundationResponse, profilesResponse);
			const ctx = createLoadOptionsContext('iam', httpMock);
			ctx.getCredentials = vi.fn().mockResolvedValue({ region: 'not-a-region' });

			await expect(node.methods.loadOptions.listModels.call(ctx)).rejects.toThrow(
				'Unsupported AWS region',
			);
			expect(httpMock).not.toHaveBeenCalled();
		});

		it('uses the China partition domain for cn regions', async () => {
			const httpMock = httpMockFor(foundationResponse, profilesResponse);
			const ctx = createLoadOptionsContext('iam', httpMock);
			ctx.getCredentials = vi.fn().mockResolvedValue({ region: 'cn-north-1' });

			await node.methods.loadOptions.listModels.call(ctx);

			expect(httpMock).toHaveBeenCalledWith(
				'aws',
				expect.objectContaining({ baseURL: 'https://bedrock.cn-north-1.amazonaws.com.cn' }),
			);
		});

		it('is exposed as version 1.2 with the merged picker as default', () => {
			expect(node.description.version).toEqual([1, 1.1, 1.2]);
			const mergedModelField = node.description.properties.find(
				(p) => p.name === 'model' && p.typeOptions?.loadOptionsMethod === 'listModels',
			);
			expect(mergedModelField?.displayOptions?.show?.['@version']).toEqual([
				{ _cnd: { gte: 1.2 } },
			]);
			// The legacy source toggle must not render on 1.2
			const modelSource = node.description.properties.find((p) => p.name === 'modelSource');
			expect(modelSource?.displayOptions?.show?.['@version']).toEqual([{ _cnd: { eq: 1.1 } }]);
		});

		describe('listInferenceProfiles (pre-1.2 picker)', () => {
			it('lists system-defined and application profiles without foundation models', async () => {
				const httpMock = httpMockFor(
					foundationResponse,
					profilesResponse,
					applicationProfilesResponse,
				);
				const ctx = createLoadOptionsContext('iam', httpMock);

				const options = await node.methods.loadOptions.listInferenceProfiles.call(ctx);

				expect(options.map((o) => o.value)).toEqual([
					'kqtghn90uu4g',
					'eu.anthropic.claude-sonnet-4-6-v1:0',
				]);
				expect(httpMock).not.toHaveBeenCalledWith(
					'aws',
					expect.objectContaining({ url: expect.stringContaining('/foundation-models') }),
				);
			});

			it('throws when both profile requests fail', async () => {
				const failure = new Error('AccessDenied');
				const httpMock = httpMockFor(
					foundationResponse,
					Promise.reject(failure),
					Promise.reject(failure),
				);
				const ctx = createLoadOptionsContext('iam', httpMock);

				await expect(node.methods.loadOptions.listInferenceProfiles.call(ctx)).rejects.toThrow(
					'AccessDenied',
				);
			});
		});
	});
});
