/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { LICENSE_FEATURES } from '@n8n/constants';
import {
	AuthRolesService,
	DeploymentKeyRepository,
	ExecutionRepository,
	SettingsRepository,
} from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { McpServer } from '@n8n/n8n-nodes-langchain/mcp/core';
import glob from 'fast-glob';
import { createReadStream, createWriteStream, existsSync } from 'fs';
import { mkdir } from 'fs/promises';
import { BinaryDataConfig } from 'n8n-core';
import { jsonParse, sleep, type IWorkflowExecutionDataProcess } from 'n8n-workflow';
import path from 'path';
import replaceStream from 'replacestream';
import { pipeline } from 'stream/promises';
import { z } from 'zod';

import { ActiveExecutions } from '@/active-executions';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import config from '@/config';
import { EDITOR_UI_DIST_DIR, N8N_VERSION } from '@/constants';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { EventService } from '@/events/event.service';
import { ExecutionService } from '@/executions/execution.service';
import { MultiMainSetup } from '@/scaling/multi-main-setup.ee';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { PubSubRegistry } from '@/scaling/pubsub/pubsub.registry';
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
import { Server } from '@/server';
import { JwtService } from '@/services/jwt.service';
import { OwnershipService } from '@/services/ownership.service';
import { ExecutionsPruningService } from '@/services/pruning/executions-pruning.service';
import { UrlService } from '@/services/url.service';
import { WaitTracker } from '@/wait-tracker';
import { WorkflowRunner } from '@/workflow-runner';

import { BaseCommand } from './base-command';
import { CredentialsOverwrites } from '@/credentials-overwrites';
import { DeprecationService } from '@/deprecation/deprecation.service';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { WorkflowHistoryCompactionService } from '@/services/pruning/workflow-history-compaction.service';
import { N8NCheckpointStorage } from '@/modules/agents/integrations/n8n-checkpoint-storage';

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const open = require('open');

const flagsSchema = z.object({
	open: z.boolean().alias('o').describe('opens the UI automatically in browser').optional(),
});

@Command({
	name: 'start',
	description: 'Starts n8n. Makes Web-UI available and starts active workflows',
	examples: ['', '-o'],
	flagsSchema,
})
export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
	protected activeWorkflowManager: ActiveWorkflowManager;

	protected server = Container.get(Server);

	override needsCommunityPackages = true;

	override needsTaskRunner = true;

	private getEditorUrl = () => Container.get(UrlService).getInstanceBaseUrl();

	/**
	 * Opens the UI in browser
	 */
	private openBrowser() {
		const editorUrl = this.getEditorUrl();

		open(editorUrl, { wait: true }).catch(() => {
			this.logger.info(
				`\nWas not able to open URL in browser. Please open manually by visiting:\n${editorUrl}\n`,
			);
		});
	}

	/**
	 * Stop n8n in a graceful way.
	 * Make for example sure that all the webhooks from third party services
	 * get removed.
	 */
	async stopProcess() {
		this.logger.info('\nStopping n8n...');

		try {
			// Stop with trying to activate workflows that could not be activated
			this.activeWorkflowManager.removeAllQueuedWorkflowActivations();

			Container.get(WaitTracker).stopTracking();

			await this.externalHooks?.run('n8n.stop');

			await this.activeWorkflowManager.removeAllNonWebhookTriggerWorkflows();

			if (this.instanceSettings.isMultiMain) {
				await Container.get(MultiMainSetup).shutdown();
			}

			if (this.globalConfig.executions.mode === 'queue') {
				Container.get(Publisher).shutdown();
				Container.get(Subscriber).shutdown();
			}

			Container.get(EventService).emit('instance-stopped');

			await Container.get(ActiveExecutions).shutdown();

			// Finally shut down Event Bus
			await Container.get(MessageEventBus).close();
		} catch (error) {
			await this.exitWithCrash('There was an error shutting down n8n.', error);
		}

		await this.exitSuccessFully();
	}

	/**
	 * Generates meta tags with base64-encoded configuration values
	 * for REST endpoint path and Sentry config.
	 */
	private generateConfigTags() {
		const frontendSentryConfig = JSON.stringify({
			dsn: this.globalConfig.sentry.frontendDsn,
			environment: process.env.ENVIRONMENT || 'development',
			serverName: process.env.DEPLOYMENT_NAME,
			release: `n8n@${N8N_VERSION}`,
		});
		const b64Encode = (value: string) => Buffer.from(value).toString('base64');

		// Base64 encode the configuration values
		const restEndpointEncoded = b64Encode(this.globalConfig.endpoints.rest);
		const sentryConfigEncoded = b64Encode(frontendSentryConfig);

		const configMetaTags = [
			`<meta name="n8n:config:rest-endpoint" content="${restEndpointEncoded}">`,
			`<meta name="n8n:config:sentry" content="${sentryConfigEncoded}">`,
		].join('');

		return configMetaTags;
	}

	private async generateStaticAssets() {
		// Read the index file and replace the path placeholder
		const n8nPath = this.globalConfig.path;
		const hooksUrls = this.globalConfig.externalFrontendHooksUrls;

		let scriptsString = '';
		if (hooksUrls) {
			scriptsString = hooksUrls.split(';').reduce((acc, curr) => {
				return `${acc}<script src="${curr}"></script>`;
			}, '');
		}

		const closingTitleTag = '</title>';
		const { staticCacheDir } = this.instanceSettings;
		const compileFile = async (fileName: string) => {
			const filePath = path.join(EDITOR_UI_DIST_DIR, fileName);
			if (/(index\.html)|.*\.(js|css)/.test(filePath) && existsSync(filePath)) {
				const destFile = path.join(staticCacheDir, fileName);
				await mkdir(path.dirname(destFile), { recursive: true });
				const streams = [
					createReadStream(filePath, 'utf-8'),
					replaceStream('%CONFIG_TAGS%', this.generateConfigTags(), { ignoreCase: false }),
					replaceStream('/{{BASE_PATH}}/', n8nPath, { ignoreCase: false }),
					replaceStream('/%7B%7BBASE_PATH%7D%7D/', n8nPath, { ignoreCase: false }),
					replaceStream('/%257B%257BBASE_PATH%257D%257D/', n8nPath, { ignoreCase: false }),
				];
				if (filePath.endsWith('index.html')) {
					streams.push(
						replaceStream('{{REST_ENDPOINT}}', this.globalConfig.endpoints.rest, {
							ignoreCase: false,
						}),
						replaceStream(closingTitleTag, closingTitleTag + scriptsString, {
							ignoreCase: false,
						}),
					);
				}
				streams.push(createWriteStream(destFile, 'utf-8'));
				return await pipeline(streams);
			}
		};

		const files = await glob('**/*.{css,js}', { cwd: EDITOR_UI_DIST_DIR });
		await Promise.all([compileFile('index.html'), ...files.map(compileFile)]);
	}

	async init() {
		await this.initCrashJournal();

		this.logger.info('Initializing n8n process');
		if (this.globalConfig.executions.mode === 'queue') {
			const scopedLogger = this.logger.scoped('scaling');
			scopedLogger.debug('Starting main instance in scaling mode');
			scopedLogger.debug(`Host ID: ${this.instanceSettings.hostId}`);

			if (process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS === 'true') {
				this.needsTaskRunner = false;
			}
		}

		await super.init();

		Container.get(DeprecationService).warn();

		this.activeWorkflowManager = Container.get(ActiveWorkflowManager);

		const isMultiMainEnabled =
			this.globalConfig.executions.mode === 'queue' && this.globalConfig.multiMainSetup.enabled;

		this.instanceSettings.setMultiMainEnabled(isMultiMainEnabled);

		/**
		 * We temporarily license multi-main to allow it to set instance role,
		 * which is needed by license init. Once the license is initialized,
		 * the actual value will be used for the license check.
		 */
		if (isMultiMainEnabled) this.instanceSettings.setMultiMainLicensed(true);

		if (this.globalConfig.executions.mode === 'regular') {
			this.instanceSettings.markAsLeader();
		} else {
			await this.initOrchestration();
		}

		await this.instanceSettings.initialize(Container.get(DeploymentKeyRepository));
		await Container.get(JwtService).initialize(Container.get(DeploymentKeyRepository));
		await Container.get(BinaryDataConfig).initialize(Container.get(DeploymentKeyRepository));

		await this.initLicense();

		if (isMultiMainEnabled) {
			await this.ensureMultiMainLicensed();
		}

		await this.initCommunityPackages();

		// Initialize the auth roles service to make sure that roles are correctly setup for the instance.
		// Only run on main instance - workers should not modify auth roles/scopes as they may have
		// different code versions, and scope sync would incorrectly delete scopes they don't know about.
		// All main instances sync on startup; a Postgres advisory lock inside a transaction
		// serializes concurrent callers to prevent duplicate-key crashes.
		if (this.instanceSettings.instanceType === 'main') {
			await Container.get(AuthRolesService).init();
			this.logger.debug('Auth roles service init complete');

			await this.initInstanceSettingsLoader();
			this.logger.debug('Instance settings loader init complete');
		}

		await this.initBinaryDataService();
		this.logger.debug('Binary data service init complete');
		Container.get(WaitTracker).init();
		this.logger.debug('Wait tracker init complete');
		await Container.get(CredentialsOverwrites).init();
		this.logger.debug('Credentials overwrites init complete');
		await this.initDataDeduplicationService();
		this.logger.debug('Data deduplication service init complete');
		await this.initExternalHooks();
		this.logger.debug('External hooks init complete');
		this.initWorkflowHistory();
		this.logger.debug('Workflow history init complete');

		if (!isMultiMainEnabled) {
			await this.cleanupTestRunner();
			this.logger.debug('Test runner cleanup complete');
		}

		if (!this.globalConfig.endpoints.disableUi) {
			await this.generateStaticAssets();
		}

		await this.moduleRegistry.initModules(this.instanceSettings.instanceType);

		// Initialize auth handler registry after modules are loaded
		const { AuthHandlerRegistry } = await import('@/auth/auth-handler.registry');
		await Container.get(AuthHandlerRegistry).init();

		if (this.instanceSettings.isMultiMain) {
			Container.get(MultiMainSetup).registerEventHandlers();
		}

		await this.executionContextHookRegistry.init();
		await Container.get(LoadNodesAndCredentials).postProcessLoaders();
	}

	private async initInstanceSettingsLoader(): Promise<void> {
		const { InstanceSettingsLoaderService } = await import(
			'@/instance-settings-loader/instance-settings-loader.service'
		);
		await Container.get(InstanceSettingsLoaderService).init();
	}

	async initOrchestration() {
		Container.get(Publisher);

		Container.get(PubSubRegistry).init();

		const subscriber = Container.get(Subscriber);
		await subscriber.subscribe(subscriber.getCommandChannel());
		await subscriber.subscribe(subscriber.getWorkerResponseChannel());
		await subscriber.subscribe(subscriber.getMcpRelayChannel());

		// Set up MCP relay handler for multi-main queue mode
		subscriber.setMcpRelayHandler((msg) => {
			try {
				const mcpServer = McpServer.instance(this.logger);
				mcpServer.handleWorkerResponse(msg.sessionId, msg.messageId, msg.response);
			} catch (error) {
				this.logger.error('Failed to handle MCP relay message', {
					sessionId: msg.sessionId,
					messageId: msg.messageId,
					error: error instanceof Error ? error.message : String(error),
				});
			}
		});

		if (this.instanceSettings.isMultiMain) {
			await Container.get(MultiMainSetup).init();
		} else {
			this.instanceSettings.markAsLeader();
		}
	}

	/**
	 * Ensures the multi-main license entitlement is present. Followers retry with
	 * exponential backoff because the leader may not have written the cert to the
	 * database yet when the follower starts up.
	 */
	private async ensureMultiMainLicensed() {
		if (this.license.isMultiMainLicensed()) return;

		if (!this.instanceSettings.isLeader) {
			const maxRetries = 5;
			for (let attempt = 1; attempt <= maxRetries; attempt++) {
				const delayMs = 2 ** attempt * 1000; // 2s, 4s, 8s, 16s, 32s (~62s total)
				this.logger.warn(
					`Instance not licensed for multi-main — retrying license check in ${delayMs / 1000}s (attempt ${attempt}/${maxRetries})`,
				);
				await sleep(delayMs);
				await this.license.reload();
				if (this.license.isMultiMainLicensed()) return;
			}
		}

		throw new FeatureNotLicensedError(LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES, {
			extra: {
				instance: {
					type: this.instanceSettings.instanceType,
					isLeader: this.instanceSettings.isLeader,
				},
				config: {
					autoRenewalEnabled: this.globalConfig.license?.autoRenewalEnabled,
					activationKeySet: !!this.globalConfig.license?.activationKey,
					usingEphemeralCert: !!this.globalConfig.license?.cert,
				},
				cert: {
					exists: (await this.license.loadCertStr()).length > 0,
					isValid: this.license.isCertValid(),
					hasMultiMain: this.license.hasFeatureInCert(LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES),
					expiresAt: this.license.getExpiryDate()?.toISOString() ?? null,
					terminatesAt: this.license.getTerminationDate()?.toISOString() ?? null,
					consumerId: this.license.getConsumerId(),
				},
			},
		});
	}

	async run() {
		const { flags } = this;

		// Load settings from database and set them to config.
		const databaseSettings = await Container.get(SettingsRepository).findBy({
			loadOnStartup: true,
		});
		databaseSettings.forEach((setting) => {
			config.set(setting.key, jsonParse(setting.value, { fallbackValue: setting.value }));
		});

		const { type: dbType } = this.globalConfig.database;
		if (dbType === 'sqlite') {
			const shouldRunVacuum = this.globalConfig.database.sqlite.executeVacuumOnStartup;
			if (shouldRunVacuum) {
				await Container.get(ExecutionRepository).query('VACUUM;');
			}
		}

		await this.server.start();

		Container.get(ExecutionsPruningService).init();
		Container.get(WorkflowHistoryCompactionService).init();
		Container.get(N8NCheckpointStorage).init();

		if (this.globalConfig.executions.mode === 'regular') {
			await this.runEnqueuedExecutions();
		}

		// Start to get active workflows and run their triggers
		if (this.globalConfig.workflows.useWorkflowPublicationService) {
			const { PublishedWorkflowEnqueuer } = await import(
				'@/workflows/publication/published-workflow-enqueuer'
			);
			const { WorkflowPublicationOutboxConsumer } = await import(
				'@/workflows/publication/workflow-publication-outbox-consumer'
			);
			const { WorkflowPublicationOutboxCleanupService } = await import(
				'@/workflows/publication/workflow-publication-outbox-cleanup.service'
			);

			// Import for its side effect: registering the trigger deactivator's
			// @OnLeaderStepdown and @OnShutdown handlers. Nothing else loads this module.
			await import('@/workflows/publication/published-workflow-trigger-deactivator');

			// Enqueue needs to happen before outbox consumer init, so it can activate
			// everything on the first drain
			if (this.instanceSettings.isLeader) {
				await Container.get(PublishedWorkflowEnqueuer).enqueueActiveWorkflows();
			}

			// Don't await: the immediate drain activates every trigger and can take a
			// while, so let it run in the background instead of blocking startup.
			void Container.get(WorkflowPublicationOutboxConsumer)
				.init()
				.catch((error) => {
					this.errorReporter.error(error, { shouldBeLogged: true });
				});

			Container.get(WorkflowPublicationOutboxCleanupService).init();
		} else {
			await this.activeWorkflowManager.init();
		}

		Container.get(LoadNodesAndCredentials).releaseTypes();

		const editorUrl = this.getEditorUrl();

		this.log(`\nEditor is now accessible via:\n${editorUrl}`);

		// Allow to open n8n editor by pressing "o"
		if (Boolean(process.stdout.isTTY) && process.stdin.setRawMode) {
			process.stdin.setRawMode(true);
			process.stdin.resume();
			process.stdin.setEncoding('utf8');

			if (flags.open) {
				this.openBrowser();
			}
			this.log('\nPress "o" to open in Browser.');
			process.stdin.on('data', (key: string) => {
				if (key === 'o') {
					this.openBrowser();
				} else if (key.charCodeAt(0) === 3) {
					// Ctrl + c got pressed
					void this.onTerminationSignal('SIGINT')();
				} else {
					// When anything else got pressed, record it and send it on enter into the child process

					if (key.charCodeAt(0) === 13) {
						// send to child process and print in terminal
						process.stdout.write('\n');
					} else {
						// record it and write into terminal
						process.stdout.write(key);
					}
				}
			});
		}
	}

	async catch(error: Error) {
		if (error.stack) this.logger.error(error.stack);
		await this.exitWithCrash('Exiting due to an error.', error);
	}

	/**
	 * During startup, we may find executions that had been enqueued at the time of shutdown.
	 *
	 * If so, start running any such executions concurrently up to the concurrency limit, and
	 * enqueue any remaining ones until we have spare concurrency capacity again.
	 */
	private async runEnqueuedExecutions() {
		const executions = await Container.get(ExecutionService).findAllEnqueuedExecutions();

		if (executions.length === 0) return;

		this.logger.debug('[Startup] Found enqueued executions to run', {
			executionIds: executions.map((e) => e.id),
		});

		const ownershipService = Container.get(OwnershipService);
		const workflowRunner = Container.get(WorkflowRunner);

		for (const execution of executions) {
			const project = await ownershipService.getWorkflowProjectCached(execution.workflowId);

			const data: IWorkflowExecutionDataProcess = {
				executionMode: execution.mode,
				executionData: execution.data,
				workflowData: execution.workflowData,
				projectId: project.id,
			};

			Container.get(EventService).emit('execution-started-during-bootup', {
				executionId: execution.id,
			});

			// do not block - each execution either runs concurrently or is queued
			void workflowRunner.run(data, undefined, false, execution.id);
		}
	}
}
