import type { Request, Response } from 'express';
import type {
	ICredentialDataDecryptedObject,
	ICredentialsHelper,
	INode,
	INodeType,
	INodeTypes,
	IRunExecutionData,
	IWebhookData,
	IWorkflowExecuteAdditionalData,
	Workflow,
	WorkflowExecuteMode,
	WorkflowExpression,
} from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';

import { getInputConnectionData } from '../utils/get-input-connection-data';
import { WebhookContext } from '../webhook-context';

vi.mock('../utils/get-input-connection-data');

describe('WebhookContext', () => {
	const testCredentialType = 'testCredential';
	const nodeType = mock<INodeType>({
		description: {
			credentials: [
				{
					name: testCredentialType,
					required: true,
				},
			],
			properties: [
				{
					name: 'testParameter',
					required: true,
				},
			],
		},
	});
	const nodeTypes = mock<INodeTypes>();
	const expression = mock<WorkflowExpression>();
	const workflow = mock<Workflow>({ expression, nodeTypes });
	const node = mock<INode>({
		credentials: {
			[testCredentialType]: {
				id: 'testCredentialId',
			},
		},
	});
	node.parameters = {
		testParameter: 'testValue',
	};
	const credentialsHelper = mock<ICredentialsHelper>();
	const additionalData = mock<IWorkflowExecuteAdditionalData>({
		credentialsHelper,
	});
	additionalData.httpRequest = {
		body: { test: 'body' },
		headers: { test: 'header' },
		params: { test: 'param' },
		query: { test: 'query' },
	} as unknown as Request;
	additionalData.httpResponse = mock<Response>();
	const mode: WorkflowExecuteMode = 'manual';
	const webhookData = mock<IWebhookData>({
		webhookDescription: {
			name: 'default',
		},
	});
	const runExecutionData = null;

	const webhookContext = new WebhookContext(
		workflow,
		node,
		additionalData,
		mode,
		webhookData,
		[],
		runExecutionData,
	);

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

	describe('connectionInputData', () => {
		it('should expose the HTTP request as input data when there is no execution stack', () => {
			const context = new WebhookContext(
				workflow,
				node,
				additionalData,
				mode,
				webhookData,
				[],
				null,
			);

			expect(context.connectionInputData).toEqual([
				{
					json: {
						body: { test: 'body' },
						headers: { test: 'header' },
						params: { test: 'param' },
						query: { test: 'query' },
					},
				},
			]);
		});

		it('should not throw and should leave input empty when the seeded execution stack has no main data', () => {
			const runExecutionDataWithEmptyStack = {
				executionData: {
					nodeExecutionStack: [{ node, data: { main: [] }, source: null }],
				},
			} as unknown as IRunExecutionData;

			const context = new WebhookContext(
				workflow,
				node,
				additionalData,
				mode,
				webhookData,
				[],
				runExecutionDataWithEmptyStack,
			);

			expect(context.connectionInputData).toEqual([]);
		});
	});

	describe('getCredentials', () => {
		it('should get decrypted credentials', async () => {
			nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
			credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
			credentialsHelper.isCredentialUsableByNode.mockReturnValue(true);

			const credentials =
				await webhookContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);

			expect(credentials).toEqual({ secret: 'token' });
		});

		it('should surface the node to the credentials helper', async () => {
			nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
			credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
			credentialsHelper.isCredentialUsableByNode.mockReturnValue(true);

			await webhookContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);

			expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith(
				additionalData,
				expect.anything(),
				testCredentialType,
				mode,
				expect.objectContaining({ node }),
				false,
				undefined,
			);
		});
	});

	describe('getBodyData', () => {
		it('should return the body data of the request', () => {
			const bodyData = webhookContext.getBodyData();
			expect(bodyData).toEqual({ test: 'body' });
		});
	});

	describe('getHeaderData', () => {
		it('should return the header data of the request', () => {
			const headerData = webhookContext.getHeaderData();
			expect(headerData).toEqual({ test: 'header' });
		});
	});

	describe('getParamsData', () => {
		it('should return the params data of the request', () => {
			const paramsData = webhookContext.getParamsData();
			expect(paramsData).toEqual({ test: 'param' });
		});
	});

	describe('getQueryData', () => {
		it('should return the query data of the request', () => {
			const queryData = webhookContext.getQueryData();
			expect(queryData).toEqual({ test: 'query' });
		});
	});

	describe('getRequestObject', () => {
		it('should return the request object', () => {
			const request = webhookContext.getRequestObject();
			expect(request).toBe(additionalData.httpRequest);
		});
	});

	describe('getResponseObject', () => {
		it('should return the response object', () => {
			const response = webhookContext.getResponseObject();
			expect(response).toBe(additionalData.httpResponse);
		});
	});

	describe('getWebhookName', () => {
		it('should return the name of the webhook', () => {
			const webhookName = webhookContext.getWebhookName();
			expect(webhookName).toBe('default');
		});
	});

	describe('getTestWebhookUser', () => {
		const user = {
			id: 'user-1',
			email: 'user@example.com',
			firstName: 'Test',
			lastName: 'User',
		};

		const contextWith = (
			webhookOverrides: Partial<IWebhookData>,
			getUserById?: IWorkflowExecuteAdditionalData['getUserById'],
		) =>
			new WebhookContext(
				workflow,
				node,
				{ ...additionalData, getUserById } as IWorkflowExecuteAdditionalData,
				mode,
				mock<IWebhookData>({ webhookDescription: { name: 'default' }, ...webhookOverrides }),
				[],
				null,
			);

		it('should return the user recorded on the test-webhook registration', async () => {
			const getUserById = vi.fn().mockResolvedValue(user);
			const context = contextWith({ userId: 'user-1' }, getUserById);

			await expect(context.getTestWebhookUser()).resolves.toEqual(user);
			expect(getUserById).toHaveBeenCalledWith('user-1');
		});

		// Production webhooks never carry a user id, so they must resolve to nobody.
		it('should return undefined when the webhook carries no user id', async () => {
			const getUserById = vi.fn();
			const context = contextWith({ userId: undefined }, getUserById);

			await expect(context.getTestWebhookUser()).resolves.toBeUndefined();
			expect(getUserById).not.toHaveBeenCalled();
		});

		it('should return undefined when no user lookup is wired up', async () => {
			const context = contextWith({ userId: 'user-1' }, undefined);

			await expect(context.getTestWebhookUser()).resolves.toBeUndefined();
		});

		it('should return undefined when the user no longer exists', async () => {
			const context = contextWith({ userId: 'user-1' }, vi.fn().mockResolvedValue(undefined));

			await expect(context.getTestWebhookUser()).resolves.toBeUndefined();
		});
	});

	describe('webhook URLs', () => {
		const WEBHOOK_KINDS = [
			['a generic webhook', undefined],
			['a form webhook', 'form'],
			['an MCP webhook', 'mcp'],
		] as const;

		const buildUrlContext = (webhookNodeType: 'form' | 'mcp' | undefined, isTest: boolean) => {
			const urlNodeType = mock<INodeType>({
				description: {
					webhooks: [
						{ name: 'default', nodeType: webhookNodeType, path: 'my-path', isFullPath: false },
					],
				},
			});
			nodeTypes.getByNameAndVersion.mockReturnValue(urlNodeType);
			expression.getSimpleParameterValue.mockImplementation((_node, value) => value);

			const urlAdditionalData = mock<IWorkflowExecuteAdditionalData>({
				formBaseUrl: 'http://localhost/prod-webhook',
				formTestBaseUrl: 'http://localhost/test-webhook',
				mcpBaseUrl: 'http://localhost/prod-webhook',
				mcpTestBaseUrl: 'http://localhost/test-webhook',
				webhookBaseUrl: 'http://localhost/prod-webhook',
				webhookTestBaseUrl: 'http://localhost/test-webhook',
			});
			const urlWebhookData = mock<IWebhookData>({
				webhookDescription: { name: 'default', nodeType: webhookNodeType },
				isTest,
			});

			return new WebhookContext(
				workflow,
				node,
				urlAdditionalData,
				mode,
				urlWebhookData,
				[],
				runExecutionData,
			);
		};

		describe('getNodeWebhookUrl', () => {
			it.each(WEBHOOK_KINDS)(
				'should use the production base URL for %s running as a test',
				(_label, nodeType) => {
					const context = buildUrlContext(nodeType, true);
					expect(context.getNodeWebhookUrl('default')).toContain('prod-webhook');
				},
			);
		});

		describe('getWebhookResourceUrl', () => {
			it.each(WEBHOOK_KINDS)(
				'should use the test base URL for %s running as a test',
				(_label, nodeType) => {
					const context = buildUrlContext(nodeType, true);
					expect(context.getWebhookResourceUrl('default')).toContain('test-webhook');
				},
			);

			it.each(WEBHOOK_KINDS)(
				'should use the production base URL for %s running in production',
				(_label, nodeType) => {
					const context = buildUrlContext(nodeType, false);
					expect(context.getWebhookResourceUrl('default')).toContain('prod-webhook');
				},
			);
		});
	});

	describe('getInputConnectionData', () => {
		const inputPassedToSubNodes = () => vi.mocked(getInputConnectionData).mock.calls[0][3];

		it('should expose the request body to sub-nodes', async () => {
			await webhookContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0);

			expect(inputPassedToSubNodes()).toEqual([{ json: { test: 'body' } }]);
		});

		it('should expose the input data the trigger supplied instead', async () => {
			await webhookContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0, {
				inputData: { body: { test: 'body' }, headers: { test: 'header' } },
			});

			expect(inputPassedToSubNodes()).toEqual([
				{ json: { body: { test: 'body' }, headers: { test: 'header' } } },
			]);
		});

		it('should fall back to the request body when the trigger supplies no input data', async () => {
			await webhookContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0, {});

			expect(inputPassedToSubNodes()).toEqual([{ json: { test: 'body' } }]);
		});

		it('should ignore a legacy numeric third argument', async () => {
			await webhookContext.getInputConnectionData(NodeConnectionTypes.AiTool, 0, 1);

			expect(inputPassedToSubNodes()).toEqual([{ json: { test: 'body' } }]);
		});

		it('should not fail when there is no HTTP request', async () => {
			const contextAdditionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
			contextAdditionalData.httpRequest = undefined;
			const context = new WebhookContext(
				workflow,
				node,
				contextAdditionalData,
				mode,
				webhookData,
				[],
				null,
			);

			await context.getInputConnectionData(NodeConnectionTypes.AiTool, 0);

			expect(inputPassedToSubNodes()).toEqual([{ json: {} }]);
		});
	});

	describe('getNodeParameter', () => {
		beforeEach(() => {
			nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
			expression.getParameterValue.mockImplementation((value) => value);
		});

		it('should return parameter value when it exists', () => {
			const parameter = webhookContext.getNodeParameter('testParameter');

			expect(parameter).toBe('testValue');
		});

		it('should return the fallback value when the parameter does not exist', () => {
			const parameter = webhookContext.getNodeParameter('otherParameter', 'fallback');

			expect(parameter).toBe('fallback');
		});
	});
});
