import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { type Response } from 'express';
import { mock } from 'jest-mock-extended';
import { isWebhookHtmlSandboxingDisabled, getHtmlSandboxCSP } from 'n8n-core';
import { OperationalError, randomString } from 'n8n-workflow';
import type { IHttpRequestMethods } from 'n8n-workflow';

import { ResponseError } from '@/errors/response-errors/abstract/response.error';
import { createWebhookHandlerFor } from '@/webhooks/webhook-request-handler';
import type {
	IWebhookManager,
	IWebhookResponseCallbackData,
	WebhookOptionsRequest,
	WebhookRequest,
} from '@/webhooks/webhook.types';

jest.mock('n8n-core', () => ({
	...jest.requireActual('n8n-core'),
	isWebhookHtmlSandboxingDisabled: jest.fn().mockReturnValue(false),
	getHtmlSandboxCSP: jest.fn().mockReturnValue('sandbox allow-downloads allow-forms allow-modals'),
}));

describe('WebhookRequestHandler', () => {
	const logger = mockInstance(Logger);
	const webhookManager = mock<Required<IWebhookManager>>();
	const handler = createWebhookHandlerFor(webhookManager) as (
		req: WebhookRequest | WebhookOptionsRequest,
		res: Response,
	) => Promise<void>;

	beforeEach(() => {
		jest.resetAllMocks();
	});

	it('should throw for unsupported methods', async () => {
		const req = mock<WebhookRequest | WebhookOptionsRequest>({
			path: '/',
			method: 'CONNECT' as IHttpRequestMethods,
		});
		const res = mock<Response>();
		res.status.mockReturnValue(res);

		await handler(req, res);

		expect(res.status).toHaveBeenCalledWith(500);
		expect(res.json).toHaveBeenCalledWith({
			code: 0,
			message: 'The method CONNECT is not supported.',
		});
	});

	describe('preflight requests', () => {
		it('should handle missing header for requested method', async () => {
			const req = mock<WebhookRequest | WebhookOptionsRequest>({
				path: '/',
				method: 'OPTIONS',
				headers: {
					origin: 'https://example.com',
					'access-control-request-method': undefined,
				},
				params: { path: 'test' },
			});
			const res = mock<Response>();
			res.status.mockReturnValue(res);

			webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']);

			await handler(req, res);

			expect(res.status).toHaveBeenCalledWith(204);
			expect(res.header).toHaveBeenCalledWith(
				'Access-Control-Allow-Methods',
				'OPTIONS, GET, PATCH',
			);
		});

		it('should handle default origin and max-age', async () => {
			const req = mock<WebhookRequest | WebhookOptionsRequest>({
				path: '/',
				method: 'OPTIONS',
				headers: {
					origin: 'https://example.com',
					'access-control-request-method': 'GET',
				},
				params: { path: 'test' },
			});
			const res = mock<Response>();
			res.status.mockReturnValue(res);

			webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']);

			await handler(req, res);

			expect(res.status).toHaveBeenCalledWith(204);
			expect(res.header).toHaveBeenCalledWith(
				'Access-Control-Allow-Methods',
				'OPTIONS, GET, PATCH',
			);
			expect(res.header).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://example.com');
			expect(res.header).toHaveBeenCalledWith('Access-Control-Max-Age', '300');
		});

		it('should handle wildcard origin', async () => {
			const randomOrigin = randomString(10);
			const req = mock<WebhookRequest | WebhookOptionsRequest>({
				path: '/',
				method: 'OPTIONS',
				headers: {
					origin: randomOrigin,
					'access-control-request-method': 'GET',
				},
				params: { path: 'test' },
			});
			const res = mock<Response>();
			res.status.mockReturnValue(res);

			webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']);
			webhookManager.findAccessControlOptions.mockResolvedValue({
				allowedOrigins: '*',
			});

			await handler(req, res);

			expect(res.status).toHaveBeenCalledWith(204);
			expect(res.header).toHaveBeenCalledWith(
				'Access-Control-Allow-Methods',
				'OPTIONS, GET, PATCH',
			);
			expect(res.header).toHaveBeenCalledWith('Access-Control-Allow-Origin', randomOrigin);
		});

		it('should handle custom origin', async () => {
			const req = mock<WebhookRequest | WebhookOptionsRequest>({
				path: '/',
				method: 'OPTIONS',
				headers: {
					origin: 'https://example.com',
					'access-control-request-method': 'GET',
				},
				params: { path: 'test' },
			});
			const res = mock<Response>();
			res.status.mockReturnValue(res);

			webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']);
			webhookManager.findAccessControlOptions.mockResolvedValue({
				allowedOrigins: 'https://test.com',
			});

			await handler(req, res);

			expect(res.status).toHaveBeenCalledWith(204);
			expect(res.header).toHaveBeenCalledWith(
				'Access-Control-Allow-Methods',
				'OPTIONS, GET, PATCH',
			);
			expect(res.header).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://test.com');
		});
	});

	describe('webhook requests', () => {
		it('should delegate the request to the webhook manager and send the response', async () => {
			const req = mock<WebhookRequest>({
				path: '/',
				method: 'GET',
				params: { path: 'test' },
			});

			const res = mock<Response>();

			const executeWebhookResponse: IWebhookResponseCallbackData = {
				responseCode: 200,
				data: {},
				headers: {
					'x-custom-header': 'test',
				},
			};
			webhookManager.executeWebhook.mockResolvedValueOnce(executeWebhookResponse);

			await handler(req, res);

			expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res, undefined);
			expect(res.status).toHaveBeenCalledWith(200);
			expect(res.setHeaders).toHaveBeenCalledWith(new Map([['x-custom-header', 'test']]));
			expect(res.json).toHaveBeenCalledWith(executeWebhookResponse.data);
		});

		it('should send an error response if webhook execution throws', async () => {
			class TestError extends ResponseError {}
			const req = mock<WebhookRequest>({
				path: '/',
				method: 'GET',
				params: { path: 'test' },
			});

			const res = mock<Response>();
			res.status.mockReturnValue(res);

			webhookManager.executeWebhook.mockRejectedValueOnce(
				new TestError('Test error', 500, 100, 'Test hint'),
			);

			await handler(req, res);

			expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res, undefined);
			expect(res.status).toHaveBeenCalledWith(500);
			expect(res.json).toHaveBeenCalledWith({
				code: 100,
				message: 'Test error',
				hint: 'Test hint',
			});
		});

		it('should log the underlying error cause when execution fails', async () => {
			const req = mock<WebhookRequest>({
				path: '/webhook/abc',
				method: 'GET',
				params: { path: 'abc' },
			});

			const res = mock<Response>();
			res.status.mockReturnValue(res);

			const rootCause = new Error('SQLITE_BUSY: database is locked');
			const wrapper = new OperationalError('There was a problem executing the workflow', {
				cause: rootCause,
			});

			webhookManager.executeWebhook.mockRejectedValueOnce(wrapper);

			await handler(req, res);

			expect(logger.error).toHaveBeenCalledWith(
				'Error in handling webhook request GET /webhook/abc: There was a problem executing the workflow',
				expect.objectContaining({ error: wrapper }),
			);
		});

		it('should not throw when legacy response headers contain invalid names', async () => {
			const req = mock<WebhookRequest>({
				path: '/',
				method: 'GET',
				params: { path: 'test' },
			});

			const res = mock<Response>();

			const executeWebhookResponse: IWebhookResponseCallbackData = {
				responseCode: 200,
				data: { ok: true },
				headers: {
					'<img src=x onerror=alert(1)>': 'xss',
					'x-valid': 'value',
				},
			};
			webhookManager.executeWebhook.mockResolvedValueOnce(executeWebhookResponse);

			await handler(req, res);

			expect(res.setHeaders).toHaveBeenCalledTimes(1);
			expect(res.setHeaders).toHaveBeenCalledWith(new Map([['x-valid', 'value']]));
			expect(res.json).toHaveBeenCalledWith(executeWebhookResponse.data);
		});

		it('should not allow user to override CSP via response headers', async () => {
			const req = mock<WebhookRequest>({
				path: '/',
				method: 'GET',
				params: { path: 'test' },
			});

			const res = mock<Response>();

			const executeWebhookResponse: IWebhookResponseCallbackData = {
				responseCode: 200,
				data: { ok: true },
				headers: {
					'Content-Security-Policy': "default-src 'unsafe-inline'",
				},
			};
			webhookManager.executeWebhook.mockResolvedValueOnce(executeWebhookResponse);

			await handler(req, res);

			expect(res.setHeaders).not.toHaveBeenCalled();
			expect(res.setHeader).toHaveBeenCalledWith('Content-Security-Policy', getHtmlSandboxCSP());
		});

		test.each<IHttpRequestMethods>(['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'])(
			"should handle '%s' method",
			async (method) => {
				const req = mock<WebhookRequest>({
					path: '/',
					method,
					params: { path: 'test' },
				});

				const res = mock<Response>();

				const executeWebhookResponse: IWebhookResponseCallbackData = {
					responseCode: 200,
				};
				webhookManager.executeWebhook.mockResolvedValueOnce(executeWebhookResponse);

				await handler(req, res);

				expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res, undefined);
				expect(res.status).toHaveBeenCalledWith(200);
				expect(res.json).toHaveBeenCalledWith(executeWebhookResponse.data);
			},
		);
	});

	describe('CSP sandbox header', () => {
		it('should set CSP sandbox header on all webhook responses', async () => {
			const req = mock<WebhookRequest>({
				path: '/',
				method: 'GET',
				params: { path: 'test' },
			});

			const res = mock<Response>();

			const executeWebhookResponse: IWebhookResponseCallbackData = {
				responseCode: 200,
				data: {},
				headers: { 'content-type': 'image/svg+xml' },
			};
			webhookManager.executeWebhook.mockResolvedValueOnce(executeWebhookResponse);

			await handler(req, res);

			expect(res.setHeader).toHaveBeenCalledWith('Content-Security-Policy', getHtmlSandboxCSP());
		});

		it('should not set CSP sandbox header when sandboxing is disabled', async () => {
			jest.mocked(isWebhookHtmlSandboxingDisabled).mockReturnValueOnce(true);

			const req = mock<WebhookRequest>({
				path: '/',
				method: 'GET',
				params: { path: 'test' },
			});

			const res = mock<Response>();

			const executeWebhookResponse: IWebhookResponseCallbackData = {
				responseCode: 200,
				data: {},
				headers: { 'content-type': 'text/html' },
			};
			webhookManager.executeWebhook.mockResolvedValueOnce(executeWebhookResponse);

			await handler(req, res);

			expect(res.setHeader).not.toHaveBeenCalledWith('Content-Security-Policy', expect.anything());
		});
	});
});
