import { OutboundHttp } from '@n8n/backend-network';
import type { HttpRequestClient } from '@n8n/backend-network';
import { Container } from '@n8n/di';
import { RoutingNode, UnrecognizedNodeTypeError } from 'n8n-core';
import type {
	ICredentialTestFunctions,
	ICredentialType,
	INode,
	INodeType,
	IWorkflowExecuteAdditionalData,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';

import type { CredentialTypes } from '@/credential-types';
import type { CredentialsHelper } from '@/credentials-helper';
import type { NodeTypes } from '@/node-types';
import {
	AUTH_PROBE_ACCEPTED_MESSAGE,
	CredentialsTester,
} from '@/services/credentials-tester.service';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';

vi.mock('n8n-core', async (importOriginal) => {
	const actual = await importOriginal<typeof import('n8n-core')>();
	return { ...actual, RoutingNode: vi.fn() };
});

describe('CredentialsTester', () => {
	const credentialTypes = mock<CredentialTypes>();
	const nodeTypes = mock<NodeTypes>();
	const credentialsHelper = mock<CredentialsHelper>();
	const credentialsTester = new CredentialsTester(
		mock(),
		mock(),
		credentialTypes,
		nodeTypes,
		credentialsHelper,
	);

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

	it('should find the OAuth2 credential test for a generic OAuth2 API credential', () => {
		credentialTypes.getByName.mockReturnValue(mock<ICredentialType>({ test: undefined }));
		credentialTypes.getSupportedNodes.mockReturnValue(['oAuth2Api']);
		credentialTypes.getParentTypes.mockReturnValue([]);
		nodeTypes.getByName.mockReturnValue(
			mock<INodeType>({
				description: { credentials: [{ name: 'oAuth2Api' }] },
			}),
		);

		const testFn = credentialsTester.getCredentialTestFunction('oAuth2Api');

		if (typeof testFn !== 'function') expect.fail();

		expect(testFn.name).toBe('oauth2CredTest');
	});

	it('should keep resolving supported nodes past one the registry cannot load', () => {
		credentialTypes.getByName.mockReturnValue(mock<ICredentialType>({ test: undefined }));
		// `graphqlTool` is a synthetic tool variant appended to `supportedNodes` by
		// tool generation; `getByName` does not fabricate those, so it throws.
		credentialTypes.getSupportedNodes.mockReturnValue(['graphqlTool', 'graphql']);
		credentialTypes.getParentTypes.mockReturnValue([]);
		const testRequest = { request: { url: '/me' } };
		nodeTypes.getByName.mockImplementation((nodeName: string) => {
			if (nodeName === 'graphqlTool') {
				throw new UnrecognizedNodeTypeError('n8n-nodes-base', 'graphqlTool');
			}
			return mock<INodeType>({
				description: { credentials: [{ name: 'httpHeaderAuth', testedBy: testRequest }] },
			});
		});

		const testFn = credentialsTester.getCredentialTestFunction('httpHeaderAuth');

		expect(testFn).toEqual(expect.objectContaining({ testRequest }));
	});

	it('should report no testing function when every supported node fails to load', async () => {
		credentialTypes.getByName.mockReturnValue(mock<ICredentialType>({ test: undefined }));
		credentialTypes.getSupportedNodes.mockReturnValue(['graphqlTool']);
		credentialTypes.getParentTypes.mockReturnValue([]);
		nodeTypes.getByName.mockImplementation(() => {
			throw new UnrecognizedNodeTypeError('n8n-nodes-base', 'graphqlTool');
		});

		const result = await credentialsTester.testCredentials('user-1', 'httpHeaderAuth', {
			id: 'cred-1',
			name: 'Header Auth account',
			type: 'httpHeaderAuth',
			data: {},
		});

		expect(result).toEqual({
			status: 'Error',
			message: 'No testing function found for this credential.',
		});
	});

	describe('testCredentials', () => {
		let mockTestFunction: Mock;

		beforeEach(() => {
			mockTestFunction = vi.fn();
			credentialTypes.getByName.mockReturnValue(mock<ICredentialType>({ test: undefined }));
			credentialTypes.getSupportedNodes.mockReturnValue(['testCredentials']);
			credentialTypes.getParentTypes.mockReturnValue([]);
			nodeTypes.getByName.mockReturnValue(
				mock<INodeType>({
					methods: {
						credentialTest: {
							testCredentialsFunction: mockTestFunction,
						},
					},
					description: {
						credentials: [{ name: 'testCredentials', testedBy: 'testCredentialsFunction' }],
					},
				}),
			);
			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue(
				{} as IWorkflowExecuteAdditionalData,
			);
		});

		it('should redact secrets in error messages', async () => {
			mockTestFunction.mockResolvedValue({
				status: 'Error',
				message: 'Test failed for apiKey secret_api_key',
			});

			const computedCredentialsData = {
				testNestedData: {
					access_token: 'abc123',
					secretData: {
						apiKey: 'secret_api_key',
					},
				},
			};
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(computedCredentialsData);

			const rawCredentialsData = {
				...computedCredentialsData,
				testNestedData: {
					...computedCredentialsData.testNestedData,
					secretData: {
						apiKey: '{{ $secrets.apiKey }}',
					},
				},
			};
			const redactedMessage = await credentialsTester.testCredentials(
				'user-id',
				'testCredentials',
				{
					id: 'credential-id',
					name: 'credential-name',
					type: 'oAuth2Api',
					data: rawCredentialsData,
				},
			);

			expect(redactedMessage.status).toBe('Error');
			expect(redactedMessage.message).toBe('Test failed for apiKey *****key');
		});

		it('should redact secrets for bracket-notation external secret expressions', async () => {
			mockTestFunction.mockResolvedValue({
				status: 'Error',
				message: 'Test failed for apiKey secret_api_key',
			});

			const computedCredentialsData = {
				testNestedData: {
					access_token: 'abc123',
					secretData: {
						apiKey: 'secret_api_key',
					},
				},
			};
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(computedCredentialsData);

			const rawCredentialsData = {
				...computedCredentialsData,
				testNestedData: {
					...computedCredentialsData.testNestedData,
					secretData: {
						apiKey: "={{ $secrets['vault']['apiKey'] }}",
					},
				},
			};
			const redactedMessage = await credentialsTester.testCredentials(
				'user-id',
				'testCredentials',
				{
					id: 'credential-id',
					name: 'credential-name',
					type: 'oAuth2Api',
					data: rawCredentialsData,
				},
			);

			expect(redactedMessage.status).toBe('Error');
			expect(redactedMessage.message).toBe('Test failed for apiKey *****key');
		});

		// A node-defined credential test function may issue an outbound request to
		// a credential-supplied host. `testCredentials` must hand it a context whose
		// legacy `this.helpers.request` helper routes through the default (safe)
		// `OutboundHttp.requests()` client, so the test honours the same egress
		// policy as node execution.
		it('routes a function-based credential test through the default safe client', async () => {
			const requestLegacy = vi.fn().mockResolvedValue('ok');
			const requests = vi.fn().mockReturnValue(mock<HttpRequestClient>({ requestLegacy }));
			Container.set(OutboundHttp, mock<OutboundHttp>({ requests }));

			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue(
				mock<IWorkflowExecuteAdditionalData>(),
			);

			mockTestFunction.mockImplementation(async function (this: ICredentialTestFunctions) {
				await this.helpers.request({ uri: 'http://internal-service.local/api' });
				return { status: 'OK', message: 'ok' };
			});
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue({ baseUrl: 'http://host' });

			await credentialsTester.testCredentials('user-id', 'testCredentials', {
				id: 'credential-id',
				name: 'credential-name',
				type: 'oAuth2Api',
				data: { baseUrl: 'http://host' },
			});

			expect(mockTestFunction).toHaveBeenCalled();
			expect(requests).toHaveBeenCalledWith();
		});

		it('should keep function-based tests working with the real routing engine untouched', async () => {
			mockTestFunction.mockResolvedValue({ status: 'OK', message: 'fine' });
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue({});

			const result = await credentialsTester.testCredentials('user-id', 'testCredentials', {
				id: 'credential-id',
				name: 'credential-name',
				type: 'testCredentials',
				data: {},
			});

			expect(result).toEqual({ status: 'OK', message: 'fine' });
			expect(RoutingNode).not.toHaveBeenCalled();
		});

		it('should not redact secrets with value shorter than 3 characters', async () => {
			mockTestFunction.mockResolvedValue({
				status: 'Error',
				message: 'Test failed for apiKey se',
			});

			const computedCredentialsData = {
				testNestedData: {
					access_token: 'abc123',
					secretData: {
						apiKey: 'se',
					},
				},
			};
			credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(computedCredentialsData);

			const rawCredentialsData = {
				...computedCredentialsData,
				testNestedData: {
					...computedCredentialsData.testNestedData,
					apiKey: '{{ $secrets.apiKey }}',
				},
			};
			const redactedMessage = await credentialsTester.testCredentials(
				'user-id',
				'testCredentials',
				{
					id: 'credential-id',
					name: 'credential-name',
					type: 'oAuth2Api',
					data: rawCredentialsData,
				},
			);

			expect(redactedMessage.status).toBe('Error');
			expect(redactedMessage.message).toBe('Test failed for apiKey se');
		});
	});

	describe('probeCredentialAuth', () => {
		const targetUrl = 'https://fal.run/fal-ai/flux/schnell';
		const credentials = () => ({
			id: 'credential-id',
			name: 'fal.ai API Key',
			type: 'httpHeaderAuth',
			data: { name: 'Authorization', value: 'Key abc' },
		});

		function mockRoutingNodeResult(outcome: { reject?: unknown; resolve?: unknown }) {
			// Regular function — RoutingNode is instantiated with `new`.
			(RoutingNode as unknown as Mock).mockImplementation(function () {
				return {
					runNode: outcome.reject
						? vi.fn().mockRejectedValue(outcome.reject)
						: vi.fn().mockResolvedValue(outcome.resolve ?? [[{ json: {} }]]),
				};
			});
		}

		function httpError(status: number) {
			const error = new Error(`Request failed with status code ${status}`);
			(error as Error & { cause: unknown }).cause = {
				response: { status, statusText: `HTTP ${status}` },
			};
			return error;
		}

		beforeEach(() => {
			vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue(
				{} as IWorkflowExecuteAdditionalData,
			);
			credentialsHelper.applyDefaultsAndOverwrites.mockImplementation(async (_base, data) => data);
			nodeTypes.getByNameAndVersion.mockReturnValue(
				mock<INodeType>({
					description: { name: 'n8n-nodes-base.noOp', version: 1, properties: [] },
				}),
			);
		});

		it('probes the target URL with the credential applied via the routing engine', async () => {
			mockRoutingNodeResult({ resolve: [[{ json: {} }]] });

			const result = await credentialsTester.probeCredentialAuth(
				'user-id',
				'httpHeaderAuth',
				credentials(),
				targetUrl,
			);

			expect(result.status).toBe('OK');
			// Some services answer 2xx regardless of the credential, so the green
			// verdict states what happened instead of claiming verification.
			expect(result.message).toBe(AUTH_PROBE_ACCEPTED_MESSAGE);
			expect(result.outcome).toBe('accepted');
			const nodeTypeArg = (RoutingNode as unknown as Mock).mock.calls[0][1] as INodeType;
			expect(nodeTypeArg.description.properties[0].routing?.request).toEqual({
				url: targetUrl,
				method: 'GET',
			});
		});

		it('pins the probe request to the target host', async () => {
			mockRoutingNodeResult({ resolve: [[{ json: {} }]] });

			await credentialsTester.probeCredentialAuth(
				'user-id',
				'httpHeaderAuth',
				credentials(),
				targetUrl,
				{ allowedDomains: 'fal.run' },
			);

			const nodeTypeArg = (RoutingNode as unknown as Mock).mock.calls[0][1] as INodeType;
			expect(nodeTypeArg.description.properties[0].routing?.request).toEqual({
				url: targetUrl,
				method: 'GET',
				allowedDomains: 'fal.run',
			});
		});

		it('fails only on an explicit auth rejection', async () => {
			mockRoutingNodeResult({ reject: httpError(401) });

			const result = await credentialsTester.probeCredentialAuth(
				'user-id',
				'httpHeaderAuth',
				credentials(),
				targetUrl,
			);

			expect(result.status).toBe('Error');
			expect(result.message).toContain('401');
			expect(result.outcome).toBe('rejected');
		});

		it('reports a non-auth error response as unverifiable instead of success', async () => {
			mockRoutingNodeResult({ reject: httpError(405) });

			const result = await credentialsTester.probeCredentialAuth(
				'user-id',
				'httpHeaderAuth',
				credentials(),
				targetUrl,
			);

			expect(result.status).toBe('Error');
			expect(result.message).toContain('405');
			expect(result.message).toContain('could not be verified');
			expect(result.outcome).toBe('unverified');
		});

		it('accepts a declared service-specific status code instead of rejecting on it', async () => {
			mockRoutingNodeResult({ reject: httpError(401) });

			const result = await credentialsTester.probeCredentialAuth(
				'user-id',
				'httpHeaderAuth',
				credentials(),
				targetUrl,
				{ acceptedStatusCodes: [401] },
			);

			expect(result.status).toBe('OK');
			expect(result.outcome).toBe('accepted');
		});

		// The routing engine wraps HTTP failures in NodeApiError: the status is
		// the string `httpCode` (plus context.data.status) — NOT cause.response,
		// which only raw axios errors carry. A wrong Replicate key probed as
		// "inconclusive" in production because the verdict read only the axios
		// shape; these cases pin the real one.
		function nodeApiError(status: number) {
			const node = {
				id: 'temp',
				name: 'Temp-Node',
				type: 'n8n-nodes-base.noOp',
				typeVersion: 1,
				position: [0, 0],
				parameters: {},
			} as INode;
			return new NodeApiError(
				node,
				{ message: `Request failed with status code ${status}` },
				{ httpCode: String(status) },
			);
		}

		it('rejects a NodeApiError-wrapped 401 — the shape the routing engine actually throws', async () => {
			mockRoutingNodeResult({ reject: nodeApiError(401) });

			const result = await credentialsTester.probeCredentialAuth(
				'user-id',
				'httpHeaderAuth',
				credentials(),
				targetUrl,
			);

			expect(result.status).toBe('Error');
			expect(result.message).toContain('401');
			expect(result.outcome).toBe('rejected');
		});

		it('reports a NodeApiError-wrapped non-auth status as unverifiable', async () => {
			mockRoutingNodeResult({ reject: nodeApiError(405) });

			const result = await credentialsTester.probeCredentialAuth(
				'user-id',
				'httpHeaderAuth',
				credentials(),
				targetUrl,
			);

			expect(result.status).toBe('Error');
			expect(result.message).toContain('405');
			expect(result.outcome).toBe('unverified');
		});

		it('accepts a declared code on the NodeApiError shape too', async () => {
			mockRoutingNodeResult({ reject: nodeApiError(401) });

			const result = await credentialsTester.probeCredentialAuth(
				'user-id',
				'httpHeaderAuth',
				credentials(),
				targetUrl,
				{ acceptedStatusCodes: [401] },
			);

			expect(result.status).toBe('OK');
			expect(result.outcome).toBe('accepted');
		});

		it('reports an unreachable service as unverifiable rather than success', async () => {
			const error = new Error('connect ECONNREFUSED');
			(error as Error & { cause: unknown }).cause = { code: 'ECONNREFUSED' };
			mockRoutingNodeResult({ reject: error });

			const result = await credentialsTester.probeCredentialAuth(
				'user-id',
				'httpHeaderAuth',
				credentials(),
				targetUrl,
			);

			expect(result.status).toBe('Error');
			expect(result.message).toContain('Could not reach');
			expect(result.outcome).toBe('unverified');
		});

		it('preserves credential configuration errors', async () => {
			mockRoutingNodeResult({
				reject: new Error('No value set for placeholder {{api_key}}'),
			});

			const result = await credentialsTester.probeCredentialAuth(
				'user-id',
				'httpTemplatedCustomAuth',
				credentials(),
				targetUrl,
			);

			expect(result).toEqual({
				status: 'Error',
				message: 'No value set for placeholder {{api_key}}',
				outcome: 'unverified',
			});
		});
	});
});
