import { v4 as uuid } from 'uuid';

import { NODE_TYPES, isIfNodeType, isSwitchNodeType } from '../../constants/node-types';
import {
	isNodeChain,
	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).
 */
export function normalizeNodeConfig(config: NodeConfig): NodeConfig {
	const creds = config?.credentials as Record<string, unknown> | undefined;
	if (!creds) return { ...config };

	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 };
	return { ...config, 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);
		this.id = 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,
			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 {
		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 {
		this.tail.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,
	);
}

// Default node dimensions for bounding box calculation
const DEFAULT_NODE_WIDTH = 200;
const DEFAULT_NODE_HEIGHT = 100;
const STICKY_PADDING = 50;

/**
 * Calculate bounding box around a set of nodes
 */
function calculateNodesBoundingBox(nodes: Array<NodeInstance<string, string, unknown>>): {
	position: [number, number];
	width: number;
	height: number;
} | null {
	if (nodes.length === 0) return null;

	// Normalize builder objects to their underlying NodeInstance
	const normalizedNodes = 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);

	if (normalizedNodes.length === 0) return null;

	let minX = Infinity;
	let minY = Infinity;
	let maxX = -Infinity;
	let maxY = -Infinity;

	for (const node of normalizedNodes) {
		const pos = node.config.position ?? [0, 0];
		const x = pos[0];
		const y = pos[1];

		minX = Math.min(minX, x);
		minY = Math.min(minY, y);
		maxX = Math.max(maxX, x + DEFAULT_NODE_WIDTH);
		maxY = Math.max(maxY, y + DEFAULT_NODE_HEIGHT);
	}

	return {
		position: [minX - STICKY_PADDING, minY - STICKY_PADDING],
		width: maxX - minX + STICKY_PADDING * 2,
		height: maxY - minY + STICKY_PADDING * 2,
	};
}

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

	constructor(
		content: string,
		nodes: Array<NodeInstance<string, string, unknown>> = [],
		stickyConfig: StickyNoteConfig = {},
	) {
		this.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)}`;

		// If nodes are provided, calculate bounding box to wrap around them
		const boundingBox = nodes.length > 0 ? calculateNodesBoundingBox(nodes) : null;

		this.config = {
			name: this.name,
			position: stickyConfig.position ?? boundingBox?.position,
			parameters: {
				content,
				...(stickyConfig.color !== undefined && { color: stickyConfig.color }),
				...((stickyConfig.width ?? boundingBox?.width) !== undefined && {
					width: stickyConfig.width ?? boundingBox?.width,
				}),
				...((stickyConfig.height ?? boundingBox?.height) !== undefined && {
					height: stickyConfig.height ?? boundingBox?.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 = {
			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,
		};
		// Pass empty nodes array since update doesn't recalculate bounding box
		return new StickyNoteInstance(newContent, [], newConfig);
	}

	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,
	);
}
