import { v4 as uuid } from 'uuid';

import { NODE_TYPES, isIfNodeType, isSwitchNodeType } from '../../constants/node-types';
import {
	isNodeChain,
	type AnchoredStickyNote,
	type NodeInstance,
	type TriggerInstance,
	type NodeConfig,
	type NodeInput,
	type TriggerInput,
	type StickyNoteConfig,
	type NewCredentialValue,
	type CredentialReference,
	type DeclaredConnection,
	type NodeChain,
	type InputTarget,
	type OutputSelector,
	type IfElseBuilder,
	type SwitchCaseBuilder,
	type IfElseTarget,
	type SwitchCaseTarget,
} from '../../types/base';
import { extractHint, isPlaceholderValue } from '../string-utils';
import {
	isSwitchCaseComposite,
	isIfElseComposite,
	isSplitInBatchesBuilder,
	extractSplitInBatchesBuilder,
} from '../type-guards';
import { assertPlainObject } from '../validation-helpers';

/**
 * Type guard to check if a value is an InputTarget
 */
export function isInputTarget(value: unknown): value is InputTarget {
	return (
		typeof value === 'object' &&
		value !== null &&
		'_isInputTarget' in value &&
		(value as InputTarget)._isInputTarget
	);
}

/**
 * Type guard to check if a value is an OutputSelector
 */
export function isOutputSelector(value: unknown): value is OutputSelector<string, string, unknown> {
	return (
		typeof value === 'object' &&
		value !== null &&
		'_isOutputSelector' in value &&
		(value as OutputSelector<string, string, unknown>)._isOutputSelector
	);
}

/**
 * Get the output node from a composite (the node that should receive connections)
 */
function getCompositeOutputNode(value: unknown): NodeInstance<string, string, unknown> | null {
	if (isSwitchCaseComposite(value)) {
		return (value as { switchNode: NodeInstance<string, string, unknown> }).switchNode;
	}
	if (isSwitchCaseBuilder(value)) {
		return value.switchNode;
	}
	if (isIfElseComposite(value)) {
		return (value as { ifNode: NodeInstance<string, string, unknown> }).ifNode;
	}
	if (isSplitInBatchesBuilder(value)) {
		return extractSplitInBatchesBuilder(value).sibNode;
	}
	return null;
}

/**
 * Generate a human-readable name from a node type
 * @example 'n8n-nodes-base.httpRequest' -> 'HTTP Request'
 */
function generateNodeName(type: string): string {
	// Extract the node name after the package prefix
	const parts = type.split('.');
	const nodeName = parts[parts.length - 1];

	// Convert camelCase/PascalCase to Title Case with spaces
	return nodeName
		.replace(/([a-z])([A-Z])/g, '$1 $2') // Insert space before capitals
		.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // Handle consecutive capitals
		.replace(/^./, (str) => str.toUpperCase()) // Capitalize first letter
		.replace(/Http/g, 'HTTP') // Fix common acronyms
		.replace(/Api/g, 'API')
		.replace(/Url/g, 'URL')
		.replace(/Id/g, 'ID')
		.replace(/Json/g, 'JSON')
		.replace(/Xml/g, 'XML')
		.replace(/Sql/g, 'SQL')
		.replace(/Ssh/g, 'SSH')
		.replace(/Ftp/g, 'FTP')
		.replace(/Aws/g, 'AWS')
		.replace(/Gcp/g, 'GCP');
}

/**
 * Normalize the various credential-slot shapes the agent (or hand-written code)
 * may emit into the canonical `CredentialReference | NewCredentialValue` form
 * downstream code expects.
 *
 * Accepted shapes (all converge to `NewCredentialValue` or `CredentialReference`):
 * - bare placeholder marker string → `newCredential(extractedHint)`
 * - `{ value: <string|placeholder> }` → `newCredential(<hint or literal>)`
 * - `{ id: <placeholder>, name: <string|placeholder> }` → `newCredential(<name or id-hint>)`
 * - `{ id: <string>, name: <string> }` → unchanged `CredentialReference`
 * - `NewCredentialValue` instance → unchanged
 *
 * Returns a new config object when any normalization happens; otherwise a
 * shallow copy (matching the previous `{ ...config }` semantics).
 */
/**
 * A declared node id, or undefined when there is none. A blank string is not a declaration —
 * treating it as one would hand every such node the same empty identity, which
 * `duplicate-node-id-validator` would then skip and the DB would accept.
 */
export function declaredNodeId(id: unknown): string | undefined {
	return typeof id === 'string' && id.trim() !== '' ? id : undefined;
}

export function normalizeNodeConfig(config: NodeConfig): NodeConfig {
	const id = declaredNodeId(config?.id);
	const creds = config?.credentials as Record<string, unknown> | undefined;
	if (!creds) return { ...config, id };

	let normalizedCreds:
		| Record<string, CredentialReference | NewCredentialValue | string>
		| undefined;
	const setNormalized = (key: string, next: CredentialReference | NewCredentialValue | string) => {
		normalizedCreds ??= { ...creds } as Record<
			string,
			CredentialReference | NewCredentialValue | string
		>;
		normalizedCreds[key] = next;
	};

	for (const [key, value] of Object.entries(creds)) {
		// 1. Bare placeholder marker string → newCredential(extractedHint)
		if (typeof value === 'string' && isPlaceholderValue(value)) {
			setNormalized(key, new NewCredentialImpl(extractHint(value)));
			continue;
		}
		if (!value || typeof value !== 'object') {
			continue;
		}
		const obj = value as Record<string, unknown>;
		// Already a NewCredentialValue — leave alone
		if ('__newCredential' in obj) continue;

		// 2. { value: ... } → newCredential
		if ('value' in obj && !('id' in obj)) {
			const v = String(obj.value);
			const name = isPlaceholderValue(v) ? extractHint(v) : v;
			setNormalized(key, new NewCredentialImpl(name));
			continue;
		}

		// 3. { id, name } where id is a placeholder marker → newCredential(name)
		if ('id' in obj) {
			const idStr = typeof obj.id === 'string' ? obj.id : '';
			if (isPlaceholderValue(idStr)) {
				const rawName = obj.name;
				const name =
					typeof rawName === 'string' && rawName.length > 0 && !isPlaceholderValue(rawName)
						? rawName
						: typeof rawName === 'string' && isPlaceholderValue(rawName)
							? extractHint(rawName)
							: extractHint(idStr);
				setNormalized(key, new NewCredentialImpl(name));
				continue;
			}
		}

		// 4. else: leave as CredentialReference / plain string
	}

	if (!normalizedCreds) return { ...config, id };
	return { ...config, id, credentials: normalizedCreds } as NodeConfig;
}

/**
 * Internal node instance implementation
 */
class NodeInstanceImpl<TType extends string, TVersion extends string, TOutput = unknown>
	implements NodeInstance<TType, TVersion, TOutput>
{
	readonly type: TType;
	readonly version: TVersion;
	readonly config: NodeConfig;
	readonly id: string;
	readonly name: string;
	readonly _outputType?: TOutput;
	private _connections: DeclaredConnection[] = [];

	constructor(
		type: TType,
		version: TVersion,
		config: NodeConfig,
		id?: string,
		name?: string,
		connections?: DeclaredConnection[],
	) {
		this.type = type;
		this.version = version;
		this.config = normalizeNodeConfig(config);
		// An explicit `id` argument (update/clone) wins over one declared in the source,
		// so `update()` can never re-identify a node. Read the NORMALIZED config so a blank
		// declaration is treated as absent rather than becoming a shared empty identity.
		this.id = id ?? this.config.id ?? uuid();
		this.name = name ?? config?.name ?? generateNodeName(type);
		this._connections = connections ?? [];
	}

	update(config: Partial<NodeConfig>): NodeInstance<TType, TVersion, TOutput> {
		const mergedConfig = {
			...this.config,
			...config,
			// Identity is not patchable: an `id` in the patch would leave `config.id` diverging
			// from `this.id`, and regenerateNodeIds() would adopt it on the next rebuild.
			id: this.config.id,
			parameters: config.parameters ?? this.config.parameters,
			credentials: config.credentials ?? this.config.credentials,
		};
		return new NodeInstanceImpl(
			this.type,
			this.version,
			mergedConfig,
			this.id,
			this.name,
			this._connections,
		);
	}

	to<T extends NodeInstance<string, string, unknown>>(
		target: T | T[] | InputTarget,
		outputIndex: number = 0,
	): NodeChain<NodeInstance<TType, TVersion, TOutput>, T> {
		// Handle InputTarget - terminal target with specific input index
		if (isInputTarget(target)) {
			this._connections.push({
				target: target.node,
				outputIndex,
				targetInputIndex: target.inputIndex,
			});
			// Return a chain ending at the input target's node
			return new NodeChainImpl(this, target.node as T, [this, target.node]);
		}

		// Handle array (fan-out) or single target
		const targets: Array<T | NodeInstance<string, string, unknown>> = Array.isArray(target)
			? target
			: [target];

		for (const t of targets) {
			this._connections.push({ target: t, outputIndex });
		}

		// Helper to extract all nodes from a target (handles NodeChain, builders, etc.)
		const flattenTarget = (t: unknown): Array<NodeInstance<string, string, unknown>> => {
			if (isNodeChain(t)) {
				return t.allNodes;
			}
			// For SplitInBatchesBuilder, return it as-is so it can be detected and handled
			// by workflow-builder's addSplitInBatchesChainNodes
			if (isSplitInBatchesBuilder(t)) {
				return [t as NodeInstance<string, string, unknown>];
			}
			// For IfElseBuilder, return it as-is so it can be detected and handled
			// by workflow-builder's addIfElseBuilderNodes
			if (isIfElseBuilder(t)) {
				return [t as unknown as NodeInstance<string, string, unknown>];
			}
			// For SwitchCaseBuilder, return it as-is so it can be detected and handled
			// by workflow-builder's addSwitchCaseBuilderNodes
			if (isSwitchCaseBuilder(t)) {
				return [t as unknown as NodeInstance<string, string, unknown>];
			}
			return [t as NodeInstance<string, string, unknown>];
		};

		// Flatten all targets (some may be NodeChains with multiple nodes)
		const allTargetNodes = targets.flatMap(flattenTarget);

		// Return chain with last target as tail (for type compatibility)
		const lastTarget = targets[targets.length - 1];
		return new NodeChainImpl(this, lastTarget as T, [this, ...allTargetNodes]);
	}

	/**
	 * Create a terminal input target for connecting to a specific input index.
	 * Use this to connect a node to a specific input of a multi-input node like Merge.
	 *
	 * Index is **0-based**: `.input(0)` is the FIRST input, `.input(1)` is the SECOND.
	 */
	input(index: number): InputTarget {
		return {
			_isInputTarget: true,
			node: this,
			inputIndex: index,
		};
	}

	/**
	 * Create an output selector for connecting from a specific output index.
	 * Use this for multi-output nodes (like text classifiers) to connect from specific outputs.
	 *
	 * Index is **0-based**: `.output(0)` is the FIRST output, `.output(1)` is the SECOND.
	 */
	output(index: number): OutputSelector<TType, TVersion, TOutput> {
		return new OutputSelectorImpl(this, index) as unknown as OutputSelector<
			TType,
			TVersion,
			TOutput
		>;
	}

	/**
	 * Start building an IF branch with the true branch target.
	 * Only available on IF nodes (n8n-nodes-base.if).
	 *
	 * @example
	 * ifNode.onTrue(trueHandler).onFalse(falseHandler)
	 */
	onTrue(target: IfElseTarget): IfElseBuilder<TOutput> {
		if (!isIfNodeType(this.type)) {
			throw new Error(`.onTrue() is only available on IF nodes (${NODE_TYPES.IF})`);
		}
		const builder = new IfElseBuilderImpl<TOutput>(
			this as unknown as NodeInstance<'n8n-nodes-base.if', string, TOutput>,
		);
		return builder.onTrue(target);
	}

	/**
	 * Start building an IF branch with the false branch target.
	 * Only available on IF nodes (n8n-nodes-base.if).
	 *
	 * @example
	 * ifNode.onFalse(falseHandler).onTrue(trueHandler)
	 */
	onFalse(target: IfElseTarget): IfElseBuilder<TOutput> {
		if (!isIfNodeType(this.type)) {
			throw new Error(`.onFalse() is only available on IF nodes (${NODE_TYPES.IF})`);
		}
		const builder = new IfElseBuilderImpl<TOutput>(
			this as unknown as NodeInstance<'n8n-nodes-base.if', string, TOutput>,
		);
		return builder.onFalse(target);
	}

	/**
	 * Start building a Switch case with a case target.
	 * Only available on Switch nodes (n8n-nodes-base.switch).
	 *
	 * @example
	 * switchNode.onCase(0, caseA).onCase(1, caseB)
	 */
	onCase(index: number, target: SwitchCaseTarget): SwitchCaseBuilder<TOutput> {
		if (!isSwitchNodeType(this.type)) {
			throw new Error(`.onCase() is only available on Switch nodes (${NODE_TYPES.SWITCH})`);
		}
		const builder = new SwitchCaseBuilderImpl<TOutput>(
			this as unknown as NodeInstance<'n8n-nodes-base.switch', string, TOutput>,
		);
		return builder.onCase(index, target);
	}

	onError<T extends NodeInstance<string, string, unknown>>(handler: T | InputTarget): this {
		// Declaring an error route implies the error output port exists.
		this.config.onError ??= 'continueErrorOutput';
		if (isInputTarget(handler)) {
			this._connections.push({
				target: handler.node,
				outputIndex: 0,
				targetInputIndex: handler.inputIndex,
				connectionType: 'error',
			});
		} else {
			this._connections.push({ target: handler, outputIndex: 0, connectionType: 'error' });
		}
		return this;
	}

	getConnections(): DeclaredConnection[] {
		return [...this._connections];
	}
}

/**
 * Internal trigger instance implementation
 */
class TriggerInstanceImpl<TType extends string, TVersion extends string, TOutput = unknown>
	extends NodeInstanceImpl<TType, TVersion, TOutput>
	implements TriggerInstance<TType, TVersion, TOutput>
{
	readonly isTrigger = true as const;
}

/**
 * Internal NodeChain implementation
 *
 * Proxies NodeInstance properties to the tail node while tracking all nodes in the chain.
 */
class NodeChainImpl<
	THead extends NodeInstance<string, string, unknown>,
	TTail extends NodeInstance<string, string, unknown>,
> implements NodeChain<THead, TTail>
{
	readonly _isChain = true as const;
	readonly head: THead;
	readonly tail: TTail;
	readonly allNodes: Array<NodeInstance<string, string, unknown>>;

	constructor(head: THead, tail: TTail, allNodes: Array<NodeInstance<string, string, unknown>>) {
		this.head = head;
		this.tail = tail;
		this.allNodes = allNodes;
	}

	// Proxy NodeInstance properties to tail
	get type(): TTail['type'] {
		return this.tail.type;
	}

	get version(): TTail['version'] {
		return this.tail.version;
	}

	get config(): NodeConfig {
		return this.tail.config;
	}

	get id(): string {
		return this.tail.id;
	}

	get name(): string {
		return this.tail.name;
	}

	get _outputType(): TTail['_outputType'] {
		return this.tail._outputType;
	}

	update(
		config: Partial<NodeConfig>,
	): NodeInstance<TTail['type'], TTail['version'], TTail['_outputType']> {
		return this.tail.update(config);
	}

	to<T extends NodeInstance<string, string, unknown>>(
		target: T | T[] | InputTarget,
		outputIndex: number = 0,
	): NodeChain<THead, T> {
		// Handle InputTarget - terminal target with specific input index
		if (isInputTarget(target)) {
			// Delegate to tail's to method which handles InputTarget
			this.tail.to(target, outputIndex);
			return new NodeChainImpl(this.head, target.node as T, [...this.allNodes, target.node]);
		}

		// Handle array (fan-out) or single target
		const targets: Array<T | NodeInstance<string, string, unknown>> = Array.isArray(target)
			? target
			: [target];

		// Helper to extract all nodes from a target (handles NodeChain, builders, etc.)
		const flattenTarget = (t: unknown): Array<NodeInstance<string, string, unknown>> => {
			if (isNodeChain(t)) {
				return t.allNodes;
			}
			// For SplitInBatchesBuilder, return it as-is so it can be detected and handled
			// by workflow-builder's addSplitInBatchesChainNodes
			if (isSplitInBatchesBuilder(t)) {
				return [t as NodeInstance<string, string, unknown>];
			}
			// For IfElseBuilder, return it as-is so it can be detected and handled
			// by workflow-builder's addIfElseBuilderNodes
			if (isIfElseBuilder(t)) {
				return [t as unknown as NodeInstance<string, string, unknown>];
			}
			// For SwitchCaseBuilder, return it as-is so it can be detected and handled
			// by workflow-builder's addSwitchCaseBuilderNodes
			if (isSwitchCaseBuilder(t)) {
				return [t as unknown as NodeInstance<string, string, unknown>];
			}
			return [t as NodeInstance<string, string, unknown>];
		};

		// Flatten all targets (some may be NodeChains with multiple nodes)
		const allTargetNodes = targets.flatMap(flattenTarget);

		// Check if the tail is a composite - if so, get its output node
		const outputNode = getCompositeOutputNode(this.tail);
		if (outputNode) {
			// For composites, connect the output node (mergeNode, switchNode, ifNode) to the targets.
			// The output node supports .to() to declare the connection.
			if (typeof outputNode.to === 'function') {
				outputNode.to(targets, outputIndex);
			}
			const lastTarget = targets[targets.length - 1];
			return new NodeChainImpl(this.head, lastTarget as T, [...this.allNodes, ...allTargetNodes]);
		}

		// Connect tail to all targets (use the tail's to method which handles connections)
		this.tail.to(targets, outputIndex);
		// Return chain with last target as tail
		const lastTarget = targets[targets.length - 1];
		return new NodeChainImpl(this.head, lastTarget as T, [...this.allNodes, ...allTargetNodes]);
	}

	/**
	 * Create a terminal input target for connecting to a specific input index.
	 * Delegates to the tail node's input method.
	 */
	input(index: number): InputTarget {
		return this.tail.input(index);
	}

	/**
	 * Create an output selector for connecting from a specific output index.
	 * Delegates to the tail node's output method.
	 */
	output(index: number): OutputSelector<TTail['type'], TTail['version'], TTail['_outputType']> {
		const compositeNode = getCompositeOutputNode(this.tail);
		if (compositeNode) {
			return compositeNode.output(index) as OutputSelector<
				TTail['type'],
				TTail['version'],
				TTail['_outputType']
			>;
		}
		return this.tail.output(index);
	}

	/**
	 * Start building an IF branch with the true branch target.
	 * Delegates to the tail node's onTrue method.
	 */
	onTrue(target: IfElseTarget): IfElseBuilder<TTail['_outputType']> {
		if (!this.tail.onTrue) {
			throw new Error(`.onTrue() is only available on IF nodes (${NODE_TYPES.IF})`);
		}
		return this.tail.onTrue(target);
	}

	/**
	 * Start building an IF branch with the false branch target.
	 * Delegates to the tail node's onFalse method.
	 */
	onFalse(target: IfElseTarget): IfElseBuilder<TTail['_outputType']> {
		if (!this.tail.onFalse) {
			throw new Error(`.onFalse() is only available on IF nodes (${NODE_TYPES.IF})`);
		}
		return this.tail.onFalse(target);
	}

	/**
	 * Start building a Switch case with a case target.
	 * Delegates to the tail node's onCase method.
	 */
	onCase(index: number, target: SwitchCaseTarget): SwitchCaseBuilder<TTail['_outputType']> {
		if (!this.tail.onCase) {
			throw new Error(`.onCase() is only available on Switch nodes (${NODE_TYPES.SWITCH})`);
		}
		const builder = this.tail.onCase(index, target);
		// Pass this chain to the builder so workflow-builder can add all chain nodes
		(builder as SwitchCaseBuilderImpl<TTail['_outputType']>).sourceChain = this;
		return builder;
	}

	onError<T extends NodeInstance<string, string, unknown>>(handler: T | InputTarget): this {
		// Bind to the chain node that declares an error output; tail is the fallback.
		const declaring = this.allNodes.filter(
			(n) => n !== null && n !== undefined && n.config?.onError === 'continueErrorOutput',
		);
		const source = declaring.length === 1 ? declaring[0] : this.tail;
		source.onError(handler as T);
		return this;
	}

	getConnections(): DeclaredConnection[] {
		// Aggregate connections from all nodes in the chain
		const allConnections: DeclaredConnection[] = [];
		for (const node of this.allNodes) {
			// Skip null values (can occur when .to([null, node]) is used for multi-output nodes)
			if (node === null) {
				continue;
			}
			// Skip composites (SwitchCaseComposite, etc.) which don't have getConnections
			if (typeof node.getConnections === 'function') {
				allConnections.push(...node.getConnections());
			}
		}
		return allConnections;
	}
}

/**
 * Internal output selector implementation
 *
 * Allows connecting from a specific output index of a node.
 */
class OutputSelectorImpl<TType extends string, TVersion extends string, TOutput = unknown>
	implements OutputSelector<TType, TVersion, TOutput>
{
	readonly _isOutputSelector = true as const;
	readonly node: NodeInstance<TType, TVersion, TOutput>;
	readonly outputIndex: number;

	constructor(node: NodeInstance<TType, TVersion, TOutput>, outputIndex: number) {
		this.node = node;
		this.outputIndex = outputIndex;
	}

	to<T extends NodeInstance<string, string, unknown>>(
		target: T | T[] | InputTarget,
	): NodeChain<NodeInstance<TType, TVersion, TOutput>, T> {
		// Delegate to the node's to method with the specific outputIndex
		return this.node.to(target, this.outputIndex);
	}
}

// =============================================================================
// Fluent API builders for IF and Switch nodes
// =============================================================================

/**
 * Extract all nodes from a target (node, chain, composite, or array)
 */
function extractNodesFromTarget(target: unknown): Array<NodeInstance<string, string, unknown>> {
	if (target === null) return [];

	// Handle array (fan-out)
	if (Array.isArray(target)) {
		const nodes: Array<NodeInstance<string, string, unknown>> = [];
		for (const t of target) {
			nodes.push(...extractNodesFromTarget(t));
		}
		return nodes;
	}

	// Handle NodeChain
	if (isNodeChain(target)) {
		const nodes: Array<NodeInstance<string, string, unknown>> = [];
		for (const chainNode of target.allNodes) {
			nodes.push(...extractNodesFromTarget(chainNode));
		}
		return nodes;
	}

	// Handle IfElseBuilder (fluent API)
	if (
		target !== null &&
		typeof target === 'object' &&
		'_isIfElseBuilder' in target &&
		(target as { _isIfElseBuilder: boolean })._isIfElseBuilder
	) {
		const builder = target as IfElseBuilder<unknown>;
		const nodes: Array<NodeInstance<string, string, unknown>> = [builder.ifNode];
		nodes.push(...extractNodesFromTarget(builder.trueBranch));
		nodes.push(...extractNodesFromTarget(builder.falseBranch));
		return nodes;
	}

	// Handle SwitchCaseBuilder (fluent API)
	if (
		target !== null &&
		typeof target === 'object' &&
		'_isSwitchCaseBuilder' in target &&
		(target as { _isSwitchCaseBuilder: boolean })._isSwitchCaseBuilder
	) {
		const builder = target as SwitchCaseBuilder<unknown>;
		const nodes: Array<NodeInstance<string, string, unknown>> = [builder.switchNode];
		for (const caseTarget of builder.caseMapping.values()) {
			nodes.push(...extractNodesFromTarget(caseTarget));
		}
		return nodes;
	}

	// Handle SplitInBatchesBuilder (fluent API) - the sibNode plus any recorded
	// done/each branch targets. Recurses so nested builders inside either branch
	// are collected too.
	if (isSplitInBatchesBuilder(target)) {
		const builder = extractSplitInBatchesBuilder(target);
		const nodes: Array<NodeInstance<string, string, unknown>> = [builder.sibNode];
		for (const doneTarget of builder._doneBatches) {
			nodes.push(...extractNodesFromTarget(doneTarget));
		}
		for (const eachTarget of builder._eachBatches) {
			nodes.push(...extractNodesFromTarget(eachTarget));
		}
		if (builder._doneTarget !== undefined) {
			nodes.push(...extractNodesFromTarget(builder._doneTarget));
		}
		if (builder._eachTarget !== undefined) {
			nodes.push(...extractNodesFromTarget(builder._eachTarget));
		}
		return nodes;
	}

	// Check if it's a node-like object with type, version, config
	if (
		target !== null &&
		typeof target === 'object' &&
		'type' in target &&
		'version' in target &&
		'config' in target
	) {
		return [target as NodeInstance<string, string, unknown>];
	}

	return [];
}

/**
 * Type guard to check if a value is an IfElseBuilder
 */
export function isIfElseBuilder(value: unknown): value is IfElseBuilder<unknown> {
	return (
		value !== null &&
		typeof value === 'object' &&
		'_isIfElseBuilder' in value &&
		(value as IfElseBuilder<unknown>)._isIfElseBuilder
	);
}

/**
 * Type guard to check if a value is a SwitchCaseBuilder
 */
export function isSwitchCaseBuilder(value: unknown): value is SwitchCaseBuilder<unknown> {
	return (
		value !== null &&
		typeof value === 'object' &&
		'_isSwitchCaseBuilder' in value &&
		(value as SwitchCaseBuilder<unknown>)._isSwitchCaseBuilder
	);
}

/**
 * Internal IF else builder implementation
 *
 * Provides fluent .onTrue()/.onFalse() methods for IF node branching.
 */
class IfElseBuilderImpl<TOutput = unknown> implements IfElseBuilder<TOutput> {
	readonly _isIfElseBuilder = true as const;
	readonly ifNode: NodeInstance<'n8n-nodes-base.if', string, TOutput>;
	trueBranch: IfElseTarget = null;
	falseBranch: IfElseTarget = null;
	errorBranch?: IfElseTarget;
	/** All nodes from both branches (for workflow-builder) */
	_allBranchNodes: Array<NodeInstance<string, string, unknown>> = [];

	constructor(ifNode: NodeInstance<'n8n-nodes-base.if', string, TOutput>) {
		this.ifNode = ifNode;
	}

	onTrue(target: IfElseTarget): IfElseBuilder<TOutput> {
		this.trueBranch = target;
		this._updateAllBranchNodes();
		return this;
	}

	onFalse(target: IfElseTarget): IfElseBuilder<TOutput> {
		this.falseBranch = target;
		this._updateAllBranchNodes();
		return this;
	}

	onError(target: IfElseTarget): IfElseBuilder<TOutput> {
		this.errorBranch = target;
		this._updateAllBranchNodes();
		return this;
	}

	to<T extends NodeInstance<string, string, unknown>>(
		target: T | T[],
		outputIndex: number = 0,
	): NodeChain<NodeInstance<'n8n-nodes-base.if', string, TOutput>, T> {
		// Delegate to the IF node's to method
		return this.ifNode.to(target, outputIndex);
	}

	private _updateAllBranchNodes(): void {
		const allNodes: Array<NodeInstance<string, string, unknown>> = [];
		for (const node of extractNodesFromTarget(this.trueBranch)) {
			if (!allNodes.some((n) => n.name === node.name)) {
				allNodes.push(node);
			}
		}
		for (const node of extractNodesFromTarget(this.falseBranch)) {
			if (!allNodes.some((n) => n.name === node.name)) {
				allNodes.push(node);
			}
		}
		if (this.errorBranch) {
			for (const node of extractNodesFromTarget(this.errorBranch)) {
				if (!allNodes.some((n) => n.name === node.name)) {
					allNodes.push(node);
				}
			}
		}
		this._allBranchNodes = allNodes;
	}
}

/**
 * Internal Switch case builder implementation
 *
 * Provides fluent .onCase() method for Switch node branching.
 */
class SwitchCaseBuilderImpl<TOutput = unknown> implements SwitchCaseBuilder<TOutput> {
	readonly _isSwitchCaseBuilder = true as const;
	readonly switchNode: NodeInstance<'n8n-nodes-base.switch', string, TOutput>;
	readonly caseMapping: Map<number, SwitchCaseTarget> = new Map();
	/** All nodes from all cases (for workflow-builder) */
	_allCaseNodes: Array<NodeInstance<string, string, unknown>> = [];
	/** Source chain if created from NodeChain.onCase() (e.g., trigger.to(switch).onCase()) */
	sourceChain?: NodeChain<
		NodeInstance<string, string, unknown>,
		NodeInstance<string, string, unknown>
	>;

	constructor(switchNode: NodeInstance<'n8n-nodes-base.switch', string, TOutput>) {
		this.switchNode = switchNode;
	}

	onCase(index: number, target: SwitchCaseTarget): SwitchCaseBuilder<TOutput> {
		this.caseMapping.set(index, target);
		this._updateAllCaseNodes();
		return this;
	}

	to<T extends NodeInstance<string, string, unknown>>(
		target: T | T[],
		outputIndex: number = 0,
	): NodeChain<NodeInstance<'n8n-nodes-base.switch', string, TOutput>, T> {
		// Delegate to the Switch node's to method
		return this.switchNode.to(target, outputIndex);
	}

	private _updateAllCaseNodes(): void {
		const allNodes: Array<NodeInstance<string, string, unknown>> = [];
		for (const target of this.caseMapping.values()) {
			for (const node of extractNodesFromTarget(target)) {
				if (!allNodes.some((n) => n.name === node.name)) {
					allNodes.push(node);
				}
			}
		}
		this._allCaseNodes = allNodes;
	}
}

/**
 * Create a node instance
 *
 * @param input - Node input with type, version, and config
 * @returns A configured node instance
 *
 * @example
 * ```typescript
 * // With generated types (recommended)
 * import { LcAgentV31Node } from './types/generated';
 * const agent = node({
 *   type: '@n8n/n8n-nodes-langchain.agent',
 *   version: 3.1,
 *   config: { parameters: { promptType: 'auto', text: 'Hello' } }
 * } satisfies LcAgentV31Node);
 *
 * // Generic usage
 * const httpNode = node({
 *   type: 'n8n-nodes-base.httpRequest',
 *   version: 4.2,
 *   config: {
 *     parameters: { url: 'https://api.example.com', method: 'GET' },
 *     credentials: { httpBasicAuth: { name: 'My Creds', id: '123' } }
 *   }
 * });
 * ```
 */
export function node<TNode extends NodeInput>(
	input: TNode,
): NodeInstance<TNode['type'], `${TNode['version']}`, unknown> {
	assertPlainObject(
		input,
		'node',
		"a configuration object { type, version, config }. Example: node({ type: 'n8n-nodes-base.httpRequest', version: 4.2, config: { parameters: {} } })",
	);
	const versionStr = String(input.version) as `${TNode['version']}`;
	// Copy top-level output into config if present
	const config: NodeConfig = input.output
		? ({ ...input.config, output: input.output } as NodeConfig)
		: (input.config as NodeConfig);
	return new NodeInstanceImpl<TNode['type'], `${TNode['version']}`, unknown>(
		input.type,
		versionStr,
		config,
	);
}

/**
 * Config for ifElse() factory function
 */
export interface IfElseFactoryConfig {
	/** Node version (required) */
	version: number;
	/** Node configuration (name, parameters, etc.) */
	config?: NodeConfig;
}

/**
 * Create an IF node with the specified config.
 * Convenience wrapper around node() with type preset to 'n8n-nodes-base.if'.
 *
 * @param input - Config with version (required) and config object
 * @returns A configured IF node instance with .onTrue()/.onFalse() methods
 *
 * @example
 * ```typescript
 * const ifNode = ifElse({ version: 2.2, config: { name: 'Check Value' } })
 *   .onTrue(trueHandler)
 *   .onFalse(falseHandler);
 * ```
 */
export function ifElse<TOutput = unknown>(
	input: IfElseFactoryConfig,
): NodeInstance<'n8n-nodes-base.if', string, TOutput> {
	assertPlainObject(input, 'ifElse', 'a config object { version, config }');
	return node({
		type: 'n8n-nodes-base.if',
		version: input.version,
		config: input.config ?? {},
	}) as NodeInstance<'n8n-nodes-base.if', string, TOutput>;
}

/**
 * Config for merge() factory function
 */
export interface MergeFactoryConfig {
	/** Node version (required) */
	version: number;
	/** Node configuration (name, parameters, etc.) */
	config?: NodeConfig;
}

/**
 * Create a Merge node for combining data from multiple branches.
 * Use .input(n) method to connect sources to specific input indices.
 *
 * Input indices are **0-based**: `.input(0)` is the FIRST input, `.input(1)` is
 * the SECOND. When wiring N sources, use indices `0, 1, ..., N-1` — never start
 * at 1.
 *
 * @param input - Config with version (required) and config object
 * @returns A Merge NodeInstance with .input(n) method for branch connections
 *
 * @example
 * ```typescript
 * const mergeNode = merge({ version: 3, config: { name: 'Combine Data' } });
 * source1.to(mergeNode.input(0)); // first input
 * source2.to(mergeNode.input(1)); // second input
 * mergeNode.to(downstream);
 * ```
 */
export function merge<TOutput = unknown>(
	input: MergeFactoryConfig,
): NodeInstance<'n8n-nodes-base.merge', string, TOutput> {
	assertPlainObject(input, 'merge', 'a config object { version, config }');
	return node({
		type: 'n8n-nodes-base.merge',
		version: input.version,
		config: input.config ?? {},
	}) as NodeInstance<'n8n-nodes-base.merge', string, TOutput>;
}

/**
 * Config for switchCase() factory function
 */
export interface SwitchCaseFactoryConfig {
	/** Node version (required) */
	version: number;
	/** Node configuration (name, parameters, etc.) */
	config?: NodeConfig;
}

/**
 * Create a Switch node for multi-way routing.
 * Use .onCase() fluent API to connect case targets.
 *
 * @param input - Config with version (required) and config object
 * @returns A Switch NodeInstance with .onCase() fluent API
 *
 * @example
 * ```typescript
 * const routeByType = switchCase({ version: 3.2, config: { name: 'Route by Type' } })
 *   .onCase(0, handleTypeA)
 *   .onCase(1, handleTypeB)
 *   .onCase(2, handleFallback);
 * ```
 */
export function switchCase<TOutput = unknown>(
	input: SwitchCaseFactoryConfig,
): NodeInstance<'n8n-nodes-base.switch', string, TOutput> {
	assertPlainObject(input, 'switchCase', 'a config object { version, config }');
	return node({
		type: 'n8n-nodes-base.switch',
		version: input.version,
		config: input.config ?? {},
	}) as NodeInstance<'n8n-nodes-base.switch', string, TOutput>;
}

/**
 * Create a trigger node instance
 *
 * @param input - Trigger input with type, version, and config
 * @returns A configured trigger node instance
 *
 * @example
 * ```typescript
 * const schedule = trigger({
 *   type: 'n8n-nodes-base.scheduleTrigger',
 *   version: 1.1,
 *   config: { parameters: { rule: { interval: [{ field: 'hours', hour: 8 }] } } }
 * });
 * ```
 */
export function trigger<TTrigger extends TriggerInput>(
	input: TTrigger,
): TriggerInstance<TTrigger['type'], `${TTrigger['version']}`, unknown> {
	assertPlainObject(
		input,
		'trigger',
		"a configuration object { type, version, config }. Example: trigger({ type: 'n8n-nodes-base.webhook', version: 2, config: { parameters: {} } })",
	);
	const versionStr = String(input.version) as `${TTrigger['version']}`;
	// Copy top-level output into config if present
	const config: NodeConfig = input.output
		? ({ ...input.config, output: input.output } as NodeConfig)
		: (input.config as NodeConfig);
	return new TriggerInstanceImpl<TTrigger['type'], `${TTrigger['version']}`, unknown>(
		input.type,
		versionStr,
		config,
	);
}

/**
 * Resolve the IDs of the nodes a sticky was asked to wrap.
 *
 * Only IDs are captured — the sticky's box is computed during serialization, once
 * layout has decided where the anchors actually sit.
 */
function resolveAnchorIds(nodes: Array<NodeInstance<string, string, unknown>>): string[] {
	return nodes
		.map((item): NodeInstance<string, string, unknown> | null => {
			if (isSplitInBatchesBuilder(item)) {
				return extractSplitInBatchesBuilder(item).sibNode;
			}
			if (isIfElseBuilder(item)) {
				return item.ifNode;
			}
			if (isSwitchCaseBuilder(item)) {
				return item.switchNode;
			}
			if (item.config) {
				return item;
			}
			return null;
		})
		.filter((n): n is NodeInstance<string, string, unknown> => n !== null)
		.map((n) => n.id);
}

/**
 * Sticky note node instance
 */
class StickyNoteInstance
	implements NodeInstance<'n8n-nodes-base.stickyNote', 'v1', void>, AnchoredStickyNote
{
	readonly type = 'n8n-nodes-base.stickyNote' as const;
	readonly version = 'v1' as const;
	readonly config: NodeConfig;
	readonly id: string;
	readonly name: string;
	readonly stickyAnchorIds: readonly string[];

	constructor(
		content: string,
		nodes: Array<NodeInstance<string, string, unknown>> = [],
		stickyConfig: StickyNoteConfig = {},
		anchorIds?: readonly string[],
		id?: string,
	) {
		this.id = id ?? declaredNodeId(stickyConfig.id) ?? uuid();
		// Use a unique default name to prevent multiple stickies from overwriting each other
		// when added to a workflow (Map uses name as key)
		this.name = stickyConfig.name ?? `Sticky Note ${this.id.slice(0, 8)}`;
		this.stickyAnchorIds = anchorIds ?? resolveAnchorIds(nodes);

		this.config = {
			// Only mirror an author-declared id, so `config.id` keeps meaning "declared
			// in the source" for the id-precedence rules in regenerateNodeIds().
			...(declaredNodeId(stickyConfig.id) !== undefined && { id: stickyConfig.id }),
			name: this.name,
			position: stickyConfig.position,
			parameters: {
				content,
				...(stickyConfig.color !== undefined && { color: stickyConfig.color }),
				...(stickyConfig.width !== undefined && { width: stickyConfig.width }),
				...(stickyConfig.height !== undefined && { height: stickyConfig.height }),
			},
		};
	}

	update(config: Partial<NodeConfig>): NodeInstance<'n8n-nodes-base.stickyNote', 'v1', void> {
		const newContent = (config.parameters?.content as string) ?? this.config.parameters?.content;
		const newConfig: StickyNoteConfig = {
			id: this.config.id,
			position: config.position ?? this.config.position,
			color: (config.parameters?.color as number) ?? (this.config.parameters?.color as number),
			width: (config.parameters?.width as number) ?? (this.config.parameters?.width as number),
			height: (config.parameters?.height as number) ?? (this.config.parameters?.height as number),
			name: config.name ?? this.name,
		};
		// Empty nodes array: update() doesn't recalculate the bounding box. Anchors carry over
		// (update changes config, not what the sticky wraps) and so does the id, so `update()`
		// cannot re-identify the node.
		return new StickyNoteInstance(newContent, [], newConfig, this.stickyAnchorIds, this.id);
	}

	input(_index: number): InputTarget {
		throw new Error('Sticky notes do not support input connections');
	}

	output(_index: number): OutputSelector<'n8n-nodes-base.stickyNote', 'v1', void> {
		throw new Error('Sticky notes do not support output connections');
	}

	to<T extends NodeInstance<string, string, unknown>>(
		_target: T | T[] | InputTarget,
		_outputIndex?: number,
	): NodeChain<NodeInstance<'n8n-nodes-base.stickyNote', 'v1', void>, T> {
		throw new Error('Sticky notes do not support connections');
	}

	onError<T extends NodeInstance<string, string, unknown>>(_handler: T): this {
		throw new Error('Sticky notes do not support error handlers');
	}

	getConnections(): DeclaredConnection[] {
		return [];
	}
}

/**
 * Create a sticky note for workflow documentation.
 *
 * Like any other node, the returned sticky must be passed to
 * `workflow(...)` (or `.add(...)`) to appear on the canvas. The optional
 * `nodes` array is **only** used to size and anchor the sticky around the
 * given nodes — it does **not** add them to the workflow.
 *
 * @param content - Markdown content for the sticky note
 * @param nodesOrConfig - Optional nodes to wrap (sizes the sticky around them; the nodes themselves still need to be added to the workflow). Pass a config object to skip wrapping.
 * @param config - Optional configuration (color, position, size)
 * @returns A sticky note node instance
 *
 * @example
 * ```typescript
 * // Manual positioning
 * const note = sticky('## API Integration\nThis section handles API calls', [], {
 *   color: 4,
 *   position: [80, -176]
 * });
 *
 * // Anchor a sticky around two nodes (the nodes still need to be added separately):
 * const httpNode = node({ type: 'n8n-nodes-base.httpRequest', ... });
 * const setNode = node({ type: 'n8n-nodes-base.set', ... });
 * const note = sticky('## Data Processing', [httpNode, setNode], { color: 2 });
 * workflow('id', 'name').add(httpNode.to(setNode)).add(note);
 *
 * // Backward compatible: config as second param (no nodes)
 * const note = sticky('## Note', { color: 4 });
 * ```
 */
export function sticky(
	content: string,
	nodesOrConfig?: Array<NodeInstance<string, string, unknown>> | StickyNoteConfig,
	config?: StickyNoteConfig,
): NodeInstance<'n8n-nodes-base.stickyNote', 'v1', void> {
	// Handle backward compatibility: sticky(content, config)
	// If second param is an object but not an array, treat it as config
	if (nodesOrConfig !== undefined && !Array.isArray(nodesOrConfig)) {
		return new StickyNoteInstance(content, [], nodesOrConfig);
	}

	// New signature: sticky(content, nodes?, config?)
	const nodes = nodesOrConfig ?? [];
	return new StickyNoteInstance(content, nodes, config ?? {});
}

/**
 * Create a placeholder value for template parameters.
 *
 * Returns the marker string `<__PLACEHOLDER_VALUE__<hint>__>`, which is
 * structurally a `string` — so it flows through every string-typed slot in
 * generated node types without TypeScript complaints. The workflow compiler
 * recognises the marker via {@link isPlaceholderValue} and treats the slot as
 * "to be filled in by the user before execution".
 *
 * Inside a node's `credentials` slot, the marker is normalised to
 * `newCredential(hint)` at config ingest — see {@link normalizeNodeConfig}.
 *
 * Note: a small number of parameters carry `placeholderSupported: false` in
 * their description (e.g. webhook `path`) and the workflow compiler will throw
 * a clear error if a placeholder lands in such a slot.
 *
 * @param hint - Description shown to users (e.g., 'Enter Channel')
 * @returns The placeholder marker string.
 *
 * @example
 * ```typescript
 * const slackNode = node('n8n-nodes-base.slack', 'v2.2', {
 *   parameters: { channel: placeholder('Enter Channel') }
 * });
 * // Serializes channel as: '<__PLACEHOLDER_VALUE__Enter Channel__>'
 * ```
 */
export function placeholder(hint: string): string {
	return `<__PLACEHOLDER_VALUE__${hint}__>`;
}

/**
 * New credential implementation.
 * When `id` is provided, serializes to `{ id, name }` to link an existing credential.
 * When `id` is omitted, serializes to undefined (placeholder for credential to be created).
 */
class NewCredentialImpl implements NewCredentialValue {
	readonly __newCredential = true as const;
	readonly name: string;
	readonly id?: string;

	constructor(name: string, id?: string) {
		this.name = name;
		this.id = id;
	}

	toJSON(): { id: string; name: string } | undefined {
		if (this.id !== undefined) {
			return { id: this.id, name: this.name };
		}
		return undefined;
	}
}

/**
 * Create a credential marker.
 *
 * When called with just a name, creates a placeholder for a credential that needs
 * to be created (serializes to undefined, omitted from JSON).
 *
 * When called with both name and id, links an existing credential
 * (serializes to `{ id, name }` in JSON).
 *
 * @param name - Display name for the credential (e.g., 'My Slack Bot')
 * @param id - Optional ID of an existing credential to link
 * @returns A credential marker
 *
 * @example
 * ```typescript
 * // Link existing credential
 * credentials: { slackApi: newCredential('Slack Bot', 'cred-123') }
 * // → { slackApi: { id: 'cred-123', name: 'Slack Bot' } }
 *
 * // Placeholder (credential to be created)
 * credentials: { slackApi: newCredential('My Slack Bot') }
 * // → {} (omitted from JSON)
 * ```
 */
export function newCredential(name: string, id?: string): NewCredentialValue {
	return new NewCredentialImpl(name, id);
}

/**
 * Clone a NodeInstance with a new ID.
 * Preserves all other properties including connections.
 * Used by regenerateNodeIds() to create deterministic IDs.
 */
export function cloneNodeWithId(
	instance: NodeInstance<string, string, unknown>,
	newId: string,
): NodeInstance<string, string, unknown> {
	const connections =
		typeof instance.getConnections === 'function' ? instance.getConnections() : [];
	const isTrigger = 'isTrigger' in instance && instance.isTrigger === true;

	if (isTrigger) {
		return new TriggerInstanceImpl(
			instance.type,
			instance.version,
			instance.config,
			newId,
			instance.name,
			connections,
		);
	}

	return new NodeInstanceImpl(
		instance.type,
		instance.version,
		instance.config,
		newId,
		instance.name,
		connections,
	);
}
