import { Container } from '@n8n/di';
import { tmpdir } from 'node:os';
import path from 'node:path';

import type { DatabaseConfig } from '../src/index';
import { ExecutionsConfig, GlobalConfig, SSRF_DEFAULT_BLOCKED_IP_RANGES } from '../src/index';

const { readFileSyncMock } = vi.hoisted(() => ({
	readFileSyncMock: vi.fn(),
}));

vi.mock('node:fs', () => ({
	readFileSync: readFileSyncMock,
}));

const consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});

// Ignore the sanitize function from the GlobalConfig nested types
type ConfigShape<T> = T extends ReadonlyArray<infer U>
	? Array<ConfigShape<U>>
	: T extends object
		? {
				[K in keyof T as K extends 'sanitize'
					? never
					: T[K] extends (...args: unknown[]) => unknown
						? never
						: K]: ConfigShape<T[K]>;
			}
		: T;

type GlobalConfigShape = ConfigShape<GlobalConfig>;

describe('GlobalConfig', () => {
	beforeEach(() => {
		Container.reset();
		vi.clearAllMocks();
	});

	const originalEnv = process.env;
	afterEach(() => {
		process.env = originalEnv;
	});

	const defaultConfig = {
		path: '/',
		host: 'localhost',
		port: 5678,
		listen_address: '::',
		protocol: 'http',
		auth: {
			cookie: {
				samesite: 'lax',
				secure: true,
			},
		},
		defaultLocale: 'en',
		hideUsagePage: false,
		deployment: {
			type: 'default',
		},
		mfa: {
			enabled: true,
		},
		hiringBanner: {
			enabled: true,
		},
		personalization: {
			enabled: true,
		},
		proxy_hops: 0,
		ssl_key: '',
		ssl_cert: '',
		canvasOnly: false,
		editorBaseUrl: '',
		dataTable: {
			maxSize: 50 * 1024 * 1024,
			sizeCheckCacheDuration: 5 * 1000,
			cleanupIntervalMs: 60 * 1000,
			fileMaxAgeMs: 2 * 60 * 1000,
			uploadDir: path.join(tmpdir(), 'n8nDataTableUploads'),
		},
		database: {
			logging: {
				enabled: false,
				maxQueryExecutionTime: 0,
				options: 'error',
			},
			postgresdb: {
				database: 'n8n',
				host: 'localhost',
				password: '',
				poolSize: 2,
				port: 5432,
				schema: 'public',
				connectionTimeoutMs: 20_000,
				idleTimeoutMs: 30_000,
				statementTimeoutMs: 5 * 60 * 1000,
				ssl: {
					ca: '',
					cert: '',
					enabled: false,
					key: '',
					rejectUnauthorized: true,
				},
				user: 'postgres',
			},
			sqlite: {
				database: 'database.sqlite',
				executeVacuumOnStartup: false,
				poolSize: 3,
			},
			tablePrefix: '',
			type: 'sqlite',
			pingIntervalSeconds: 2,
		} as DatabaseConfig,
		credentials: {
			defaultName: 'My credentials',
			overwrite: {
				data: '{}',
				endpoint: '',
				endpointAuthToken: '',
				persistence: false,
				skipTypes: [],
			},
		},
		userManagement: {
			inviteLinksEmailOnly: false,
			jwtSecret: '',
			jwtSessionDurationHours: 168,
			jwtRefreshTimeoutHours: 0,
			password: {
				minLength: 8,
			},
			emails: {
				mode: 'smtp',
				smtp: {
					host: '',
					port: 465,
					secure: true,
					sender: '',
					startTLS: true,
					auth: {
						pass: '',
						user: '',
						privateKey: '',
						serviceClient: '',
					},
				},
				template: {
					'credentials-shared': '',
					'user-invited': '',
					'password-reset-requested': '',
					'workflow-deactivated': '',
					'workflow-failure': '',
					'workflow-shared': '',
					'project-shared': '',
				},
			},
		},
		eventBus: {
			checkUnsentInterval: 0,
			crashRecoveryMode: 'extensive',
			logWriter: {
				keepLogCount: 3,
				logBaseName: 'n8nEventLog',
				logFullPath: '',
				maxFileSizeInKB: 10240,
				maxMessagesPerParse: 10_000,
				maxTotalMessagesPerFile: 500_000,
			},
		},
		externalHooks: {
			separator: ':',
			files: [],
		},
		nodes: {
			errorTriggerType: 'n8n-nodes-base.errorTrigger',
			include: [],
			exclude: ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'],
			pythonEnabled: true,
		},
		publicApi: {
			disabled: false,
			path: 'api',
			swaggerUiDisabled: false,
			packagesEnabled: false,
		},
		templates: {
			enabled: true,
			host: 'https://api.n8n.io/api/',
			dynamicTemplatesHost: 'https://dynamic-templates.n8n.io/templates',
		},
		versionNotifications: {
			enabled: true,
			endpoint: 'https://api.n8n.io/api/versions/',
			whatsNewEnabled: true,
			whatsNewEndpoint: 'https://api.n8n.io/api/whats-new',
			infoUrl: 'https://docs.n8n.io/hosting/installation/updating/',
		},
		dynamicBanners: {
			endpoint: 'https://api.n8n.io/api/banners',
			enabled: true,
		},
		workflows: {
			defaultName: 'My workflow',
			callerPolicyDefaultOption: 'workflowsFromSameOwner',
			activationBatchSize: 1,
			indexingBatchSize: 10,
			useWorkflowPublicationService: false,
			autosaveDisabled: false,
		},
		endpoints: {
			metrics: {
				enable: false,
				prefix: 'n8n_',
				includeWorkflowIdLabel: false,
				includeWorkflowNameLabel: false,
				includeDefaultMetrics: true,
				includeMessageEventBusMetrics: false,
				includeNodeTypeLabel: false,
				includeCacheMetrics: false,
				includeApiEndpoints: false,
				includeApiPathLabel: false,
				includeApiMethodLabel: false,
				includeCredentialTypeLabel: false,
				includeApiStatusCodeLabel: false,
				includeQueueMetrics: false,
				includeWorkflowExecutionDuration: true,
				queueMetricsInterval: 20,
				activeWorkflowCountInterval: 60,
				includeWorkflowStatistics: false,
				workflowStatisticsInterval: 300,
			},
			additionalNonUIRoutes: '',
			disableProductionWebhooksOnMainProcess: false,
			disableUi: false,
			form: 'form',
			formTest: 'form-test',
			formWaiting: 'form-waiting',
			mcp: 'mcp',
			mcpBuilderEnabled: true,
			mcpMaxRegisteredClients: 5000,
			mcpTest: 'mcp-test',
			payloadSizeMax: 16,
			formDataFileSizeMax: 200,
			rest: 'rest',
			webhook: 'webhook',
			webhookTest: 'webhook-test',
			webhookWaiting: 'webhook-waiting',
			health: '/healthz',
		},
		cache: {
			backend: 'auto',
			memory: {
				maxSize: 3145728,
				ttl: 3600000,
			},
			redis: {
				prefix: 'cache',
				ttl: 3600000,
			},
		},
		chatTrigger: {
			disablePublicChat: false,
		},
		chatHub: {
			executionContextTtl: 3600,
			maxBufferedChunks: 1000,
			streamStateTtl: 300,
		},
		instanceAi: {
			model: 'anthropic/claude-opus-4-7',
			modelUrl: '',
			modelApiKey: '',
			mcpServers: '',
			localGatewayDisabled: false,
			lastMessages: 20,
			observerMessageTokens: 30_000,
			reflectorObservationTokens: 40_000,
			subAgentMaxSteps: 100,
			sandboxEnabled: false,
			sandboxProvider: 'daytona',
			sandboxImage: 'daytonaio/sandbox:0.5.0',
			daytonaApiUrl: '',
			daytonaApiKey: '',
			n8nSandboxServiceUrl: '',
			n8nSandboxServiceApiKey: '',
			sandboxTimeout: 300000,
			sandboxNamePrefix: '',
			daytonaTokenRefreshSkewMs: 300_000,
			builderSandboxTtlMs: 900_000,
			braveSearchApiKey: '',
			searxngUrl: '',
			gatewayApiKey: '',
			threadTtlDays: 90,
			snapshotPruneInterval: 3_600_000,
			snapshotRetention: 86_400_000,
			confirmationTimeout: 86_400_000,
		},
		queue: {
			health: {
				active: false,
				port: 5678,
				address: '::',
			},
			bull: {
				redis: {
					db: 0,
					host: 'localhost',
					password: '',
					port: 6379,
					timeoutThreshold: 10_000,
					username: '',
					clusterNodes: '',
					tls: false,
					dualStack: false,
					slotsRefreshInterval: 5_000,
					slotsRefreshTimeout: 1_000,
					dnsResolveStrategy: 'LOOKUP',
					keepAlive: false,
					keepAliveDelay: 5000,
					keepAliveInterval: 5000,
					reconnectOnFailover: true,
				},
				gracefulShutdownTimeout: 30,
				prefix: 'bull',
				settings: {
					lockDuration: 60_000,
					lockRenewTime: 10_000,
					stalledInterval: 30_000,
				},
			},
		},
		taskRunners: {
			mode: 'internal',
			path: '/runners',
			authToken: '',
			listenAddress: '127.0.0.1',
			maxPayload: 1024 * 1024 * 1024,
			port: 5679,
			maxOldSpaceSize: '',
			maxConcurrency: 10,
			taskTimeout: 300,
			taskRequestTimeout: 60,
			heartbeatInterval: 30,
			grantTokenTtl: 30,
			insecureMode: false,
		},
		sentry: {
			backendDsn: '',
			frontendDsn: '',
			environment: '',
			deploymentName: '',
			profilesSampleRate: 0,
			tracesSampleRate: 0,
			eventLoopBlockThreshold: 500,
			eventLoopBlockMaxEventsPerHour: 5,
		},
		logging: {
			level: 'info',
			format: 'text',
			outputs: ['console'],
			file: {
				fileCountMax: 100,
				fileSizeMax: 16,
				location: 'logs/n8n.log',
			},
			scopes: [],
			cron: {
				activeInterval: 0,
			},
		},
		multiMainSetup: {
			enabled: false,
			ttl: 10,
			interval: 3,
		},
		evaluation: {
			collectionsEnabled: false,
		},
		generic: {
			timezone: 'America/New_York',
			releaseChannel: 'dev',
			gracefulShutdownTimeout: 30,
		},
		license: {
			serverUrl: 'https://license.n8n.io/v1',
			autoRenewalEnabled: true,
			detachFloatingOnShutdown: true,
			activationKey: '',
			tenantId: 1,
			cert: '',
		},
		security: {
			restrictFileAccessTo: '~/.n8n-files',
			blockFileAccessToN8nFiles: true,
			blockFilePatterns: '^(.*\\/)*\\.git(\\/.*)*$',
			daysAbandonedWorkflow: 90,
			contentSecurityPolicy: '{}',
			contentSecurityPolicyReportOnly: false,
			crossOriginOpenerPolicy: 'same-origin',
			disableWebhookHtmlSandboxing: false,
			disableFormHtmlSandboxing: false,
			disableBareRepos: true,
			awsSystemCredentialsAccess: false,
			enableGitNodeHooks: false,
			enableGitNodeAllConfigKeys: false,
		},
		executions: {
			mode: 'regular',
			timeout: -1,
			maxTimeout: 3600,
			pruneData: true,
			pruneDataMaxAge: 336,
			pruneDataMaxCount: 10_000,
			pruneDataHardDeleteBuffer: 1,
			pruneDataIntervals: {
				hardDelete: 15,
				softDelete: 60,
			},
			concurrency: {
				productionLimit: -1,
				evaluationLimit: -1,
			},
			queueRecovery: {
				interval: 180,
				batchSize: 100,
			},
			queueRetention: {
				keepLastCompleted: 0,
				keepLastFailed: 0,
			},
			recovery: {
				maxLastExecutions: 3,
				workflowDeactivationEnabled: false,
			},
			saveDataOnError: 'all',
			saveDataOnSuccess: 'all',
			saveExecutionProgress: false,
			saveDataManualExecutions: true,
			scheduledExecutionDeduplicationEnabled: false,
		},
		diagnostics: {
			enabled: true,
			frontendConfig: '1zPn9bgWPzlQc0p8Gj1uiK6DOTn;https://telemetry.n8n.io',
			backendConfig: '1zPn7YoGC3ZXE9zLeTKLuQCB4F6;https://telemetry.n8n.io',
			posthogConfig: {
				apiKey: 'phc_kMstNfAgBcBkWSh6KdsgN09heqqNe5VNmalHP1Ni9Q4',
				apiHost: 'https://ph.n8n.io',
			},
		},
		aiAssistant: {
			baseUrl: '',
		},
		aiBuilder: {
			apiKey: '',
		},
		tags: {
			disabled: false,
		},
		workflowHistory: {
			pruneTime: -1,
		},
		sso: {
			justInTimeProvisioning: true,
			redirectLoginToSso: true,
			saml: {
				loginEnabled: false,
				loginLabel: '',
			},
			oidc: {
				loginEnabled: false,
			},
			ldap: {
				loginEnabled: false,
				loginLabel: '',
			},
			provisioning: {
				scopesProvisionInstanceRole: false,
				scopesProvisionProjectRoles: false,
				scopesName: 'n8n',
				scopesInstanceRoleClaimName: 'n8n_instance_role',
				scopesProjectsRolesClaimName: 'n8n_projects',
				scopesUseExpressionMapping: false,
			},
		},
		ssrfProtection: {
			enabled: false,
			blockedIpRanges: [...SSRF_DEFAULT_BLOCKED_IP_RANGES],
			allowedIpRanges: [],
			allowedHostnames: [],
			dnsCacheMaxSize: 1024 * 1024,
		},
		httpRequest: {
			enforceGlobalUserAgent: false,
			globalUserAgentValue: '',
		},
		redis: {
			prefix: 'n8n',
		},
		externalFrontendHooksUrls: '',
		// @ts-expect-error structuredClone ignores properties defined as a getter
		ai: {
			enabled: false,
			timeout: 3600000,
			allowSendingParameterValues: true,
		},
		workflowHistoryCompaction: {
			batchDelayMs: 1_000,
			batchSize: 100,
			optimizingMinimumAgeHours: 0.25,
			optimizingTimeWindowHours: 2,
			trimmingMinimumAgeDays: 6,
			trimmingTimeWindowDays: 2,
			trimOnStartUp: false,
		},
		expressionEngine: {
			engine: 'legacy',
			poolSize: 1,
			maxCodeCacheSize: 1024,
			bridgeTimeout: 5000,
			bridgeMemoryLimit: 128,
			observabilityEnabled: true,
			tracesEnabled: true,
			slowEvaluationThresholdMs: 50,
			tracesSampleRate: 0.0,
		},
		instanceSettingsLoader: {
			ownerManagedByEnv: false,
			ownerEmail: '',
			ownerFirstName: 'Instance',
			ownerLastName: 'Owner',
			ownerPasswordHash: '',
			ssoManagedByEnv: false,
			oidcClientId: '',
			oidcClientSecret: '',
			oidcDiscoveryEndpoint: '',
			oidcLoginEnabled: false,
			oidcPrompt: 'select_account',
			oidcAcrValues: '',
			ssoUserRoleProvisioning: 'disabled',
			securityPolicyManagedByEnv: false,
			mfaEnforcedEnabled: false,
			personalSpacePublishingEnabled: true,
			personalSpaceSharingEnabled: true,
			samlMetadata: '',
			samlMetadataUrl: '',
			samlLoginEnabled: false,
			logStreamingManagedByEnv: false,
			logStreamingDestinations: '',
			mcpManagedByEnv: false,
			mcpAccessEnabled: false,
			communityPackagesManagedByEnv: false,
			communityPackages: '',
		},
		agents: {
			checkpointTtlSeconds: 345600,
			modules: [],
		},
	} satisfies GlobalConfigShape;

	it('should use all default values when no env variables are defined', () => {
		process.env = {};
		const config = Container.get(GlobalConfig);
		// Makes sure the objects are structurally equal while respecting getters,
		// which `toEqual` and `toBe` does not do.
		expect(defaultConfig).toMatchObject(config);
		expect(config).toMatchObject(defaultConfig);
		expect(readFileSyncMock).not.toHaveBeenCalled();
	});

	it('should use values from env variables when defined', () => {
		process.env = {
			DB_POSTGRESDB_HOST: 'some-host',
			DB_POSTGRESDB_USER: 'n8n',
			DB_POSTGRESDB_IDLE_CONNECTION_TIMEOUT: '10000',
			DB_TABLE_PREFIX: 'test_',
			DB_PING_INTERVAL_SECONDS: '2',
			NODES_INCLUDE: '["n8n-nodes-base.hackerNews"]',
			DB_LOGGING_MAX_EXECUTION_TIME: '0',
			N8N_METRICS: 'TRUE',
			N8N_TEMPLATES_ENABLED: '0',
			N8N_DYNAMIC_BANNERS_ENDPOINT: 'https://localhost:5678/api/banners',
			N8N_DYNAMIC_BANNERS_ENABLED: 'false',
			N8N_PASSWORD_MIN_LENGTH: '12',
			N8N_ENFORCE_GLOBAL_USER_AGENT: 'true',
			N8N_GLOBAL_USER_AGENT_VALUE: 'AcmeCorp/1.0',
		};
		const config = Container.get(GlobalConfig);

		expect(structuredClone(config)).toEqual({
			...defaultConfig,
			database: {
				logging: defaultConfig.database.logging,
				postgresdb: {
					...defaultConfig.database.postgresdb,
					host: 'some-host',
					user: 'n8n',
					idleTimeoutMs: 10_000,
				},
				sqlite: defaultConfig.database.sqlite,
				tablePrefix: 'test_',
				type: 'sqlite',
				pingIntervalSeconds: 2,
			},
			endpoints: {
				...defaultConfig.endpoints,
				metrics: {
					...defaultConfig.endpoints.metrics,
					enable: true,
				},
			},
			nodes: {
				...defaultConfig.nodes,
				include: ['n8n-nodes-base.hackerNews'],
			},
			templates: {
				...defaultConfig.templates,
				enabled: false,
			},
			dynamicBanners: {
				endpoint: 'https://localhost:5678/api/banners',
				enabled: false,
			},
			userManagement: {
				...defaultConfig.userManagement,
				password: {
					minLength: 12,
				},
			},
			httpRequest: {
				enforceGlobalUserAgent: true,
				globalUserAgentValue: 'AcmeCorp/1.0',
			},
		});
		expect(readFileSyncMock).not.toHaveBeenCalled();
	});

	it('should read values from files using _FILE env variables', () => {
		const passwordFile = '/path/to/postgres/password';
		process.env = {
			DB_POSTGRESDB_PASSWORD_FILE: passwordFile,
		};
		readFileSyncMock.mockReturnValueOnce('password-from-file');

		const config = Container.get(GlobalConfig);
		const expected = {
			...defaultConfig,
			database: {
				...defaultConfig.database,
				postgresdb: {
					...defaultConfig.database.postgresdb,
					password: 'password-from-file',
				},
			},
		};
		// Makes sure the objects are structurally equal while respecting getters,
		// which `toEqual` and `toBe` does not do.
		expect(config).toMatchObject(expected);
		expect(expected).toMatchObject(config);
		expect(readFileSyncMock).toHaveBeenCalled();
	});

	it('should warn when _FILE env variable value contains whitespace', () => {
		const passwordFile = '/path/to/postgres/password';
		process.env = {
			DB_POSTGRESDB_PASSWORD_FILE: passwordFile,
		};
		readFileSyncMock.mockReturnValueOnce('password-from-file\n');

		const config = Container.get(GlobalConfig);
		expect(config.database.postgresdb.password).toBe('password-from-file');
		expect(consoleWarnMock).toHaveBeenCalledWith(
			expect.stringContaining(
				'DB_POSTGRESDB_PASSWORD_FILE contains leading or trailing whitespace',
			),
		);
	});

	it('should handle invalid numbers', () => {
		process.env = {
			DB_LOGGING_MAX_EXECUTION_TIME: 'abcd',
		};
		const config = Container.get(GlobalConfig);
		expect(config.database.logging.maxQueryExecutionTime).toEqual(0);
		expect(consoleWarnMock).toHaveBeenCalledWith(
			'Invalid number value for DB_LOGGING_MAX_EXECUTION_TIME: abcd',
		);
	});

	it('should clamp password min length to valid range', () => {
		process.env = { N8N_PASSWORD_MIN_LENGTH: '100' };
		const config = Container.get(GlobalConfig);
		expect(config.userManagement.password.minLength).toEqual(64);
	});

	it('should floor password min length at 8', () => {
		process.env = { N8N_PASSWORD_MIN_LENGTH: '2' };
		const config = Container.get(GlobalConfig);
		expect(config.userManagement.password.minLength).toEqual(8);
	});

	describe('string unions', () => {
		it('on invalid value, should warn and fall back to default value', () => {
			process.env = {
				N8N_RUNNERS_MODE: 'non-existing-mode',
				DB_TYPE: 'postgresdb',
			};

			const globalConfig = Container.get(GlobalConfig);
			expect(globalConfig.taskRunners.mode).toEqual('internal');
			expect(consoleWarnMock).toHaveBeenCalledWith(
				expect.stringContaining(
					"Invalid value for N8N_RUNNERS_MODE - Invalid enum value. Expected 'internal' | 'external', received 'non-existing-mode'. Falling back to default value.",
				),
			);

			expect(globalConfig.database.type).toEqual('postgresdb');
		});

		it('should validate crossOriginOpenerPolicy enum values', () => {
			process.env = {
				N8N_CROSS_ORIGIN_OPENER_POLICY: 'same-origin-allow-popups',
			};

			const globalConfig = Container.get(GlobalConfig);
			expect(globalConfig.security.crossOriginOpenerPolicy).toEqual('same-origin-allow-popups');
		});

		it('should warn and fall back to default for invalid crossOriginOpenerPolicy', () => {
			process.env = {
				N8N_CROSS_ORIGIN_OPENER_POLICY: 'invalid-policy',
			};

			const globalConfig = Container.get(GlobalConfig);
			expect(globalConfig.security.crossOriginOpenerPolicy).toEqual('same-origin');
		});
	});

	describe('health endpoint transformation', () => {
		it('should add leading slash if not present', () => {
			process.env = {
				N8N_ENDPOINT_HEALTH: 'healthz',
			};

			const config = Container.get(GlobalConfig);
			expect(config.endpoints.health).toEqual('/healthz');
		});

		it('should keep leading slash if already present', () => {
			process.env = {
				N8N_ENDPOINT_HEALTH: '/custom-health',
			};

			const config = Container.get(GlobalConfig);
			expect(config.endpoints.health).toEqual('/custom-health');
		});

		it('should add leading slash to paths with multiple segments', () => {
			process.env = {
				N8N_ENDPOINT_HEALTH: 'api/v1/health',
			};

			const config = Container.get(GlobalConfig);
			expect(config.endpoints.health).toEqual('/api/v1/health');
		});
	});

	describe('ExecutionsConfig', () => {
		it('should default scheduledExecutionDeduplicationEnabled to false', () => {
			process.env = {};
			const config = Container.get(ExecutionsConfig);
			expect(config.scheduledExecutionDeduplicationEnabled).toBe(false);
		});

		it('should enable scheduledExecutionDeduplicationEnabled when env var is set to true', () => {
			process.env = {
				N8N_SCHEDULED_EXECUTION_DEDUPLICATION_ENABLED: 'true',
			};
			const config = Container.get(ExecutionsConfig);
			expect(config.scheduledExecutionDeduplicationEnabled).toBe(true);
		});
	});
});
