import { Logger } from '@n8n/backend-common';
import { WorkflowsConfig } from '@n8n/config';
import type { IWorkflowDb } from '@n8n/db';
import { WorkflowDependencies, WorkflowDependencyRepository, WorkflowRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { ErrorReporter, SpanStatus, Tracing } from 'n8n-core';
import {
	DATA_TABLE_NODE_TYPES,
	ensureError,
	INode,
	IWorkflowBase,
	IWorkflowSettings,
} from 'n8n-workflow';

import { EventService } from '@/events/event.service';

// A safety limit to prevent infinite loops in indexing.
const LOOP_LIMIT = 1_000_000_000;

// Placeholder key for workflows with no dependencies.
const WORKFLOW_INDEXED_PLACEHOLDER_KEY = '__INDEXED__';

/**
 * Service for managing the workflow dependency index. The index tracks dependencies such as node types,
 * credentials, workflow calls, and webhook paths used by each workflow. The service builds the index on server start
 * and updates it in response to workflow-related events.
 *
 */
@Service()
export class WorkflowIndexService {
	private readonly batchSize: number;

	constructor(
		private readonly dependencyRepository: WorkflowDependencyRepository,
		private readonly workflowRepository: WorkflowRepository,
		private readonly eventService: EventService,
		private readonly logger: Logger,
		private readonly errorReporter: ErrorReporter,
		private readonly tracing: Tracing,
		workflowsConfig: WorkflowsConfig,
	) {
		this.batchSize = workflowsConfig.indexingBatchSize;
	}

	init() {
		this.eventService.on('server-started', async (): Promise<void> => {
			this.logger.info('Building workflow dependency index...');
			await this.buildIndex().catch((e) => this.errorReporter.error(e));
		});
		this.eventService.on('workflow-created', async ({ workflow }) => {
			await this.updateIndexForDraft(workflow);
		});
		this.eventService.on('workflow-saved', async ({ workflow }) => {
			await this.updateIndexForDraft(workflow);
		});
		this.eventService.on('workflow-deleted', async ({ workflowId }) => {
			await this.removeDependenciesForWorkflow(workflowId);
		});
		this.eventService.on('workflow-activated', async ({ workflow }) => {
			if (workflow.activeVersionId === null) {
				this.logger.warn(
					`Workflow ${workflow.id} activated with null activeVersionId. Skipping index update.`,
				);
				return;
			}
			// At activation time, the draft nodes are the published nodes.
			await this.updateIndexForPublished(workflow, workflow.activeVersionId, workflow.nodes);
		});
	}

	async buildIndex() {
		return await this.tracing.startSpan(
			{ name: 'WorkflowIndex build', op: 'workflow-index.build' },
			async (span) => {
				const draftCount = await this.buildIndexInternal(
					async (batchSize) =>
						await this.workflowRepository.findWorkflowsNeedingIndexing(batchSize),
					'draft',
				);

				const publishedCount = await this.buildIndexInternal(
					async (batchSize) =>
						await this.workflowRepository.findWorkflowsNeedingPublishedVersionIndexing(batchSize),
					'published',
				);

				this.logger.info(
					`Finished building workflow dependency index. Processed ${draftCount} draft workflows, ${publishedCount} published workflows.`,
				);
				span.setStatus({ code: SpanStatus.ok });
			},
		);
	}

	private async buildIndexInternal(
		unindexedWorkflowFinder: (batchSize: number) => Promise<IWorkflowDb[]>,
		dependencyType: 'draft' | 'published',
	): Promise<number> {
		const batchSize = this.batchSize;
		let processedCount = 0;

		while (processedCount < LOOP_LIMIT) {
			const workflows = await unindexedWorkflowFinder(batchSize);

			if (workflows.length === 0) {
				break;
			}

			// Build the index for each workflow in the batch.
			for (const workflow of workflows) {
				if (dependencyType === 'draft') {
					await this.updateIndexForDraft(workflow);
				} else {
					const publishedNodes = workflow.activeVersion?.nodes;
					if (!publishedNodes) {
						this.logger.warn(
							`Workflow ${workflow.id} has activeVersionId but no activeVersion nodes. Skipping published index.`,
						);
						continue;
					}
					await this.updateIndexForPublished(workflow, workflow.activeVersionId!, publishedNodes);
				}
			}

			processedCount += workflows.length;
			this.logger.debug(`Indexed ${processedCount} ${dependencyType} workflows so far`);

			// If we got fewer workflows than the batch size, we're done.
			if (workflows.length < batchSize) {
				break;
			}
		}

		if (processedCount >= LOOP_LIMIT) {
			const message = `Stopping ${dependencyType} workflow indexing because we hit the limit of ${LOOP_LIMIT} workflows. There's probably a bug causing an infinite loop.`;
			this.logger.warn(message);
			this.errorReporter.warn(new Error(message));
		}

		return processedCount;
	}

	async updateIndexForDraft(workflow: IWorkflowBase) {
		const dependencyUpdates = new WorkflowDependencies(
			workflow.id,
			workflow.versionCounter,
			/*publishedVersionId=*/ null,
		);
		return await this.updateIndexInternal(
			dependencyUpdates,
			workflow.nodes,
			workflow.name,
			workflow.settings,
		);
	}

	async updateIndexForPublished(
		workflow: IWorkflowBase,
		publishedVersionId: string,
		publishedNodes: INode[],
	) {
		const dependencyUpdates = new WorkflowDependencies(
			workflow.id,
			workflow.versionCounter,
			publishedVersionId,
		);
		return await this.updateIndexInternal(
			dependencyUpdates,
			publishedNodes,
			workflow.name,
			workflow.settings,
		);
	}

	async removeDependenciesForWorkflow(workflowId: string) {
		return await this.tracing.startSpan(
			{
				name: 'WorkflowIndex remove',
				op: 'workflow-index.remove',
				attributes: this.tracing.pickWorkflowAttributes({ id: workflowId }),
			},
			async (span) => {
				await this.dependencyRepository.removeDependenciesForWorkflow(workflowId);
				span.setStatus({ code: SpanStatus.ok });
			},
		);
	}

	/**
	 * Update the dependency index for a given workflow.
	 *
	 * NOTE: this should generally be handled via events, rather than called directly.
	 * The exception is during workflow imports where it's simpler to call directly.
	 *
	 * IMPORTANT: The indexer's input set (`nodes` and `settings`) is mirrored by
	 * the `workflow_version_increment` DB trigger, which only bumps
	 * `versionCounter` when one of those columns changes. If you add code that
	 * indexes any other column on `workflow_entity`, then update the trigger's gate
	 * condition too. Otherwise the staleness check in `findWorkflowsNeedingIndexing`
	 * will miss reindex work. See
	 * `packages/@n8n/db/src/migrations/sqlite/1784000000003-LimitWorkflowVersionTriggerToContent.ts`
	 * (and the postgres equivalent) for the gate condition to extend.
	 */
	private async updateIndexInternal(
		dependencyUpdates: WorkflowDependencies,
		nodes: INode[],
		workflowName?: string,
		settings?: IWorkflowSettings,
	) {
		const indexType = dependencyUpdates.publishedVersionId ? 'published' : 'draft';
		const workflowId = dependencyUpdates.workflowId;

		return await this.tracing.startSpan(
			{
				name: 'WorkflowIndex update',
				op: 'workflow-index.update',
				attributes: {
					...this.tracing.pickWorkflowAttributes({ id: workflowId, name: workflowName }),
					'n8n.workflow-index.type': indexType,
				},
			},
			async (span) => {
				nodes.forEach((node) => {
					this.addNodeTypeDependencies(node, dependencyUpdates);
					this.addCredentialDependencies(node, dependencyUpdates);
					this.addDataTableDependencies(node, dependencyUpdates);
					this.addWorkflowCallDependencies(node, dependencyUpdates);
					this.addWebhookPathDependencies(node, dependencyUpdates);
				});

				this.addErrorWorkflowDependency(settings, dependencyUpdates);

				// If no dependencies were extracted, add a placeholder to mark the workflow as indexed
				if (dependencyUpdates.dependencies.length === 0) {
					dependencyUpdates.add({
						dependencyType: 'workflowIndexed',
						dependencyKey: WORKFLOW_INDEXED_PLACEHOLDER_KEY,
						dependencyInfo: null,
					});
				}

				let updated: boolean;
				try {
					updated = await this.dependencyRepository.updateDependenciesForWorkflow(
						workflowId,
						dependencyUpdates,
					);
				} catch (e) {
					const error = ensureError(e);
					this.logger.error(
						`Failed to update workflow ${indexType} dependency index for workflow ${workflowId}: ${error.message}`,
					);
					this.errorReporter.error(error);
					span.setStatus({ code: SpanStatus.error });
					return;
				}
				this.logger.debug(
					`Workflow ${indexType} dependency index ${updated ? 'updated' : 'skipped'} for workflow ${workflowId}`,
				);
				span.setStatus({ code: SpanStatus.ok });
			},
		);
	}

	private addNodeTypeDependencies(node: INode, dependencyUpdates: WorkflowDependencies): void {
		if (node.type) {
			dependencyUpdates.add({
				dependencyType: 'nodeType',
				dependencyKey: node.type,
				dependencyInfo: { nodeId: node.id, nodeVersion: node.typeVersion },
			});
		}
	}

	private addCredentialDependencies(node: INode, dependencyUpdates: WorkflowDependencies): void {
		if (!node.credentials) {
			return;
		}
		for (const credentialDetails of Object.values(node.credentials)) {
			const { id } = credentialDetails;
			if (!id) {
				continue;
			}
			dependencyUpdates.add({
				dependencyType: 'credentialId',
				dependencyKey: id,
				dependencyInfo: { nodeId: node.id, nodeVersion: node.typeVersion },
			});
		}
	}

	private addDataTableDependencies(node: INode, dependencyUpdates: WorkflowDependencies): void {
		if (!DATA_TABLE_NODE_TYPES.includes(node.type)) {
			return;
		}
		const dataTableId = node.parameters?.['dataTableId'] as
			| { mode?: string; value?: string }
			| undefined;
		if (!dataTableId?.value || typeof dataTableId.value !== 'string') {
			return;
		}
		// Skip expression-based IDs that can't be statically resolved
		if (dataTableId.value.includes('{')) {
			return;
		}

		dependencyUpdates.add({
			dependencyType: 'dataTableId',
			dependencyKey: dataTableId.value,
			dependencyInfo: { nodeId: node.id, nodeVersion: node.typeVersion, mode: dataTableId.mode },
		});
	}

	private addWorkflowCallDependencies(node: INode, dependencyUpdates: WorkflowDependencies): void {
		if (node.type !== 'n8n-nodes-base.executeWorkflow') {
			return;
		}
		const calledWorkflowId: string | undefined = this.getCalledWorkflowIdFrom(node);
		if (!calledWorkflowId) {
			return;
		}
		dependencyUpdates.add({
			dependencyType: 'workflowCall',
			dependencyKey: calledWorkflowId,
			dependencyInfo: { nodeId: node.id, nodeVersion: node.typeVersion },
		});
	}

	private addWebhookPathDependencies(node: INode, dependencyUpdates: WorkflowDependencies): void {
		if (node.type !== 'n8n-nodes-base.webhook') {
			return;
		}
		const webhookPath = node.parameters.path as string;
		if (webhookPath) {
			dependencyUpdates.add({
				dependencyType: 'webhookPath',
				dependencyKey: webhookPath,
				dependencyInfo: { nodeId: node.id, nodeVersion: node.typeVersion },
			});
		}
	}

	private addErrorWorkflowDependency(
		settings: IWorkflowSettings | undefined,
		dependencyUpdates: WorkflowDependencies,
	): void {
		const errorWorkflowId = settings?.errorWorkflow;
		if (!errorWorkflowId || errorWorkflowId === 'DEFAULT') {
			return;
		}
		dependencyUpdates.add({
			dependencyType: 'errorWorkflow',
			dependencyKey: errorWorkflowId,
			dependencyInfo: null,
		});
	}

	private getCalledWorkflowIdFrom(node: INode): string | undefined {
		if (node.parameters?.['source'] === 'parameter') {
			return undefined; // The sub-workflow is provided directly in the parameters, so no dependency to track.
		}
		if (node.parameters?.['source'] === 'localFile') {
			return undefined; // The sub-workflow is provided via a local file, so no dependency to track.
		}
		if (node.parameters?.['source'] === 'url') {
			return undefined; // The sub-workflow is provided via a URL, so no dependency to track.
		}
		if (!('workflowId' in node.parameters)) {
			// This happens when the node is first added to the canvas.
			return undefined; // The workflowId is not present in the parameters, so no dependency to track.
		}
		// We have a workflowId. This might be either directly as a string, or an object.
		if (typeof node.parameters?.['workflowId'] === 'string') {
			return node.parameters?.['workflowId'];
		}
		if (
			node.parameters &&
			typeof node.parameters['workflowId'] === 'object' &&
			node.parameters['workflowId'] !== null &&
			'value' in node.parameters['workflowId'] &&
			typeof node.parameters['workflowId']['value'] === 'string'
		) {
			return node.parameters['workflowId']['value'];
		}
		this.errorReporter.warn(
			`While indexing, could not determine called workflow ID from executeWorkflow node ${node.id}`,
			{ extra: node.parameters },
		);
		return undefined;
	}
}
