/**
 * JSON Serializer Plugin
 *
 * Serializes workflows to n8n's standard JSON format.
 */

import { deepCopy } from 'n8n-workflow';
import { randomUUID } from 'node:crypto';

import { foldLegacyErrorConnections } from '../../../types/base';
import type {
	WorkflowJSON,
	NodeJSON,
	IConnections,
	IDataObject,
	GraphNode,
} from '../../../types/base';
import { START_X, DEFAULT_Y } from '../../constants';
import { calculateNodePositions, calculateNodePositionsDagre } from '../../layout-utils';
import {
	normalizeResourceLocators,
	escapeNewlinesInExpressionStrings,
	parseVersion,
	generateDeterministicGroupId,
} from '../../string-utils';
import type { SerializerPlugin, SerializerContext } from '../types';

/**
 * Node types that require a webhookId for proper webhook path registration.
 * Without it, n8n falls back to encoding the node name into the URL path.
 */
const WEBHOOK_NODE_TYPES = new Set([
	'n8n-nodes-base.webhook',
	'n8n-nodes-base.formTrigger',
	'@n8n/n8n-nodes-langchain.mcpTrigger',
]);

/**
 * Serialize a single node to NodeJSON format.
 */
function serializeNode(
	mapKey: string,
	graphNode: GraphNode,
	nodePositions: Map<string, [number, number]>,
): NodeJSON | undefined {
	const instance = graphNode.instance;

	// Skip invalid nodes (shouldn't happen, but defensive)
	if (!instance?.name || !instance.type) {
		return undefined;
	}

	const config = instance.config ?? {};
	const position = config.position ?? nodePositions.get(mapKey) ?? [START_X, DEFAULT_Y];

	// Determine node name:
	// - If config has _originalName, use that (preserves undefined for sticky notes from fromJSON)
	// - If mapKey was auto-renamed (e.g., "Process 1" from "Process"), use mapKey
	// - Otherwise use instance.name (preserves original name for fromJSON imports)
	let nodeName: string | undefined;
	if ('_originalName' in config) {
		// Node was loaded via fromJSON - preserve original name (may be undefined)
		nodeName = config._originalName as string | undefined;
	} else {
		// Node was created via builder - use auto-renamed key if applicable
		const isAutoRenamed =
			mapKey !== instance.name &&
			mapKey.startsWith(instance.name + ' ') &&
			/^\d+$/.test(mapKey.slice(instance.name.length + 1));
		nodeName = isAutoRenamed ? mapKey : instance.name;
	}

	// Check if this node was loaded via fromJSON (has _originalName marker)
	const isFromJson = '_originalName' in config;

	// Serialize parameters - for SDK-created nodes, also normalize resource locators
	// (add __rl: true if missing) and escape newlines in expression strings.
	// Missing parameters are serialized as an empty object because n8n requires
	// each persisted node to have an object-valued parameters field.
	const parsedParams = deepCopy(config.parameters ?? {});
	let serializedParams: IDataObject;
	if (isFromJson) {
		serializedParams = parsedParams;
	} else {
		const normalized = normalizeResourceLocators(parsedParams);
		serializedParams = escapeNewlinesInExpressionStrings(normalized) as IDataObject;
	}

	const n8nNode: NodeJSON = {
		id: instance.id,
		name: nodeName,
		type: instance.type,
		typeVersion: parseVersion(instance.version),
		position,
		parameters: serializedParams,
	};

	// Generate webhookId for webhook-based nodes so n8n registers clean paths
	// (e.g., "{uuid}/dashboard" instead of "{workflowId}/{encodedNodeName}/dashboard")
	if (WEBHOOK_NODE_TYPES.has(instance.type)) {
		n8nNode.webhookId = config.webhookId ?? randomUUID();
	}

	// Add optional properties
	if (config.credentials) {
		if (typeof config.credentials !== 'object') {
			// Real workflows occasionally carry credentials as a primitive (e.g. the
			// post-redaction string `"[REDACTED]"`). Pass through unchanged.
			n8nNode.credentials = deepCopy(config.credentials);
		} else {
			// `NodeConfig.credentials` is typed wide (string | { value } | etc.) at the
			// public API. By this point `normalizeNodeConfig` has rewritten the loose
			// shapes to `CredentialReference | NewCredentialValue`. Defensively skip
			// any leftover placeholder marker strings or `{ value }` objects (they are
			// placeholders the user must still fill in and have no `id`/`name` to
			// serialize). Plain strings (e.g. legacy 'YOUR_CREDENTIALS' style refs)
			// pass through unchanged for backwards compatibility.
			const resolvable: NonNullable<NodeJSON['credentials']> = {};
			for (const [key, value] of Object.entries(config.credentials)) {
				if (typeof value === 'string') {
					if (value.startsWith('<__PLACEHOLDER_VALUE__') && value.endsWith('__>')) continue;
					resolvable[key] = value as unknown as { id?: string; name: string };
					continue;
				}
				if (value && typeof value === 'object' && 'value' in value && !('id' in value)) continue;
				resolvable[key] = value as { id?: string; name: string };
			}
			n8nNode.credentials = deepCopy(resolvable);
		}
	}
	if (config.disabled) {
		n8nNode.disabled = config.disabled;
	}
	if (config.notes) {
		n8nNode.notes = config.notes;
	}
	if (config.notesInFlow) {
		n8nNode.notesInFlow = config.notesInFlow;
	}
	if (config.executeOnce) {
		n8nNode.executeOnce = config.executeOnce;
	}
	if (config.retryOnFail) {
		n8nNode.retryOnFail = config.retryOnFail;
	}
	if (typeof config.maxTries === 'number') {
		n8nNode.maxTries = config.maxTries;
	}
	if (typeof config.waitBetweenTries === 'number') {
		n8nNode.waitBetweenTries = config.waitBetweenTries;
	}
	if (config.alwaysOutputData) {
		n8nNode.alwaysOutputData = config.alwaysOutputData;
	}
	if (config.onError) {
		n8nNode.onError = config.onError;
	}
	if (config.extendsCredential) {
		n8nNode.extendsCredential = config.extendsCredential;
	}

	return n8nNode;
}

/**
 * Serialize connections for a single node.
 */
function serializeNodeConnections(
	graphNode: GraphNode,
	nodeName: string | undefined,
): IConnections[string] | undefined {
	// Check if node has any connections
	let hasConnections = false;
	for (const typeConns of graphNode.connections.values()) {
		if (typeConns.size > 0) {
			hasConnections = true;
			break;
		}
	}

	if (!hasConnections || nodeName === undefined) {
		return undefined;
	}

	const nodeConnections: IConnections[string] = {};

	for (const [connType, outputMap] of graphNode.connections) {
		if (outputMap.size === 0) continue;

		// Get max output index to ensure array is properly sized
		const maxOutput = Math.max(...outputMap.keys());
		const outputArray: Array<Array<{ node: string; type: string; index: number }>> = [];

		for (let i = 0; i <= maxOutput; i++) {
			const targets = outputMap.get(i) ?? [];
			outputArray[i] = targets.map((target) => ({
				node: target.node,
				type: target.type,
				index: target.index,
			}));
		}

		nodeConnections[connType] = outputArray;
	}

	if (Object.keys(nodeConnections).length === 0) {
		return undefined;
	}

	return nodeConnections;
}

/**
 * Serializer for the standard n8n workflow JSON format.
 *
 * Produces WorkflowJSON output that can be imported into n8n.
 */
export const jsonSerializer: SerializerPlugin<WorkflowJSON> = {
	id: 'core:json',
	name: 'JSON Serializer',
	format: 'json',

	serialize(ctx: SerializerContext): WorkflowJSON {
		const nodes: NodeJSON[] = [];
		const connections: IConnections = {};

		// Calculate positions for nodes without explicit positions
		const nodePositions = ctx.tidyUp
			? calculateNodePositionsDagre(ctx.nodes)
			: calculateNodePositions(ctx.nodes);

		// Convert nodes and connections
		for (const [mapKey, graphNode] of ctx.nodes) {
			// Serialize node
			const serializedNode = serializeNode(mapKey, graphNode, nodePositions);
			if (!serializedNode) continue;

			nodes.push(serializedNode);

			// Serialize connections
			const nodeName = serializedNode.name;
			const nodeConns = serializeNodeConnections(graphNode, nodeName);
			if (nodeConns && nodeName !== undefined) {
				connections[nodeName] = nodeConns;
			}
		}

		// Emit the modern error-pin shape (main[errorIndex]) regardless of
		// whether the internal graph used an 'error' connection-type key (from
		// .onError() or from an imported legacy workflow). Node info is passed
		// so IF / Switch / SplitInBatches place the error slot at the right
		// index even when some natural outputs are unwired.
		foldLegacyErrorConnections(connections, nodes);

		// Build the workflow JSON
		const json: WorkflowJSON = {
			id: ctx.workflowId,
			name: ctx.workflowName,
			nodes,
			connections,
		};

		// Preserve settings even if empty (for round-trip fidelity)
		if (ctx.settings !== undefined) {
			json.settings = ctx.settings;
		}

		if (ctx.pinData && Object.keys(ctx.pinData).length > 0) {
			json.pinData = ctx.pinData;
		}

		if (ctx.meta) {
			json.meta = ctx.meta;
		}

		// Members already carry the emitted nodes' IDs; filter out any that aren't present
		// in the output (defensive — should never happen). Group ID precedence: own ID
		// (carried through fromJSON for a lossless round-trip), then a name match (preserves
		// UI-assigned IDs across edits), else a deterministic ID from the name.
		if (ctx.nodeGroups && ctx.nodeGroups.length > 0) {
			const emittedIds = new Set(nodes.map((node) => node.id));

			json.nodeGroups = ctx.nodeGroups.map((group) => ({
				id:
					group.id ??
					ctx.existingGroupIdsByName?.get(group.name) ??
					generateDeterministicGroupId(ctx.workflowId, group.name),
				name: group.name,
				nodeIds: group.memberIds.filter((id) => emittedIds.has(id)),
			}));
		}

		return json;
	},
};
