import type { Mocked } from 'vitest';
import { mockInstance } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import type { Request, Response, NextFunction } from 'express';
import { mock, mockDeep } from 'vitest-mock-extended';
import type { InstanceSettings } from 'n8n-core';

import { JwtService } from '@/services/jwt.service';
import type { McpCallerAuth } from '@/services/oauth-token-verifier-proxy.service';
import { OAuthTokenVerifierProxy } from '@/services/oauth-token-verifier-proxy.service';
import { Telemetry } from '@/telemetry';

import { McpServerApiKeyService } from '../mcp-api-key.service';
import { MCP_CLIENT_INFO_META_KEY, MCP_PROTOCOL_VERSION_META_KEY } from '../mcp.constants';
import { McpProtectedResource } from '../mcp-protected-resource';
import { McpServerMiddlewareService } from '../mcp-server-middleware.service';

const mockReqWith = (authHeader: string | undefined, body?: any) => {
	const req = mockDeep<Request>();
	req.header.mockImplementation((name: string) => {
		if (name === 'authorization') return authHeader;
		return undefined;
	});
	req.body = body || {};
	return req;
};

const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const jwtService = new JwtService(instanceSettings, mock());

let mcpServerApiKeyService: Mocked<McpServerApiKeyService>;
let oauthTokenVerifier: Mocked<OAuthTokenVerifierProxy>;
let mcpProtectedResource: Mocked<McpProtectedResource>;
let telemetry: Mocked<Telemetry>;
let service: McpServerMiddlewareService;

describe('McpServerMiddlewareService', () => {
	beforeAll(() => {
		mcpServerApiKeyService = mockInstance(McpServerApiKeyService) as Mocked<McpServerApiKeyService>;
		oauthTokenVerifier = mockInstance(OAuthTokenVerifierProxy) as Mocked<OAuthTokenVerifierProxy>;
		mcpProtectedResource = mockInstance(McpProtectedResource) as Mocked<McpProtectedResource>;
		telemetry = mockInstance(Telemetry);

		service = new McpServerMiddlewareService(
			mcpServerApiKeyService,
			oauthTokenVerifier,
			mcpProtectedResource,
			jwtService,
			telemetry,
		);
	});

	beforeEach(() => {
		vi.clearAllMocks();
		mcpProtectedResource.getResourceUrl.mockReturnValue('https://n8n.example.com/mcp-server/http');
		mcpProtectedResource.getProtectedResourceMetadataUrl.mockReturnValue(
			'https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http',
		);
	});

	describe('getUserForToken', () => {
		it('should return user for valid OAuth token (meta.isOAuth = true)', async () => {
			const user = mock<User>({ id: 'user-123' });
			const oauthToken = jwtService.sign({
				sub: 'user-123',
				aud: 'mcp-server-api',
				meta: { isOAuth: true },
			});

			oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({
				user,
				caller: { authType: 'oauth', clientId: 'client-abc' },
			});

			const result = await service.getUserForToken(oauthToken);

			expect(result).toEqual({ user, caller: { authType: 'oauth', clientId: 'client-abc' } });
			expect(oauthTokenVerifier.verifyOAuthAccessToken).toHaveBeenCalledWith(
				oauthToken,
				'https://n8n.example.com/mcp-server/http',
			);
			expect(mcpServerApiKeyService.verifyApiKey).not.toHaveBeenCalled();
		});

		it('should return user for valid API key (no meta.isOAuth)', async () => {
			const user = mock<User>({ id: 'user-123' });
			const apiKeyToken = jwtService.sign({
				sub: 'user-123',
				aud: 'mcp-server-api',
			});

			mcpServerApiKeyService.verifyApiKey.mockResolvedValue({
				user,
				caller: { authType: 'api_key' },
			});

			const result = await service.getUserForToken(apiKeyToken);

			expect(result).toEqual({ user, caller: { authType: 'api_key' } });
			expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKeyToken);
			expect(oauthTokenVerifier.verifyOAuthAccessToken).not.toHaveBeenCalled();
		});

		it('should return user for valid API key (meta.isOAuth = false)', async () => {
			const user = mock<User>({ id: 'user-123' });
			const apiKeyToken = jwtService.sign({
				sub: 'user-123',
				aud: 'mcp-server-api',
				meta: { isOAuth: false },
			});

			mcpServerApiKeyService.verifyApiKey.mockResolvedValue({
				user,
				caller: { authType: 'api_key' },
			});

			const result = await service.getUserForToken(apiKeyToken);

			expect(result).toEqual({ user, caller: { authType: 'api_key' } });
			expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKeyToken);
			expect(oauthTokenVerifier.verifyOAuthAccessToken).not.toHaveBeenCalled();
		});

		it('should return null for invalid JWT format', async () => {
			const invalidToken = 'not-a-jwt-token';

			mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null });
			const result = await service.getUserForToken(invalidToken);

			expect(result).toMatchObject({ user: null });
			expect(oauthTokenVerifier.verifyOAuthAccessToken).not.toHaveBeenCalled();
		});

		it('should return null when OAuth token verification fails', async () => {
			const oauthToken = jwtService.sign({
				sub: 'user-123',
				aud: 'mcp-server-api',
				meta: { isOAuth: true },
			});

			oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({ user: null });

			const result = await service.getUserForToken(oauthToken);

			expect(result).toMatchObject({ user: null });
		});

		it('should return null when API key verification fails', async () => {
			const apiKeyToken = jwtService.sign({
				sub: 'user-123',
				aud: 'mcp-server-api',
			});

			mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null });

			const result = await service.getUserForToken(apiKeyToken);

			expect(result).toMatchObject({ user: null });
		});
	});

	describe('getEnabledMiddleware', () => {
		const runMiddleware = async (enabled: boolean, body?: unknown) => {
			mcpProtectedResource.isAvailable.mockResolvedValue(enabled);
			const res = mockDeep<Response>();
			res.status.mockReturnThis();
			res.json.mockReturnThis();
			const next = vi.fn() as NextFunction;

			await service.getEnabledMiddleware()(mockReqWith(undefined, body), res, next);

			return { res, next };
		};

		it('should pass the request through when MCP access is enabled', async () => {
			const { res, next } = await runMiddleware(true);

			expect(next).toHaveBeenCalled();
			expect(res.status).not.toHaveBeenCalled();
		});

		it('should return 404 without an authentication challenge when MCP access is disabled', async () => {
			const { res, next } = await runMiddleware(false);

			expect(res.status).toHaveBeenCalledWith(404);
			expect(res.json).toHaveBeenCalledWith({ message: 'MCP access is disabled' });
			expect(res.header).not.toHaveBeenCalledWith('WWW-Authenticate', expect.anything());
			expect(next).not.toHaveBeenCalled();
			expect(telemetry.track).not.toHaveBeenCalled();
		});

		it('should track a connection error when a handshake hits the disabled server', async () => {
			await runMiddleware(false, {
				jsonrpc: '2.0',
				method: 'initialize',
				params: { clientInfo: { name: 'Claude', version: '1.0.0' } },
			});

			expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
				mcp_connection_status: 'error',
				error: 'MCP access is disabled',
				http_status: 404,
				client_name: 'Claude',
				client_version: '1.0.0',
			});
		});

		it('should not track non-handshake requests against the disabled server', async () => {
			await runMiddleware(false, { jsonrpc: '2.0', method: 'tools/call', params: {} });

			expect(telemetry.track).not.toHaveBeenCalled();
		});
	});

	describe('getAuthMiddleware', () => {
		it('should return 401 with WWW-Authenticate header when authorization header is missing', async () => {
			const req = mockReqWith(undefined);
			const res = mockDeep<Response>();
			res.status.mockReturnThis();
			res.send.mockReturnThis();
			res.header.mockReturnThis();
			const next = vi.fn() as NextFunction;

			const middleware = service.getAuthMiddleware();

			await middleware(req, res, next);

			expect(res.header).toHaveBeenCalledWith(
				'WWW-Authenticate',
				'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"',
			);
			expect(res.status).toHaveBeenCalledWith(401);
			expect(res.send).toHaveBeenCalledWith({
				message: 'Unauthorized: Authorization header not sent',
			});
			expect(next).not.toHaveBeenCalled();
			expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
				mcp_connection_status: 'error',
				http_status: 401,
				error: 'Unauthorized',
				client_name: undefined,
				client_version: undefined,
				auth_type: 'unknown',
				error_details: 'Authorization header not sent',
				reason: 'missing_authorization_header',
			});
		});

		it('should return 401 with WWW-Authenticate header when authorization header does not start with Bearer', async () => {
			const req = mockReqWith('Basic sometoken');
			const res = mockDeep<Response>();
			res.status.mockReturnThis();
			const next = vi.fn() as NextFunction;

			const middleware = service.getAuthMiddleware();

			await middleware(req, res, next);

			expect(res.header).toHaveBeenCalledWith(
				'WWW-Authenticate',
				'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"',
			);
			expect(res.status).toHaveBeenCalledWith(401);
			expect(res.send).toHaveBeenCalledWith({
				message: 'Unauthorized: Invalid authorization header format - Missing Bearer prefix',
			});
			expect(next).not.toHaveBeenCalled();
			expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
				mcp_connection_status: 'error',
				http_status: 401,
				error: 'Unauthorized',
				client_name: undefined,
				client_version: undefined,
				auth_type: 'unknown',
				error_details: 'Invalid authorization header format - Missing Bearer prefix',
				reason: 'invalid_bearer_format',
			});
		});

		it('should return 401 with WWW-Authenticate header when Bearer token is malformed', async () => {
			const req = mockReqWith('Bearer');
			const res = mockDeep<Response>();
			res.status.mockReturnThis();
			const next = vi.fn() as NextFunction;

			const middleware = service.getAuthMiddleware();

			await middleware(req, res, next);

			expect(res.header).toHaveBeenCalledWith(
				'WWW-Authenticate',
				'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"',
			);
			expect(res.status).toHaveBeenCalledWith(401);
			expect(res.send).toHaveBeenCalledWith({
				message: 'Unauthorized: Invalid authorization header format - Malformed Bearer token',
			});
			expect(next).not.toHaveBeenCalled();
			expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
				mcp_connection_status: 'error',
				http_status: 401,
				error: 'Unauthorized',
				client_name: undefined,
				client_version: undefined,
				auth_type: 'unknown',
				error_details: 'Invalid authorization header format - Malformed Bearer token',
				reason: 'invalid_bearer_format',
			});
		});

		it('should authenticate with valid OAuth token and call next', async () => {
			const user = mock<User>({ id: 'user-123' });
			const oauthToken = jwtService.sign({
				sub: 'user-123',
				aud: 'mcp-server-api',
				meta: { isOAuth: true },
			});

			const req = mockReqWith(`Bearer ${oauthToken}`);
			const res = mockDeep<Response>();
			const next = vi.fn() as NextFunction;

			oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({
				user,
				caller: { authType: 'oauth', clientId: 'client-abc' },
			});

			const middleware = service.getAuthMiddleware();

			await middleware(req, res, next);
			const authenticatedReq = req as Request & { user?: User; mcpCaller?: McpCallerAuth };

			expect(authenticatedReq.user).toEqual(user);
			expect(authenticatedReq.mcpCaller?.authType).toBe('oauth');
			expect(next).toHaveBeenCalled();
			expect(res.status).not.toHaveBeenCalled();
		});

		it('should attach the OAuth token scopes to the request', async () => {
			const user = mock<User>({ id: 'user-123' });
			const oauthToken = jwtService.sign({
				sub: 'user-123',
				aud: 'mcp-server-api',
				meta: { isOAuth: true },
			});

			const req = mockReqWith(`Bearer ${oauthToken}`);
			const res = mockDeep<Response>();
			const next = vi.fn() as NextFunction;

			oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({
				user,
				caller: { authType: 'oauth', clientId: 'client-abc' },
				scopes: ['workflow:read'],
			});

			await service.getAuthMiddleware()(req, res, next);
			const authenticatedReq = req as Request & { mcpScopes?: string[] };

			expect(authenticatedReq.mcpScopes).toEqual(['workflow:read']);
			expect(next).toHaveBeenCalled();
		});

		it('should attach the OAuth client the token was issued to', async () => {
			const user = mock<User>({ id: 'user-123' });
			const oauthToken = jwtService.sign({
				sub: 'user-123',
				aud: 'mcp-server-api',
				meta: { isOAuth: true },
			});

			const req = mockReqWith(`Bearer ${oauthToken}`);
			const res = mockDeep<Response>();
			const next = vi.fn() as NextFunction;

			oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({
				user,
				caller: { authType: 'oauth', clientId: 'client-abc' },
			});

			await service.getAuthMiddleware()(req, res, next);

			expect((req as Request & { mcpCaller?: McpCallerAuth }).mcpCaller).toEqual({
				authType: 'oauth',
				clientId: 'client-abc',
			});
			expect(next).toHaveBeenCalled();
		});

		it('should authenticate with valid API key and call next', async () => {
			const user = mock<User>({ id: 'user-123' });
			const apiKeyToken = jwtService.sign({
				sub: 'user-123',
				aud: 'mcp-server-api',
			});

			const req = mockReqWith(`Bearer ${apiKeyToken}`);
			const res = mockDeep<Response>();
			const next = vi.fn() as NextFunction;

			mcpServerApiKeyService.verifyApiKey.mockResolvedValue({
				user,
				caller: { authType: 'api_key' },
			});

			const middleware = service.getAuthMiddleware();

			await middleware(req, res, next);
			const authenticatedReq = req as Request & {
				user?: User;
				mcpCaller?: McpCallerAuth;
				mcpScopes?: string[];
			};

			expect(authenticatedReq.user).toEqual(user);
			// An API key is not issued to an OAuth client, so there is none to report
			expect(authenticatedReq.mcpCaller).toEqual({ authType: 'api_key' });
			// API keys are not scope-bearing: undefined means full tool access
			expect(authenticatedReq.mcpScopes).toBeUndefined();
			expect(next).toHaveBeenCalled();
			expect(res.status).not.toHaveBeenCalled();
		});

		it('should authenticate with a delegated scoped JWT and set req.user to the actor', async () => {
			const actor = mock<User>({ id: 'actor-1' });
			const scopedJwt = jwtService.sign({
				iss: 'n8n-token-exchange',
				sub: 'subject-1',
				act: { sub: 'actor-1' },
				jti: 'test-jti',
			});

			const req = mockReqWith(`Bearer ${scopedJwt}`);
			const res = mockDeep<Response>();
			const next = vi.fn() as NextFunction;

			// verifyApiKey returns the acting principal as `user` and keeps the actor field populated.
			mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: actor, actor });

			const middleware = service.getAuthMiddleware();

			await middleware(req, res, next);

			expect((req as any).user).toEqual(actor);
			expect(next).toHaveBeenCalled();
			expect(res.status).not.toHaveBeenCalled();
			// Scoped JWTs flow through verifyApiKey (no meta.isOAuth), not the OAuth path.
			expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(scopedJwt);
			expect(oauthTokenVerifier.verifyOAuthAccessToken).not.toHaveBeenCalled();
		});

		it('should return 401 with WWW-Authenticate header when token validation fails', async () => {
			const invalidToken = jwtService.sign({
				sub: 'user-123',
				aud: 'mcp-server-api',
				meta: { isOAuth: true },
			});

			const req = mockReqWith(`Bearer ${invalidToken}`);
			const res = mockDeep<Response>();
			res.status.mockReturnThis();
			res.send.mockReturnThis();
			res.header.mockReturnThis();
			const next = vi.fn() as NextFunction;

			oauthTokenVerifier.verifyOAuthAccessToken.mockResolvedValue({ user: null });

			const middleware = service.getAuthMiddleware();

			await middleware(req, res, next);

			expect(res.header).toHaveBeenCalledWith(
				'WWW-Authenticate',
				'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"',
			);
			expect(res.status).toHaveBeenCalledWith(401);
			expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
			expect(next).not.toHaveBeenCalled();
		});

		it('should track telemetry with client info from request body', async () => {
			const req = mockReqWith(undefined, {
				params: {
					clientInfo: {
						name: 'test-client',
						version: '1.0.0',
					},
				},
			});
			const res = mockDeep<Response>();
			res.status.mockReturnThis();
			res.send.mockReturnThis();
			res.header.mockReturnThis();
			const next = vi.fn() as NextFunction;

			const middleware = service.getAuthMiddleware();

			await middleware(req, res, next);

			expect(res.header).toHaveBeenCalledWith(
				'WWW-Authenticate',
				'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"',
			);
			expect(res.status).toHaveBeenCalledWith(401);
			expect(res.send).toHaveBeenCalledWith({
				message: 'Unauthorized: Authorization header not sent',
			});
			expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
				mcp_connection_status: 'error',
				http_status: 401,
				error: 'Unauthorized',
				client_name: 'test-client',
				client_version: '1.0.0',
				auth_type: 'unknown',
				error_details: 'Authorization header not sent',
				reason: 'missing_authorization_header',
			});
		});

		// An unauthenticated `server/discover` probe is treated like any other RPC:
		// capability discovery is grant-scoped, so it stays behind auth and the 401
		// points the client at the OAuth protected-resource metadata. The client
		// identity and protocol version it declared in `_meta` are still captured.
		it('should reject an unauthenticated server/discover probe with 401 and capture _meta', async () => {
			const req = mockReqWith(undefined, {
				jsonrpc: '2.0',
				method: 'server/discover',
				params: {
					_meta: {
						[MCP_PROTOCOL_VERSION_META_KEY]: '2026-07-28',
						[MCP_CLIENT_INFO_META_KEY]: { name: 'Claude', version: '3.0.0' },
					},
				},
			});
			const res = mockDeep<Response>();
			res.status.mockReturnThis();
			res.send.mockReturnThis();
			res.header.mockReturnThis();
			const next = vi.fn() as NextFunction;

			await service.getAuthMiddleware()(req, res, next);

			expect(res.header).toHaveBeenCalledWith(
				'WWW-Authenticate',
				'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"',
			);
			expect(res.status).toHaveBeenCalledWith(401);
			expect(next).not.toHaveBeenCalled();
			expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
				mcp_connection_status: 'error',
				http_status: 401,
				error: 'Unauthorized',
				client_name: 'Claude',
				client_version: '3.0.0',
				protocol_version: '2026-07-28',
				auth_type: 'unknown',
				error_details: 'Authorization header not sent',
				reason: 'missing_authorization_header',
			});
		});

		it('should handle invalid token format gracefully with WWW-Authenticate header', async () => {
			const req = mockReqWith('Bearer invalid-token-format');
			const res = mockDeep<Response>();
			res.status.mockReturnThis();
			res.send.mockReturnThis();
			res.header.mockReturnThis();
			const next = vi.fn() as NextFunction;

			mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null });

			const middleware = service.getAuthMiddleware();

			await middleware(req, res, next);

			expect(res.header).toHaveBeenCalledWith(
				'WWW-Authenticate',
				'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"',
			);
			expect(res.status).toHaveBeenCalledWith(401);
			expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
			expect(next).not.toHaveBeenCalled();
		});
	});
});
