import { Logger, ModuleRegistry } from '@n8n/backend-common';
import { InstanceSettingsLoaderConfig } from '@n8n/config';
import { type ApiKey, type AuthenticatedRequest, User, Role } from '@n8n/db';
import { Container } from '@n8n/di';
import type { Response } from 'express';
import { mock, mockDeep } from 'vitest-mock-extended';

import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { EventService } from '@/events/event.service';
import type { ListQuery } from '@/requests';
import { WorkflowService } from '@/workflows/workflow.service';

import { UpdateAllowedRedirectUrisDto } from '../dto/update-allowed-redirect-uris.dto';
import { UpdateMcpSettingsDto } from '../dto/update-mcp-settings.dto';
import { UpdateWorkflowsAvailabilityDto } from '../dto/update-workflows-availability.dto';
import { McpServerApiKeyService } from '../mcp-api-key.service';
import { McpSettingsController } from '../mcp.settings.controller';
import { McpSettingsService } from '../mcp.settings.service';
import { createWorkflow } from './mock.utils';

const createReq = (
	body: unknown,
	overrides: Partial<AuthenticatedRequest> = {},
): AuthenticatedRequest => ({ body, ...overrides }) as unknown as AuthenticatedRequest;

const createRole = () =>
	Object.assign(new Role(), {
		slug: 'member',
		displayName: 'Member',
		description: null,
		systemRole: false,
		roleType: 'global' as const,
		projectRelations: [],
		scopes: [],
	});

const createUser = (overrides: Partial<User> = {}) =>
	Object.assign(
		new User(),
		{
			id: 'user-1',
			email: 'user@example.com',
			firstName: 'Test',
			lastName: 'User',
			password: null,
			personalizationAnswers: null,
			settings: null,
			role: createRole(),
			authIdentities: [],
			apiKeys: [],
			sharedWorkflows: [],
			sharedCredentials: [],
			projectRelations: [],
			disabled: false,
			mfaEnabled: false,
			mfaSecret: null,
			mfaRecoveryCodes: [],
			lastActiveAt: null,
			isPending: false,
		},
		overrides,
	);

const createRes = () => {
	const res = mock<Response>();
	res.status.mockReturnThis();
	res.json.mockReturnThis();
	return res;
};

const createListQueryReq = (
	user: User,
	listQueryOptions: ListQuery.Options = {},
): ListQuery.Request =>
	({
		user,
		listQueryOptions,
	}) as unknown as ListQuery.Request;

describe('McpSettingsController', () => {
	const logger = mock<Logger>();
	const moduleRegistry = mockDeep<ModuleRegistry>();
	const mcpSettingsService = mock<McpSettingsService>();
	const mcpServerApiKeyService = mockDeep<McpServerApiKeyService>();
	const workflowService = mock<WorkflowService>();
	const instanceSettingsLoaderConfig = mock<InstanceSettingsLoaderConfig>();
	const eventService = mock<EventService>();

	let controller: McpSettingsController;

	beforeEach(() => {
		vi.clearAllMocks();
		instanceSettingsLoaderConfig.mcpManagedByEnv = false;
		Container.set(Logger, logger);
		Container.set(McpSettingsService, mcpSettingsService);
		Container.set(ModuleRegistry, moduleRegistry);
		Container.set(McpServerApiKeyService, mcpServerApiKeyService);
		Container.set(WorkflowService, workflowService);
		Container.set(InstanceSettingsLoaderConfig, instanceSettingsLoaderConfig);
		Container.set(EventService, eventService);
		controller = Container.get(McpSettingsController);
	});

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

	describe('updateSettings', () => {
		test('disables MCP access correctly', async () => {
			const user = createUser();
			const req = createReq({ mcpAccessEnabled: false }, { user });
			const dto = new UpdateMcpSettingsDto({ mcpAccessEnabled: false });
			mcpSettingsService.setEnabled.mockResolvedValue(undefined);
			mcpSettingsService.setAutoExposeNewWorkflows.mockResolvedValue(undefined);
			mcpSettingsService.getEnabled.mockResolvedValue(false);
			mcpSettingsService.getAutoExposeNewWorkflows.mockResolvedValue(false);
			moduleRegistry.refreshModuleSettings.mockResolvedValue(null);

			const res = createRes();
			const result = await controller.updateSettings(req, res, dto);

			expect(mcpSettingsService.setEnabled).toHaveBeenCalledWith(false);
			expect(moduleRegistry.refreshModuleSettings).toHaveBeenCalledWith('mcp');
			expect(eventService.emit).toHaveBeenCalledWith('mcp-access-updated', {
				user,
				enabled: false,
			});
			expect(mcpSettingsService.setAutoExposeNewWorkflows).not.toHaveBeenCalled();
			expect(result).toEqual({ mcpAccessEnabled: false, autoExposeNewWorkflows: false });
		});

		test('enables MCP access correctly', async () => {
			const user = createUser();
			const req = createReq({ mcpAccessEnabled: true }, { user });
			const dto = new UpdateMcpSettingsDto({ mcpAccessEnabled: true });
			mcpSettingsService.setEnabled.mockResolvedValue(undefined);
			mcpSettingsService.getEnabled.mockResolvedValue(true);
			mcpSettingsService.getAutoExposeNewWorkflows.mockResolvedValue(true);
			moduleRegistry.refreshModuleSettings.mockResolvedValue(null);

			const res = createRes();
			const result = await controller.updateSettings(req, res, dto);

			expect(mcpSettingsService.setEnabled).toHaveBeenCalledWith(true);
			expect(moduleRegistry.refreshModuleSettings).toHaveBeenCalledWith('mcp');
			expect(eventService.emit).toHaveBeenCalledWith('mcp-access-updated', {
				user,
				enabled: true,
			});
			// The response reflects the persisted state read back via the getters,
			// surfacing the real stored auto-expose value on enable.
			expect(result).toEqual({ mcpAccessEnabled: true, autoExposeNewWorkflows: true });
		});

		test('handles module registry refresh failure gracefully', async () => {
			const req = createReq({ mcpAccessEnabled: true }, { user: createUser() });
			const dto = new UpdateMcpSettingsDto({ mcpAccessEnabled: true });
			const error = new Error('Registry sync failed');

			mcpSettingsService.setEnabled.mockResolvedValue(undefined);
			mcpSettingsService.getEnabled.mockResolvedValue(true);
			mcpSettingsService.getAutoExposeNewWorkflows.mockResolvedValue(false);
			moduleRegistry.refreshModuleSettings.mockRejectedValue(error);

			const res = createRes();
			const result = await controller.updateSettings(req, res, dto);

			expect(mcpSettingsService.setEnabled).toHaveBeenCalledWith(true);
			expect(moduleRegistry.refreshModuleSettings).toHaveBeenCalledWith('mcp');
			expect(logger.warn).toHaveBeenCalledWith('Failed to sync MCP settings to module registry', {
				cause: 'Registry sync failed',
			});
			expect(result).toEqual({ mcpAccessEnabled: true, autoExposeNewWorkflows: false });
		});

		test('handles a non-Error rejection from the module registry refresh', async () => {
			const req = createReq({ mcpAccessEnabled: true }, { user: createUser() });
			const dto = new UpdateMcpSettingsDto({ mcpAccessEnabled: true });

			mcpSettingsService.setEnabled.mockResolvedValue(undefined);
			moduleRegistry.refreshModuleSettings.mockRejectedValue('registry unavailable');

			const res = createRes();
			await controller.updateSettings(req, res, dto);

			expect(logger.warn).toHaveBeenCalledWith('Failed to sync MCP settings to module registry', {
				cause: 'registry unavailable',
			});
		});

		test('rejects updates when MCP settings are managed by env', async () => {
			instanceSettingsLoaderConfig.mcpManagedByEnv = true;
			const req = createReq({ mcpAccessEnabled: true });
			const dto = new UpdateMcpSettingsDto({ mcpAccessEnabled: true });

			await expect(controller.updateSettings(req, createRes(), dto)).rejects.toThrow(
				ForbiddenError,
			);
			expect(mcpSettingsService.setEnabled).not.toHaveBeenCalled();
			expect(moduleRegistry.refreshModuleSettings).not.toHaveBeenCalled();
			expect(eventService.emit).not.toHaveBeenCalled();
		});

		test('requires boolean mcpAccessEnabled value', () => {
			expect(() => new UpdateMcpSettingsDto({ mcpAccessEnabled: 'yes' } as never)).toThrow();
		});

		test('updates both fields when both are patched', async () => {
			const user = createUser();
			const req = createReq({ mcpAccessEnabled: true, autoExposeNewWorkflows: false }, { user });
			const dto = UpdateMcpSettingsDto.parse({
				mcpAccessEnabled: true,
				autoExposeNewWorkflows: false,
			});
			mcpSettingsService.setEnabled.mockResolvedValue(undefined);
			mcpSettingsService.getEnabled.mockResolvedValue(true);
			mcpSettingsService.getAutoExposeNewWorkflows.mockResolvedValue(false);
			moduleRegistry.refreshModuleSettings.mockResolvedValue(null);

			const res = createRes();
			const result = await controller.updateSettings(req, res, dto);

			expect(mcpSettingsService.setEnabled).toHaveBeenCalledWith(true);
			expect(mcpSettingsService.setAutoExposeNewWorkflows).toHaveBeenCalledWith(false);
			expect(result).toEqual({ mcpAccessEnabled: true, autoExposeNewWorkflows: false });
		});

		test('touches only autoExposeNewWorkflows when mcpAccessEnabled is not patched', async () => {
			const user = createUser();
			const req = createReq({ autoExposeNewWorkflows: true }, { user });
			const dto = UpdateMcpSettingsDto.parse({ autoExposeNewWorkflows: true });
			mcpSettingsService.setAutoExposeNewWorkflows.mockResolvedValue(undefined);
			mcpSettingsService.getEnabled.mockResolvedValue(true);
			mcpSettingsService.getAutoExposeNewWorkflows.mockResolvedValue(true);
			moduleRegistry.refreshModuleSettings.mockResolvedValue(null);

			const res = createRes();
			const result = await controller.updateSettings(req, res, dto);

			expect(mcpSettingsService.setEnabled).not.toHaveBeenCalled();
			expect(mcpSettingsService.setAutoExposeNewWorkflows).toHaveBeenCalledWith(true);
			expect(eventService.emit).not.toHaveBeenCalled();
			expect(result).toEqual({ mcpAccessEnabled: true, autoExposeNewWorkflows: true });
		});

		test('rejects an empty body', () => {
			const result = UpdateMcpSettingsDto.safeParse({});

			expect(result.success).toBe(false);
			if (!result.success) {
				expect(result.error.errors[0].message).toBe(
					'Provide at least one of mcpAccessEnabled or autoExposeNewWorkflows',
				);
			}
			expect(mcpSettingsService.setEnabled).not.toHaveBeenCalled();
			expect(mcpSettingsService.setAutoExposeNewWorkflows).not.toHaveBeenCalled();
		});
	});

	describe('getApiKeyForMcpServer', () => {
		const mockUser = createUser({ id: 'user123', email: 'user123@example.com' });
		const mockApiKey = {
			id: 'api-key-123',
			key: 'mcp-key-abc123',
			userId: 'user123',
			createdAt: new Date(),
		} as unknown as ApiKey;

		test('returns API key from getOrCreateApiKey', async () => {
			const req = createReq({}, { user: mockUser });
			mcpServerApiKeyService.getOrCreateApiKey.mockResolvedValue(mockApiKey);

			const result = await controller.getApiKeyForMcpServer(req);

			expect(mcpServerApiKeyService.getOrCreateApiKey).toHaveBeenCalledWith(mockUser);
			expect(result).toEqual(mockApiKey);
		});
	});

	describe('rotateApiKeyForMcpServer', () => {
		const mockUser = createUser({ id: 'user123', email: 'user123@example.com' });
		const mockApiKey = {
			id: 'api-key-123',
			key: 'mcp-key-abc123',
			userId: 'user123',
			createdAt: new Date(),
		} as unknown as ApiKey;

		test('successfully rotates API key', async () => {
			const req = createReq({}, { user: mockUser });

			mcpServerApiKeyService.rotateMcpServerApiKey.mockResolvedValue(mockApiKey);

			const result = await controller.rotateApiKeyForMcpServer(req);

			expect(mcpServerApiKeyService.rotateMcpServerApiKey).toHaveBeenCalledWith(mockUser);
			expect(result).toEqual(mockApiKey);
		});
	});

	describe('getMcpEligibleWorkflows', () => {
		const user = createUser();

		test('calls workflowService.getMany with correct filter options', async () => {
			const req = createListQueryReq(user);
			const res = createRes();
			const mockWorkflows = [createWorkflow({ id: 'wf-1' }), createWorkflow({ id: 'wf-2' })];

			workflowService.getMany.mockResolvedValue({ workflows: mockWorkflows, count: 2 });

			await controller.getMcpEligibleWorkflows(req, res);

			expect(workflowService.getMany).toHaveBeenCalledWith(
				user,
				expect.objectContaining({
					filter: expect.objectContaining({
						isArchived: false,
						availableInMCP: false,
					}),
				}),
				{ requiredScopes: ['workflow:update'] },
			);
		});

		test('returns workflows and count in response', async () => {
			const req = createListQueryReq(user);
			const res = createRes();
			const mockWorkflows = [createWorkflow({ id: 'wf-1' }), createWorkflow({ id: 'wf-2' })];

			workflowService.getMany.mockResolvedValue({ workflows: mockWorkflows, count: 2 });

			await controller.getMcpEligibleWorkflows(req, res);

			expect(res.json).toHaveBeenCalledWith({ count: 2, data: mockWorkflows });
		});

		test('returns empty array when no eligible workflows exist', async () => {
			const req = createListQueryReq(user);
			const res = createRes();

			workflowService.getMany.mockResolvedValue({ workflows: [], count: 0 });

			await controller.getMcpEligibleWorkflows(req, res);

			expect(res.json).toHaveBeenCalledWith({ count: 0, data: [] });
		});

		test('merges user-provided filter options with required filters', async () => {
			const req = createListQueryReq(user, {
				filter: { name: 'test-workflow' },
				take: 10,
				skip: 5,
			});
			const res = createRes();

			workflowService.getMany.mockResolvedValue({ workflows: [], count: 0 });

			await controller.getMcpEligibleWorkflows(req, res);

			expect(workflowService.getMany).toHaveBeenCalledWith(
				user,
				expect.objectContaining({
					filter: expect.objectContaining({
						name: 'test-workflow',
						isArchived: false,
						availableInMCP: false,
					}),
					take: 10,
					skip: 5,
				}),
				{ requiredScopes: ['workflow:update'] },
			);
		});

		test('required filters override user-provided conflicting filters', async () => {
			const req = createListQueryReq(user, {
				filter: {
					active: false,
					isArchived: true,
					availableInMCP: true,
				},
			});
			const res = createRes();

			workflowService.getMany.mockResolvedValue({ workflows: [], count: 0 });

			await controller.getMcpEligibleWorkflows(req, res);

			expect(workflowService.getMany).toHaveBeenCalledWith(
				user,
				expect.objectContaining({
					filter: expect.objectContaining({
						isArchived: false,
						availableInMCP: false,
					}),
				}),
				{ requiredScopes: ['workflow:update'] },
			);
		});
	});

	describe('toggleWorkflowsMCPAccess', () => {
		const user = createUser();

		test('delegates to mcpSettingsService.bulkSetAvailableInMCP and returns its result', async () => {
			const dto = new UpdateWorkflowsAvailabilityDto({
				availableInMCP: true,
				workflowIds: ['wf-1', 'wf-2'],
			});
			const bulkResult = {
				updatedCount: 2,
				updatedIds: ['wf-1', 'wf-2'],
				unchangedCount: 0,
				unchangedIds: [],
				skippedCount: 0,
				failedCount: 0,
				changedWorkflows: [
					{
						workflowId: 'wf-1',
						settings: { availableInMCP: true },
						checksum: 'checksum-wf-1',
					},
					{
						workflowId: 'wf-2',
						settings: { availableInMCP: true },
						checksum: 'checksum-wf-2',
					},
				],
			};
			mcpSettingsService.bulkSetAvailableInMCP.mockResolvedValue(bulkResult);

			const req = createReq({}, { user });
			const result = await controller.toggleWorkflowsMCPAccess(req, mock<Response>(), dto);

			expect(mcpSettingsService.bulkSetAvailableInMCP).toHaveBeenCalledTimes(1);
			expect(mcpSettingsService.bulkSetAvailableInMCP).toHaveBeenCalledWith(user, dto);
			expect(mcpSettingsService.broadcastWorkflowMCPAvailabilityChanged).toHaveBeenCalledWith(
				bulkResult.changedWorkflows,
			);
			expect(result).toEqual({
				updatedCount: 2,
				updatedIds: ['wf-1', 'wf-2'],
				unchangedCount: 0,
				unchangedIds: [],
				skippedCount: 0,
				failedCount: 0,
			});
			expect(moduleRegistry.refreshModuleSettings).not.toHaveBeenCalled();
		});
	});

	describe('getAllowedRedirectUris', () => {
		test('returns allowed redirect URIs', async () => {
			const mockUris = ['https://example.com/callback', 'http://localhost:3000/callback'];
			mcpSettingsService.getAllowedRedirectUris.mockResolvedValue(mockUris);

			const result = await controller.getAllowedRedirectUris();

			expect(mcpSettingsService.getAllowedRedirectUris).toHaveBeenCalled();
			expect(result).toEqual({ uris: mockUris });
		});

		test('returns empty array when no URIs configured', async () => {
			mcpSettingsService.getAllowedRedirectUris.mockResolvedValue([]);

			const result = await controller.getAllowedRedirectUris();

			expect(result).toEqual({ uris: [] });
		});
	});

	describe('updateAllowedRedirectUris', () => {
		test('updates allowed redirect URIs successfully', async () => {
			const uris = ['https://example.com/callback', 'http://localhost:3000/callback'];
			const dto = new UpdateAllowedRedirectUrisDto({ uris });
			mcpSettingsService.setAllowedRedirectUris.mockResolvedValue(undefined);

			const result = await controller.updateAllowedRedirectUris(createReq({}), createRes(), dto);

			expect(mcpSettingsService.setAllowedRedirectUris).toHaveBeenCalledWith(uris);
			expect(result).toEqual({ success: true });
		});

		test('propagates storage errors instead of mapping them to a client error', async () => {
			const uris = ['https://example.com/callback'];
			const dto = new UpdateAllowedRedirectUrisDto({ uris });
			const dbError = new Error('Database connection failed');
			mcpSettingsService.setAllowedRedirectUris.mockRejectedValue(dbError);

			await expect(
				controller.updateAllowedRedirectUris(createReq({}), createRes(), dto),
			).rejects.toThrow(dbError);
		});

		test('accepts empty array', async () => {
			const dto = new UpdateAllowedRedirectUrisDto({ uris: [] });
			mcpSettingsService.setAllowedRedirectUris.mockResolvedValue(undefined);

			const result = await controller.updateAllowedRedirectUris(createReq({}), createRes(), dto);

			expect(mcpSettingsService.setAllowedRedirectUris).toHaveBeenCalledWith([]);
			expect(result).toEqual({ success: true });
		});
	});

	describe('UpdateAllowedRedirectUrisDto', () => {
		test('accepts valid http and https URIs', () => {
			const dto = new UpdateAllowedRedirectUrisDto({
				uris: ['https://example.com/callback', 'http://localhost:3000/callback'],
			});

			expect(dto.uris).toEqual(['https://example.com/callback', 'http://localhost:3000/callback']);
		});

		test('trims whitespace and filters out empty entries', () => {
			const dto = new UpdateAllowedRedirectUrisDto({
				uris: ['  https://example.com/callback  ', '', '   '],
			});

			expect(dto.uris).toEqual(['https://example.com/callback']);
		});

		test('rejects malformed URLs', () => {
			expect(() => new UpdateAllowedRedirectUrisDto({ uris: ['not-a-url'] })).toThrow();
		});

		test('rejects non-http/https protocols', () => {
			expect(
				() => new UpdateAllowedRedirectUrisDto({ uris: ['ftp://example.com/callback'] }),
			).toThrow();
		});

		test('requires HTTPS for non-localhost URIs outside development', () => {
			const originalEnv = process.env.NODE_ENV;
			process.env.NODE_ENV = 'production';

			try {
				expect(
					() => new UpdateAllowedRedirectUrisDto({ uris: ['http://example.com/callback'] }),
				).toThrow();
			} finally {
				process.env.NODE_ENV = originalEnv;
			}
		});

		test('allows http for localhost outside development', () => {
			const originalEnv = process.env.NODE_ENV;
			process.env.NODE_ENV = 'production';

			try {
				const dto = new UpdateAllowedRedirectUrisDto({
					uris: ['http://localhost:3000/callback', 'http://127.0.0.1:3000/callback'],
				});

				expect(dto.uris).toEqual([
					'http://localhost:3000/callback',
					'http://127.0.0.1:3000/callback',
				]);
			} finally {
				process.env.NODE_ENV = originalEnv;
			}
		});

		test('allows http for IPv6 loopback [::1] outside development', () => {
			const originalEnv = process.env.NODE_ENV;
			process.env.NODE_ENV = 'production';

			try {
				const dto = new UpdateAllowedRedirectUrisDto({
					uris: ['http://[::1]:3000/callback'],
				});

				expect(dto.uris).toEqual(['http://[::1]:3000/callback']);
			} finally {
				process.env.NODE_ENV = originalEnv;
			}
		});
	});

	describe('UpdateWorkflowsAvailabilityDto', () => {
		test('requires availableInMCP to be a boolean', () => {
			expect(() => new UpdateWorkflowsAvailabilityDto({} as never)).toThrow();
			expect(
				() => new UpdateWorkflowsAvailabilityDto({ availableInMCP: 'yes' } as never),
			).toThrow();
		});

		test('accepts a valid workflowIds scope', () => {
			const dto = new UpdateWorkflowsAvailabilityDto({
				availableInMCP: true,
				workflowIds: ['wf-1'],
			});
			expect(dto.workflowIds).toEqual(['wf-1']);
			expect(dto.projectId).toBeUndefined();
			expect(dto.folderId).toBeUndefined();
		});

		test('rejects an empty workflowIds array', () => {
			expect(
				() =>
					new UpdateWorkflowsAvailabilityDto({
						availableInMCP: true,
						workflowIds: [],
					}),
			).toThrow();
		});

		test('rejects workflowIds arrays over the cap', () => {
			const workflowIds = Array.from({ length: 101 }, (_, i) => `wf-${i}`);
			expect(
				() =>
					new UpdateWorkflowsAvailabilityDto({
						availableInMCP: true,
						workflowIds,
					}),
			).toThrow();
		});
	});
});
