/**
 * Setup workflow service — encapsulates all logic for analyzing workflow nodes,
 * building setup requests, sorting by execution order, and applying user changes.
 *
 * Separated from the tool definition so the tool stays a thin suspend/resume
 * state machine, and this logic is testable independently.
 */
import {
	AI_GATEWAY_MANAGED_TAG,
	TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
	shouldAutoResolveCredential,
	type InstanceAiCredentialSetupHint,
} from '@n8n/api-types';
import { findPlaceholderDetails } from '@n8n/utils/placeholder';
import type { IDataObject, NodeJSON, DisplayOptions, WorkflowJSON } from '@n8n/workflow-sdk';
import { matchesDisplayOptions } from '@n8n/workflow-sdk';
import type { IConnections, ICredentialsDisplayOptions, INode } from 'n8n-workflow';
import {
	getCredentialActivationParameters as getCredentialActivationParametersByDisplayOptions,
	getParentNodes,
	mapConnectionsByDestination,
} from 'n8n-workflow';
import { nanoid } from 'nanoid';

import { computeUnavailableLocatorIssues } from './chat-model-validation';
import {
	AI_GATEWAY_CREDENTIAL,
	N8N_CONNECT_DISPLAY_NAME,
	assignCredentialToNode,
	extractServiceHost,
	extractServiceOrigin,
	isAiGatewayManagedCredential,
	resolveCredentialForApply,
	serviceHostsMatch,
	toSetupNodeCredential,
	type SetupNodeCredential,
} from './credential-utils';
import { coerceWrongKindListModeParams } from './detect-wrong-kind-locator';
import type { SetupRequest } from './setup-workflow.schema';
import { refreshWorkflowSourceFileBindingFromSave } from './workflow-file-bindings';
import type { InstanceAiContext } from '../../types';

// ── Credential cache ────────────────────────────────────────────────────────

/** Cache for deduplicating credential fetches across nodes with the same types. */
export interface CredentialCache {
	/** Credential list promises, keyed by `${workflowId ?? ''}|${credentialType}` —
	 *  scope is part of the key so a cache shared across workflows stays correct. */
	lists: Map<string, Promise<Array<{ id: string; name: string }>>>;
	/** Testability check promises, keyed by credential type (workflow-independent). */
	testability: Map<string, Promise<boolean>>;
	/** Credential test result promises, keyed by credential ID (workflow-independent). */
	tests: Map<string, Promise<{ success: boolean; message?: string }>>;
	/** Templated credential host lookups, keyed by the sorted candidate-id set.
	 *  Every HTTP node resolving the shared type sees the same candidates, so the
	 *  host decrypt runs once per workflow rather than once per node. */
	templatedHosts: Map<string, Promise<Record<string, string | null>>>;
}

export function createCredentialCache(): CredentialCache {
	return { lists: new Map(), testability: new Map(), tests: new Map(), templatedHosts: new Map() };
}

function listCacheKey(workflowId: string | undefined, credentialType: string): string {
	return `${workflowId ?? ''}|${credentialType}`;
}

// ── Node analysis ───────────────────────────────────────────────────────────

/**
 * Compute the set of credential types valid for a node given its current
 * parameters. Mirrors the resolution in `buildSetupRequests`: consults the
 * node service's dynamic resolver first, then falls back to the description's
 * static credentials filtered by displayOptions, plus the dynamic types
 * implied by `authentication: genericCredentialType | predefinedCredentialType`.
 */
export async function getValidCredentialTypes(
	context: InstanceAiContext,
	node: NodeJSON,
): Promise<Set<string>> {
	const typeVersion = node.typeVersion ?? 1;
	const parameters = (node.parameters as Record<string, unknown>) ?? {};
	let nodeDesc: Awaited<ReturnType<typeof context.nodeService.getDescription>> | undefined;
	try {
		nodeDesc = await context.nodeService.getDescription(node.type, typeVersion);
	} catch {
		nodeDesc = undefined;
	}

	const types = new Set<string>();

	if (context.nodeService.getNodeCredentialTypes) {
		try {
			const dynamic = await context.nodeService.getNodeCredentialTypes(
				node.type,
				typeVersion,
				parameters,
				node.credentials as Record<string, unknown> | undefined,
			);
			for (const t of dynamic) types.add(t);
		} catch (error) {
			// Falling through to description-based detection is safe, but the dynamic
			// resolver isn't expected to throw — log so we can investigate if it does.
			context.logger.warn(
				'[setup-workflow] getNodeCredentialTypes threw during credential validation',
				{
					nodeType: node.type,
					typeVersion,
					error: error instanceof Error ? error.message : String(error),
				},
			);
		}
	}

	if (nodeDesc?.credentials) {
		for (const c of nodeDesc.credentials as Array<{ name?: string; displayOptions?: unknown }>) {
			if (!c.name) continue;
			if (!c.displayOptions) {
				types.add(c.name);
				continue;
			}
			if (
				matchesDisplayOptions(
					{ parameters, nodeVersion: typeVersion },
					c.displayOptions as DisplayOptions,
				)
			) {
				types.add(c.name);
			}
		}
	}

	const authentication = parameters.authentication;
	if (
		authentication === 'genericCredentialType' &&
		typeof parameters.genericAuthType === 'string'
	) {
		types.add(parameters.genericAuthType);
	} else if (
		authentication === 'predefinedCredentialType' &&
		typeof parameters.nodeCredentialType === 'string'
	) {
		types.add(parameters.nodeCredentialType);
	}

	return types;
}

/**
 * Adapter over the n8n-workflow helper of the same name for the loosely-typed
 * descriptions the node service returns; see it for semantics.
 */
export function getCredentialActivationParameters(
	displayOptions: Record<string, unknown> | undefined,
): IDataObject {
	return getCredentialActivationParametersByDisplayOptions(
		displayOptions as ICredentialsDisplayOptions | undefined,
	);
}

export type CredentialActivationState = 'active' | 'activatable' | 'unreachable';

/**
 * Whether a credential is the node's active slot (`active`), can become it by
 * writing its activation parameters (`activatable`), or can't be reached from
 * this node at all (`unreachable`, e.g. gated to another node version via
 * `@version`).
 */
export function getCredentialActivationState(
	node: NodeJSON,
	credential: { displayOptions?: Record<string, unknown> },
): CredentialActivationState {
	if (!credential.displayOptions) return 'active';
	const displayOptions = credential.displayOptions as DisplayOptions;
	const nodeVersion = node.typeVersion ?? 1;
	const parameters = (node.parameters as Record<string, unknown>) ?? {};
	if (matchesDisplayOptions({ parameters, nodeVersion }, displayOptions)) return 'active';
	const activated = {
		...parameters,
		...getCredentialActivationParameters(credential.displayOptions),
	};
	return matchesDisplayOptions({ parameters: activated, nodeVersion }, displayOptions)
		? 'activatable'
		: 'unreachable';
}

/**
 * Fallback for a slot whose credential type n8n credits doesn't support: the
 * first declared sibling type that is supported, unassigned, not backed by a
 * stored user credential, and reachable by a parameter switch. The async/loosely-
 * typed counterpart of the shared `resolveSupportedCredentialActivation`.
 */
export async function resolveSupportedSiblingCredentialType(
	context: InstanceAiContext,
	node: NodeJSON,
	unsupportedType: string,
	isSupported: (credentialType: string) => Promise<boolean>,
	hasStoredCredential: (credentialType: string) => boolean,
): Promise<string | undefined> {
	let nodeDesc: NodeDescription | undefined;
	try {
		nodeDesc = await context.nodeService.getDescription(node.type, node.typeVersion ?? 1);
	} catch {
		return undefined;
	}
	for (const credential of nodeDesc?.credentials ?? []) {
		if (!credential.name || credential.name === unsupportedType) continue;
		const assigned = (node.credentials as Record<string, unknown> | undefined)?.[credential.name];
		if (assigned !== undefined && assigned !== null) continue;
		if (hasStoredCredential(credential.name)) continue;
		if (!(await isSupported(credential.name))) continue;
		if (getCredentialActivationState(node, credential) === 'unreachable') continue;
		return credential.name;
	}
	return undefined;
}

/**
 * Drop credential entries from a node whose type is no longer valid for the
 * node's current parameters (e.g. an `httpHeaderAuth` left over from an earlier
 * builder revision after `authentication` was switched to `none`). Mutates
 * `node.credentials` in place. Types in `preserveTypes` are kept regardless —
 * used by the apply path so a credential the user just assigned isn't stripped.
 */
export async function stripStaleCredentialsFromNode(
	context: InstanceAiContext,
	node: NodeJSON,
	preserveTypes?: Set<string>,
): Promise<void> {
	if (!node.credentials || Object.keys(node.credentials).length === 0) return;
	const validTypes = await getValidCredentialTypes(context, node);
	const cleaned: NonNullable<typeof node.credentials> = {};
	for (const [credType, value] of Object.entries(node.credentials)) {
		if (validTypes.has(credType) || preserveTypes?.has(credType)) {
			cleaned[credType] = value;
		}
	}
	node.credentials = Object.keys(cleaned).length > 0 ? cleaned : undefined;
}

/**
 * Run {@link stripStaleCredentialsFromNode} over every node in a workflow.
 * Intended to run after `resolveCredentials` in the builder save paths so the
 * LLM can't persist stale credential references between turns.
 */
export async function stripStaleCredentialsFromWorkflow(
	context: InstanceAiContext,
	json: WorkflowJSON,
): Promise<void> {
	await Promise.all(
		(json.nodes ?? []).map(async (node) => await stripStaleCredentialsFromNode(context, node)),
	);
}

type NodeDescription = Awaited<ReturnType<InstanceAiContext['nodeService']['getDescription']>>;

/** Compute parameter issues from the node service, then add placeholder-value issues. */
async function computeParameterIssues(
	context: InstanceAiContext,
	node: NodeJSON,
	parameters: Record<string, unknown>,
	typeVersion: number,
): Promise<Record<string, string[]>> {
	let parameterIssues: Record<string, string[]> = {};
	if (context.nodeService.getParameterIssues) {
		parameterIssues = await context.nodeService
			.getParameterIssues(node.type, typeVersion, parameters)
			.catch(() => ({}));
	}

	// Also treat placeholder values as parameter issues so the setup wizard surfaces them
	for (const [paramName, paramValue] of Object.entries(parameters)) {
		const details = findPlaceholderDetails(paramValue);
		if (details.length > 0) {
			const message = `Placeholder "${details[0].label}" — please provide the real value`;
			if (parameterIssues[paramName]) {
				parameterIssues[paramName].push(message);
			} else {
				parameterIssues[paramName] = [message];
			}
		}
	}
	return parameterIssues;
}

/** Build editable parameter definitions for the parameters that have issues. */
function buildEditableParameters(
	parameterIssues: Record<string, string[]>,
	nodeDesc: NodeDescription | undefined,
): SetupRequest['editableParameters'] {
	if (Object.keys(parameterIssues).length === 0 || !nodeDesc?.properties) return undefined;

	const editableParameters: NonNullable<SetupRequest['editableParameters']> = [];
	for (const paramName of Object.keys(parameterIssues)) {
		const prop = nodeDesc.properties.find((p) => p.name === paramName);
		if (!prop) continue;
		editableParameters.push({
			name: prop.name,
			displayName: prop.displayName,
			type: prop.type,
			...(prop.required !== undefined ? { required: prop.required } : {}),
			...(prop.default !== undefined ? { default: prop.default } : {}),
			...(prop.options
				? {
						options: prop.options as SetupRequest['editableParameters'] extends Array<infer T>
							? T extends { options?: infer O }
								? O
								: never
							: never,
					}
				: {}),
		});
	}
	return editableParameters;
}

/**
 * Resolve the credential types valid for a node: dynamic resolver first, then
 * the description's static credentials filtered by displayOptions, then the
 * dynamic types implied by `authentication: generic/predefinedCredentialType`.
 */
async function resolveCredentialTypes(
	context: InstanceAiContext,
	node: NodeJSON,
	parameters: Record<string, unknown>,
	typeVersion: number,
	nodeDesc: NodeDescription | undefined,
): Promise<string[]> {
	let credentialTypes: string[] = [];
	if (context.nodeService.getNodeCredentialTypes) {
		credentialTypes = await context.nodeService
			.getNodeCredentialTypes(
				node.type,
				typeVersion,
				parameters,
				node.credentials as Record<string, unknown> | undefined,
			)
			.catch(() => []);
	}

	// Fallback: if dynamic detection returned nothing, check the node description's
	// static credentials list with displayOptions filtering. This catches cases where
	// getNodeCredentialTypes fails silently (e.g. node lookup miss) or isn't available.
	// We intentionally do NOT fall back to node.credentials here — stale credential
	// entries (e.g. httpHeaderAuth left over from an earlier builder revision when
	// authentication was later changed to 'none') must not generate setup requests.
	if (credentialTypes.length === 0 && nodeDesc?.credentials) {
		credentialTypes = nodeDesc.credentials
			.filter((c: { name?: string; displayOptions?: unknown }) => {
				if (!c.displayOptions) return true;
				return matchesDisplayOptions(
					{ parameters, nodeVersion: typeVersion },
					c.displayOptions as DisplayOptions,
				);
			})
			.map((c: { name?: string }) => c.name)
			.filter((n): n is string => n !== undefined);
	}

	// Dynamic credential resolution for nodes that use genericCredentialType
	// or predefinedCredentialType (e.g. HTTP Request). The credential type name
	// is stored in the node parameters rather than the description's credentials array.
	if (credentialTypes.length === 0) {
		const authentication = parameters.authentication;
		if (
			authentication === 'genericCredentialType' &&
			typeof parameters.genericAuthType === 'string'
		) {
			credentialTypes = [parameters.genericAuthType];
		} else if (
			authentication === 'predefinedCredentialType' &&
			typeof parameters.nodeCredentialType === 'string'
		) {
			credentialTypes = [parameters.nodeCredentialType];
		}
	}

	return credentialTypes;
}

interface CredentialState {
	existingCredentials: Array<{ id: string; name: string }>;
	isAutoApplied: boolean;
	/**
	 * True when the auto-apply picked the AI Gateway managed option instead
	 * of a stored credential. Downstream writes `AI_GATEWAY_CREDENTIAL` to
	 * the node rather than an `{ id, name }` stored reference.
	 */
	autoAppliedGateway?: true;
	credentialTestResult?: { success: boolean; message?: string };
	/**
	 * The stored credential this slot effectively resolves to — bound on the node,
	 * or the sole candidate we auto-applied. Undefined for the gateway option (no
	 * stored record) and for slots with nothing resolved yet. Used to validate
	 * credential-scoped parameter values such as a model name.
	 */
	effectiveCredential?: { id: string; name: string };
}

/**
 * Same-service filter for Templated Custom Auth candidates: the type is shared
 * by every service, so a credential is offered only when the host it was
 * created for (its stored `serviceHost`) matches the node's own URL host.
 * Untagged credentials — and every candidate when the node host can't be
 * derived — are dropped: fail closed rather than offer another service's key.
 * Same-service reuse survives; anything else goes through "create new".
 */
async function filterTemplatedCredentialsByServiceHost(
	context: InstanceAiContext,
	node: NodeJSON,
	candidates: Array<{ id: string; name: string }>,
	cache: CredentialCache | undefined,
): Promise<Array<{ id: string; name: string }>> {
	if (candidates.length === 0) return candidates;
	const parameters = node.parameters as Record<string, unknown> | undefined;
	const nodeHost = extractServiceHost(parameters?.url);
	const getHosts = context.credentialService.getTemplatedCredentialHosts?.bind(
		context.credentialService,
	);
	if (!nodeHost || !getHosts) return [];
	const ids = candidates.map((c) => c.id);
	const cacheKey = [...ids].sort().join(',');
	let hostsPromise = cache?.templatedHosts.get(cacheKey);
	if (!hostsPromise) {
		hostsPromise = getHosts(ids).catch((): Record<string, string | null> => ({}));
		cache?.templatedHosts.set(cacheKey, hostsPromise);
	}
	const hosts = await hostsPromise;
	return candidates.filter((candidate) => {
		const host = hosts[candidate.id];
		return typeof host === 'string' && host !== '' && serviceHostsMatch(host, nodeHost);
	});
}

/**
 * For a single credential type, list existing credentials (cached + workflow-scoped),
 * decide whether to auto-apply the sole candidate, and test the resolved credential.
 */
async function resolveCredentialState(
	context: InstanceAiContext,
	node: NodeJSON,
	credentialType: string,
	cache: CredentialCache | undefined,
	workflowId: string | undefined,
	prefersNewCredential = false,
): Promise<CredentialState> {
	// Use cache to avoid duplicate fetches for the same credential type across nodes.
	// Scope to the workflow so we list only credentials the save path will accept —
	// the editor's credential picker uses the same scoping. The cache key includes
	// workflowId so a cache shared across workflows stays correct.
	const cacheKey = listCacheKey(workflowId, credentialType);
	let listPromise = cache?.lists.get(cacheKey);
	if (!listPromise) {
		listPromise = context.credentialService
			.list({ type: credentialType, ...(workflowId ? { workflowId } : {}) })
			.then((creds) => creds.map((c) => ({ id: c.id, name: c.name })));
		cache?.lists.set(cacheKey, listPromise);
	}
	const sortedCreds = await listPromise;
	let existingCredentials = sortedCreds.map((c) => ({ id: c.id, name: c.name }));
	if (credentialType === TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE) {
		existingCredentials = await filterTemplatedCredentialsByServiceHost(
			context,
			node,
			existingCredentials,
			cache,
		);
	}

	// A slot the user asked to refill fresh ignores its current binding — it renders
	// unbound, so the bound credential is neither tested nor reported as effective.
	const existingOnNode = prefersNewCredential ? undefined : node.credentials?.[credentialType];
	const existingCredentialId =
		typeof existingOnNode?.id === 'string' && existingOnNode.id ? existingOnNode.id : undefined;
	const hasExistingOnNode =
		existingCredentialId !== undefined || isAiGatewayManagedCredential(existingOnNode);
	let isAutoApplied = false;
	let autoAppliedGateway: true | undefined;

	// Auto-apply n8n credits only when the user has no credentials of their own
	// for this type: none assigned on the node (`hasExistingOnNode`) and none
	// stored (`existingCredentials`). Any user-defined credential wins — we never
	// override a saved key with the managed gateway.
	if (
		!hasExistingOnNode &&
		!prefersNewCredential &&
		existingCredentials.length === 0 &&
		context.credentialService.isAiGatewayCredentialType
	) {
		const gatewaySupported = await context.credentialService
			.isAiGatewayCredentialType(credentialType)
			.catch(() => false);
		if (gatewaySupported) {
			isAutoApplied = true;
			autoAppliedGateway = true;
		}
	}

	// Fall back to auto-applying the sole stored credential when n8n credits
	// is not available. With multiple candidates, picking the first is a
	// silent guess — surface the list so the setup wizard can prompt.
	// Generic auth types never auto-apply: the type alone does not identify a
	// service, so a sole bearer/header/etc. key must not be attached to an
	// arbitrary URL without the user's click (a sole match arrives preselected).
	// Neither does a type the user asked to create fresh — answering that with a
	// stored credential is the contradiction this flag exists to prevent.
	if (
		!prefersNewCredential &&
		!hasExistingOnNode &&
		shouldAutoResolveCredential(credentialType, existingCredentials.length)
	) {
		isAutoApplied = true;
	}

	const credToTest =
		existingCredentialId ??
		(isAutoApplied && !autoAppliedGateway ? existingCredentials[0]?.id : undefined);
	if (!credToTest) {
		return {
			existingCredentials,
			isAutoApplied,
			...(autoAppliedGateway ? { autoAppliedGateway } : {}),
		};
	}

	const resolvedCredential = sortedCreds.find((c) => c.id === credToTest);
	const effectiveCredential = resolvedCredential ? { effectiveCredential: resolvedCredential } : {};

	let testabilityPromise = cache?.testability.get(credentialType);
	if (!testabilityPromise) {
		testabilityPromise = context.credentialService.isTestable
			? context.credentialService.isTestable(credentialType).catch(() => true)
			: Promise.resolve(true);
		cache?.testability.set(credentialType, testabilityPromise);
	}
	const canTest = await testabilityPromise;
	if (!canTest) return { existingCredentials, isAutoApplied, ...effectiveCredential };

	let testPromise = cache?.tests.get(credToTest);
	if (!testPromise) {
		testPromise = context.credentialService.test(credToTest).catch((testError) => ({
			success: false,
			message: testError instanceof Error ? testError.message : 'Test failed',
		}));
		cache?.tests.set(credToTest, testPromise);
	}
	const credentialTestResult = await testPromise;
	return { existingCredentials, isAutoApplied, credentialTestResult, ...effectiveCredential };
}

type RequestNodeCredentials = NonNullable<SetupRequest['node']['credentials']>;

/** Build the optional `credentials` slice of a setup request's node, merging an auto-applied credential. */
function buildRequestCredentials(
	nodeCredentials: Record<string, SetupNodeCredential> | undefined,
	isAutoApplied: boolean,
	credentialType: string | undefined,
	existingCredentials: Array<{ id: string; name: string }>,
	autoAppliedGateway: true | undefined,
): { credentials?: RequestNodeCredentials } {
	let autoCredential: Record<string, SetupNodeCredential> | undefined;
	if (isAutoApplied && credentialType) {
		if (autoAppliedGateway) {
			autoCredential = {
				[credentialType]: { ...AI_GATEWAY_CREDENTIAL, name: N8N_CONNECT_DISPLAY_NAME },
			};
		} else if (existingCredentials.length > 0) {
			autoCredential = {
				[credentialType]: {
					id: existingCredentials[0].id,
					name: existingCredentials[0].name,
				},
			};
		}
	}

	if (nodeCredentials && Object.keys(nodeCredentials).length > 0) {
		return {
			credentials: (autoCredential
				? { ...nodeCredentials, ...autoCredential }
				: nodeCredentials) as RequestNodeCredentials,
		};
	}

	return autoCredential ? { credentials: autoCredential as RequestNodeCredentials } : {};
}

/**
 * Build setup request(s) from a single WorkflowJSON node.
 * Detects credential types, auto-selects the most recent credential,
 * tests testable credentials, determines trigger eligibility, and
 * computes parameter issues with editable parameter definitions.
 */
/** Resolve credential state for a type and auto-apply the sole candidate onto nodeCredentials. */
async function resolveAppliedCredentialState(
	context: InstanceAiContext,
	node: NodeJSON,
	credentialType: string | undefined,
	cache: CredentialCache | undefined,
	workflowId: string | undefined,
	nodeCredentials: Record<string, SetupNodeCredential> | undefined,
	prefersNewCredential = false,
): Promise<CredentialState> {
	if (!credentialType) {
		return { existingCredentials: [], isAutoApplied: false };
	}
	const state = await resolveCredentialState(
		context,
		node,
		credentialType,
		cache,
		workflowId,
		prefersNewCredential,
	);
	if (state.isAutoApplied && nodeCredentials) {
		if (state.autoAppliedGateway) {
			nodeCredentials[credentialType] = {
				...AI_GATEWAY_CREDENTIAL,
				name: N8N_CONNECT_DISPLAY_NAME,
			};
		} else {
			nodeCredentials[credentialType] = {
				id: state.existingCredentials[0].id,
				name: state.existingCredentials[0].name,
			};
		}
	}
	return state;
}

interface NodeSetupContext {
	nodeName: string;
	isTrigger: boolean;
	isTestable: boolean;
	parameterIssues: Record<string, string[]>;
	editableParameters: SetupRequest['editableParameters'];
	triggerTestResult?: { status: 'success' | 'error' | 'listening'; error?: string };
	nodeId: string;
	nodePosition: [number, number];
	typeVersion: number;
	parameters: Record<string, unknown>;
}

/**
 * Build a single setup request for one (optional) credential type: resolve and
 * auto-apply credentials, decide whether user action is still needed, and assemble
 * the request. Returns null when the request carries nothing actionable.
 */
async function buildRequestForCredentialType(
	context: InstanceAiContext,
	node: NodeJSON,
	credentialType: string | undefined,
	cache: CredentialCache | undefined,
	workflowId: string | undefined,
	nodeCtx: NodeSetupContext,
	preferNewCredentialTypes?: ReadonlySet<string>,
	appliedCredentialIds?: ReadonlySet<string>,
): Promise<SetupRequest | null> {
	const prefersNewCredential =
		credentialType !== undefined && (preferNewCredentialTypes?.has(credentialType) ?? false);
	const nodeCredentials = node.credentials
		? Object.fromEntries(
				Object.entries(node.credentials)
					.map(([key, value]) => [key, toSetupNodeCredential(value)] as const)
					.filter(
						(entry): entry is readonly [string, SetupNodeCredential] => entry[1] !== undefined,
					),
			)
		: undefined;
	// A slot routed to fresh creation renders unbound: the credential being replaced
	// must not show as selected — it only stays available in the picker's list.
	if (prefersNewCredential && credentialType && nodeCredentials) {
		delete nodeCredentials[credentialType];
	}

	const {
		existingCredentials,
		isAutoApplied,
		credentialTestResult,
		autoAppliedGateway,
		effectiveCredential,
	} = await resolveAppliedCredentialState(
		context,
		node,
		credentialType,
		cache,
		workflowId,
		nodeCredentials,
		prefersNewCredential,
	);

	// The connected credential can rule out a parameter value chosen before it existed
	// (see computeUnavailableLocatorIssues), so fold those in as parameter issues.
	// Gateway-managed (n8n credits) slots use the managed tag so the host can
	// validate against the gateway allowlist.
	const locatorCredential =
		effectiveCredential ??
		(credentialType &&
		!prefersNewCredential &&
		(autoAppliedGateway || isAiGatewayManagedCredential(node.credentials?.[credentialType]))
			? { id: AI_GATEWAY_MANAGED_TAG, name: N8N_CONNECT_DISPLAY_NAME }
			: undefined);
	const unavailableIssues =
		credentialType && locatorCredential
			? await computeUnavailableLocatorIssues(
					context,
					node,
					nodeCtx.parameters,
					nodeCtx.typeVersion,
					credentialType,
					locatorCredential,
				)
			: {};

	const parameterIssues = { ...nodeCtx.parameterIssues, ...unavailableIssues };

	const { isTrigger, isTestable } = nodeCtx;
	const hasParamIssues = Object.keys(parameterIssues).length > 0;
	if (!credentialType && !isTrigger && !hasParamIssues) return null;
	if (!credentialType && isTrigger && !isTestable && !hasParamIssues) return null;

	// Determine whether this request still needs user intervention.
	// A credential request needs action only when the slot leaves something to
	// collect: nothing bound, or a bound id that doesn't resolve to a stored
	// credential in scope (e.g. an imported workflow referencing another
	// instance's credential). A resolvable bound credential is settled even if
	// its live test fails — tests fail transiently and re-asking for an
	// already-connected credential is redundant friction; the failure still
	// rides along in credentialTestResult for display.
	// A parameter request needs action if issues remain.
	// A trigger-only request (no credential, no param issues) never blocks apply.
	let credentialNeedsAction = false;
	if (credentialType) {
		const existingOnNode = node.credentials?.[credentialType];
		const boundId =
			typeof existingOnNode?.id === 'string' && existingOnNode.id !== ''
				? existingOnNode.id
				: undefined;
		// A preferNewCredentials request un-settles a node that already has a working
		// credential, so setup shows the card to change it instead of reporting
		// "No nodes require setup". Otherwise, an id the resume just applied resolves
		// even when the list view lags it (scoped discovery, e.g. eval allowlist
		// pins) — the apply already resolved it before binding.
		const isSettled =
			!prefersNewCredential &&
			(isAiGatewayManagedCredential(existingOnNode) ||
				(boundId !== undefined &&
					(existingCredentials.some((credential) => credential.id === boundId) ||
						(appliedCredentialIds?.has(boundId) ?? false))));
		credentialNeedsAction = !isSettled;
	}
	// Tracked apart from `needsAction` because the two answer different questions: a node with a
	// working credential and an unfilled parameter needs action, but not *about the credential*.
	const needsAction = credentialNeedsAction || hasParamIssues;

	return {
		node: {
			name: nodeCtx.nodeName,
			type: node.type,
			typeVersion: nodeCtx.typeVersion,
			parameters: nodeCtx.parameters,
			position: nodeCtx.nodePosition,
			id: nodeCtx.nodeId,
			...buildRequestCredentials(
				nodeCredentials,
				isAutoApplied,
				credentialType,
				existingCredentials,
				autoAppliedGateway,
			),
		},
		...(credentialType ? { credentialType } : {}),
		...(existingCredentials.length > 0 ? { existingCredentials } : {}),
		isTrigger,
		...(isTestable ? { isTestable } : {}),
		...(isAutoApplied ? { isAutoApplied } : {}),
		...(prefersNewCredential ? { preferNewCredential: true } : {}),
		...(credentialTestResult ? { credentialTestResult } : {}),
		...(nodeCtx.triggerTestResult ? { triggerTestResult: nodeCtx.triggerTestResult } : {}),
		...(hasParamIssues ? { parameterIssues } : {}),
		...(nodeCtx.editableParameters && nodeCtx.editableParameters.length > 0
			? { editableParameters: nodeCtx.editableParameters }
			: {}),
		needsAction,
		...(credentialNeedsAction ? { credentialNeedsAction } : {}),
	};
}

export async function buildSetupRequests(
	context: InstanceAiContext,
	node: NodeJSON,
	triggerTestResult?: { status: 'success' | 'error' | 'listening'; error?: string },
	cache?: CredentialCache,
	workflowId?: string,
	preferNewCredentialTypes?: ReadonlySet<string>,
	appliedCredentialIds?: ReadonlySet<string>,
): Promise<SetupRequest[]> {
	if (!node.name) return [];
	if (node.disabled) return [];

	const typeVersion = node.typeVersion ?? 1;
	const parameters = (node.parameters as Record<string, unknown>) ?? {};

	const nodeDesc = await context.nodeService
		.getDescription(node.type, typeVersion)
		.catch(() => undefined);

	const isTrigger = nodeDesc?.group?.includes('trigger') ?? false;
	const isTestable =
		isTrigger &&
		((nodeDesc?.webhooks !== undefined && nodeDesc.webhooks.length > 0) ||
			nodeDesc?.polling === true ||
			nodeDesc?.triggerPanel !== undefined);

	const parameterIssues = await computeParameterIssues(context, node, parameters, typeVersion);
	const editableParameters = buildEditableParameters(parameterIssues, nodeDesc);
	const credentialTypes = await resolveCredentialTypes(
		context,
		node,
		parameters,
		typeVersion,
		nodeDesc,
	);

	const nodeId = node.id ?? nanoid();
	const nodePosition: [number, number] = node.position ?? [0, 0];

	const requests: SetupRequest[] = [];
	const processedCredTypes = credentialTypes.length > 0 ? credentialTypes : [undefined];
	const nodeCtx: NodeSetupContext = {
		nodeName: node.name,
		isTrigger,
		isTestable,
		parameterIssues,
		editableParameters,
		triggerTestResult,
		nodeId,
		nodePosition,
		typeVersion,
		parameters,
	};

	for (const credentialType of processedCredTypes) {
		const request = await buildRequestForCredentialType(
			context,
			node,
			credentialType,
			cache,
			workflowId,
			nodeCtx,
			preferNewCredentialTypes,
			appliedCredentialIds,
		);
		if (request) requests.push(request);
	}

	return requests;
}

// ── Credential setup hints ──────────────────────────────────────────────────

/** Agent-supplied Templated Custom Auth recipe, optionally scoped to one node. */
export type CredentialHintInput = InstanceAiCredentialSetupHint & { nodeName?: string };

/**
 * Attach agent-supplied credential recipes to the setup requests of nodes
 * using Templated Custom Auth, so the setup UI creates the credential from the
 * template and asks the user only for the placeholder values. A node-scoped
 * hint (nodeName) wins over a type-wide one.
 */
export function applyCredentialHints(
	requests: SetupRequest[],
	hints: CredentialHintInput[] | undefined,
): void {
	if (!hints?.length) return;
	for (const request of requests) {
		if (request.credentialType !== TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE) continue;
		const hint =
			hints.find((h) => h.nodeName === request.node.name) ?? hints.find((h) => !h.nodeName);
		if (!hint) continue;
		const { nodeName: _nodeName, ...setupHint } = hint;
		// The workflow node is the authority for service identity. The recipe's
		// own destinations cannot establish or replace it.
		const serviceHost = extractServiceHost(request.node.parameters?.url);
		const serviceOrigin = extractServiceOrigin(request.node.parameters?.url);
		request.setupHint = {
			...setupHint,
			...(serviceHost ? { serviceHost } : {}),
			...(serviceOrigin ? { serviceOrigin } : {}),
		};
	}
}

// ── Execution order ─────────────────────────────────────────────────────────

/**
 * Sort setup requests by execution order derived from workflow connections,
 * then mark the first trigger in the result.
 *
 * Algorithm: DFS from each trigger (sorted left-to-right by X position),
 * following outgoing connections. Nodes not reachable from any trigger go last.
 */
function addConnectionEdge(
	mainOutgoing: Map<string, string[]>,
	nonMainIncoming: Map<string, string[]>,
	sourceName: string,
	connType: string,
	conn: unknown,
): void {
	if (typeof conn !== 'object' || conn === null || !('node' in conn)) return;
	const destName = (conn as { node: string }).node;

	if (connType === 'main') {
		const existing = mainOutgoing.get(sourceName) ?? [];
		if (!existing.includes(destName)) existing.push(destName);
		mainOutgoing.set(sourceName, existing);
	} else {
		// Non-main connection: source is an AI sub-node of destination
		const existing = nonMainIncoming.get(destName) ?? [];
		if (!existing.includes(sourceName)) existing.push(sourceName);
		nonMainIncoming.set(destName, existing);
	}
}

function addSourceConnections(
	sourceName: string,
	nodeConns: Record<string, unknown>,
	mainOutgoing: Map<string, string[]>,
	nonMainIncoming: Map<string, string[]>,
): void {
	for (const [connType, outputs] of Object.entries(nodeConns)) {
		if (!Array.isArray(outputs)) continue;
		for (const slot of outputs) {
			if (!Array.isArray(slot)) continue;
			for (const conn of slot) {
				addConnectionEdge(mainOutgoing, nonMainIncoming, sourceName, connType, conn);
			}
		}
	}
}

/**
 * Build adjacency maps from workflow connections: main outgoing (source →
 * destinations) and non-main incoming (destination → AI sub-node sources).
 */
function buildConnectionAdjacency(connections: Record<string, unknown>): {
	mainOutgoing: Map<string, string[]>;
	nonMainIncoming: Map<string, string[]>;
} {
	const mainOutgoing = new Map<string, string[]>();
	const nonMainIncoming = new Map<string, string[]>();

	for (const [sourceName, nodeConns] of Object.entries(connections)) {
		if (typeof nodeConns !== 'object' || nodeConns === null) continue;
		addSourceConnections(
			sourceName,
			nodeConns as Record<string, unknown>,
			mainOutgoing,
			nonMainIncoming,
		);
	}

	return { mainOutgoing, nonMainIncoming };
}

export function sortByExecutionOrder(
	requests: SetupRequest[],
	connections: Record<string, unknown>,
): void {
	const { mainOutgoing, nonMainIncoming } = buildConnectionAdjacency(connections);

	const triggerRequests = requests
		.filter((r) => r.isTrigger)
		.sort((a, b) => a.node.position[0] - b.node.position[0]);

	const visited = new Set<string>();
	const executionOrder: string[] = [];

	function dfs(nodeName: string): void {
		if (visited.has(nodeName)) return;
		visited.add(nodeName);

		// Visit AI sub-nodes before the node that consumes them (non-main incoming connections).
		const subNodes = nonMainIncoming.get(nodeName) ?? [];
		for (const subNode of subNodes) {
			dfs(subNode);
		}

		executionOrder.push(nodeName);

		// Follow main outgoing connections
		const children = mainOutgoing.get(nodeName) ?? [];
		for (const child of children) {
			dfs(child);
		}
	}

	for (const trigger of triggerRequests) {
		dfs(trigger.node.name);
	}

	const orderMap = new Map<string, number>();
	for (let i = 0; i < executionOrder.length; i++) {
		orderMap.set(executionOrder[i], i);
	}

	requests.sort((a, b) => {
		const aOrder = orderMap.get(a.node.name) ?? Number.MAX_SAFE_INTEGER;
		const bOrder = orderMap.get(b.node.name) ?? Number.MAX_SAFE_INTEGER;
		if (aOrder !== bOrder) return aOrder - bOrder;
		return a.node.position[0] - b.node.position[0] || a.node.position[1] - b.node.position[1];
	});

	const firstTrigger = requests.find((r) => r.isTrigger);
	if (firstTrigger) {
		firstTrigger.isFirstTrigger = true;
	}
}

// ── Workflow mutation ───────────────────────────────────────────────────────

/** Result of applying credentials or parameters to workflow nodes. */
export interface ApplyResult {
	applied: string[];
	failed: Array<{ nodeName: string; error: string }>;
}

function addUnknownNodeFailures(
	result: ApplyResult,
	workflowJson: WorkflowJSON,
	nodeCredentials?: Record<string, Record<string, string>>,
	nodeParameters?: Record<string, Record<string, unknown>>,
): void {
	const nodeNames = new Set(workflowJson.nodes.map((node) => node.name).filter(Boolean));
	const submittedNodeNames = new Set([
		...Object.keys(nodeCredentials ?? {}),
		...Object.keys(nodeParameters ?? {}),
	]);

	for (const nodeName of submittedNodeNames) {
		if (!nodeNames.has(nodeName)) {
			result.failed.push({
				nodeName,
				error: `Node "${nodeName}" was not found in the workflow`,
			});
		}
	}
}

/** Apply per-node credentials from resume data to a workflow. */
export async function applyNodeCredentials(
	context: InstanceAiContext,
	workflowId: string,
	nodeCredentials: Record<string, Record<string, string>>,
): Promise<ApplyResult> {
	const result: ApplyResult = { applied: [], failed: [] };
	const workflowJson = await context.workflowService.getAsWorkflowJSON(workflowId);
	addUnknownNodeFailures(result, workflowJson, nodeCredentials);

	for (const node of workflowJson.nodes) {
		if (!node.name) continue;
		const credsMap = nodeCredentials[node.name];
		if (!credsMap) continue;

		let nodeSucceeded = true;
		for (const [credType, credId] of Object.entries(credsMap)) {
			const resolved = await resolveCredentialForApply(credType, credId, context);
			if (resolved.resolved) {
				assignCredentialToNode(node, credType, resolved.credential);
			} else {
				nodeSucceeded = false;
				result.failed.push({
					nodeName: node.name,
					error: resolved.error,
				});
			}
		}
		if (nodeSucceeded) {
			result.applied.push(node.name);
		}
	}

	try {
		const saved = await context.workflowService.updateFromWorkflowJSON(workflowId, workflowJson);
		await refreshWorkflowSourceFileBindingFromSave(context, workflowId, {
			versionId: saved.versionId,
			checksum: saved.checksum,
		});
	} catch (error) {
		// If the final save fails, mark all previously-applied nodes as failed
		const saveError = `Failed to save workflow after credential apply: ${error instanceof Error ? error.message : 'Unknown error'}`;
		for (const nodeName of result.applied) {
			result.failed.push({ nodeName, error: saveError });
		}
		result.applied = [];
	}

	return result;
}

/** Apply per-node parameter values from resume data to a workflow. */
export async function applyNodeParameters(
	context: InstanceAiContext,
	workflowId: string,
	nodeParameters: Record<string, Record<string, unknown>>,
): Promise<ApplyResult> {
	const result: ApplyResult = { applied: [], failed: [] };
	const workflowJson = await context.workflowService.getAsWorkflowJSON(workflowId);
	addUnknownNodeFailures(result, workflowJson, undefined, nodeParameters);

	for (const node of workflowJson.nodes) {
		if (!node.name) continue;
		const params = nodeParameters[node.name];
		if (!params) continue;

		try {
			node.parameters = {
				...(node.parameters ?? {}),
				...params,
			} as IDataObject;
			result.applied.push(node.name);
		} catch (error) {
			result.failed.push({
				nodeName: node.name,
				error: `Failed to merge parameters: ${error instanceof Error ? error.message : 'Unknown error'}`,
			});
		}
	}

	try {
		const saved = await context.workflowService.updateFromWorkflowJSON(workflowId, workflowJson);
		await refreshWorkflowSourceFileBindingFromSave(context, workflowId, {
			versionId: saved.versionId,
			checksum: saved.checksum,
		});
	} catch (error) {
		const saveError = `Failed to save workflow after parameter apply: ${error instanceof Error ? error.message : 'Unknown error'}`;
		for (const nodeName of result.applied) {
			result.failed.push({ nodeName, error: saveError });
		}
		result.applied = [];
	}

	return result;
}

/**
 * Attribution funnel: record who configured a credential and whether it is n8n
 * Connect vs BYOK, so analytics can compare agent-driven vs manual assignment
 * (the manual canvas emits the same event with `source: 'user'`).
 *
 * `instance-ai-auto` mirrors the auto-apply rules in `buildSetupRequests`: Instance
 * AI defaults a credential unprompted only when the node had none and the user did
 * not have to choose — n8n Connect when the user has no stored credential of the
 * type (rule 3), or their sole stored non-generic credential (rule 2). Anything
 * else — a credential picked among several, a generic auth type, or n8n Connect
 * chosen despite having stored keys — is a user-confirmed choice.
 */
async function trackCredentialAssignment(
	context: InstanceAiContext,
	opts: {
		credType: string;
		nodeType: string;
		workflowId: string;
		credential: SetupNodeCredential;
		hadPriorCredential: boolean;
	},
): Promise<void> {
	if (!context.trackTelemetry) return;
	const isGateway = isAiGatewayManagedCredential(opts.credential);
	let source: 'instance-ai-auto' | 'instance-ai-confirmed' = 'instance-ai-confirmed';
	if (!opts.hadPriorCredential) {
		const stored = await context.credentialService
			.list({ type: opts.credType, ...(opts.workflowId ? { workflowId: opts.workflowId } : {}) })
			.catch(() => []);
		// Gateway auto-applies when no stored key exists; a BYOK key auto-applies
		// when it is the user's only one for a service-scoped type. Generic auth
		// never auto-applies — the type alone does not identify a service.
		const soleByokAutoApplied =
			!isGateway && shouldAutoResolveCredential(opts.credType, stored.length);
		if (isGateway ? stored.length === 0 : soleByokAutoApplied) source = 'instance-ai-auto';
	}
	context.trackTelemetry('Node credential assigned', {
		credential_type: opts.credType,
		node_type: opts.nodeType,
		workflow_id: opts.workflowId,
		// The join key back to `User created credentials`; n8n Connect slots have no
		// stored credential of the user's own.
		credential_id: isGateway ? null : opts.credential.id,
		thread_id: context.threadId ?? null,
		credential_kind: isGateway ? 'n8n_connect' : 'own',
		source,
	});
}

/** Resolve and apply each credential in credsMap onto the node; returns whether all succeeded. */
async function applyCredentialsToNode(
	context: InstanceAiContext,
	node: NodeJSON,
	nodeName: string,
	credsMap: Record<string, string>,
	result: ApplyResult,
	workflowId: string,
): Promise<boolean> {
	let nodeSucceeded = true;
	for (const [credType, credId] of Object.entries(credsMap)) {
		const hadPriorCredential = node.credentials?.[credType] !== undefined;
		const resolved = await resolveCredentialForApply(credType, credId, context);
		if (resolved.resolved) {
			assignCredentialToNode(node, credType, resolved.credential);
			await trackCredentialAssignment(context, {
				credType,
				nodeType: node.type,
				workflowId,
				credential: resolved.credential,
				hadPriorCredential,
			});
		} else {
			nodeSucceeded = false;
			context.trackTelemetry?.('Node credential assignment failed', {
				credential_type: credType,
				node_type: node.type,
				workflow_id: workflowId,
				error: resolved.error,
			});
			result.failed.push({
				nodeName,
				error: resolved.error,
			});
		}
	}
	return nodeSucceeded;
}

/** Merge params into the node's parameters; returns whether it succeeded. */
function applyParametersToNode(
	node: NodeJSON,
	nodeName: string,
	params: Record<string, unknown>,
	result: ApplyResult,
): boolean {
	try {
		node.parameters = {
			...(node.parameters ?? {}),
			...params,
		} as IDataObject;
		return true;
	} catch (error) {
		result.failed.push({
			nodeName,
			error: `Failed to merge parameters: ${error instanceof Error ? error.message : 'Unknown error'}`,
		});
		return false;
	}
}

/**
 * Atomically apply both credentials and parameters to a workflow in a single
 * load-mutate-save cycle, avoiding partial-success overwrite windows.
 */
export async function applyNodeChanges(
	context: InstanceAiContext,
	workflowId: string,
	nodeCredentials?: Record<string, Record<string, string>>,
	nodeParameters?: Record<string, Record<string, unknown>>,
): Promise<ApplyResult> {
	const result: ApplyResult = { applied: [], failed: [] };
	const workflowJson = await context.workflowService.getAsWorkflowJSON(workflowId);
	const appliedNodes = new Set<string>();
	addUnknownNodeFailures(result, workflowJson, nodeCredentials, nodeParameters);

	for (const node of workflowJson.nodes) {
		if (!node.name) continue;
		const nodeName = node.name;

		const credsMap = nodeCredentials?.[nodeName];
		if (
			credsMap &&
			(await applyCredentialsToNode(context, node, nodeName, credsMap, result, workflowId))
		) {
			appliedNodes.add(nodeName);
		}

		const params = nodeParameters?.[nodeName];
		if (params) {
			// A display name typed into a list-mode resource locator can never
			// resolve (list values are opaque picker IDs) — store it as name mode.
			coerceWrongKindListModeParams(context.nodeTypesProvider, node, params);
			if (applyParametersToNode(node, nodeName, params, result)) {
				appliedNodes.add(nodeName);
			}
		}

		// Drop credential entries that are no longer valid for the node's current
		// parameters. Entries just applied via credsMap are preserved so a user
		// can assign an auxiliary credential without it being cleaned up.
		const appliedTypes = new Set(credsMap ? Object.keys(credsMap) : []);
		await stripStaleCredentialsFromNode(context, node, appliedTypes);
	}

	// Single save for all changes
	try {
		const saved = await context.workflowService.updateFromWorkflowJSON(workflowId, workflowJson);
		await refreshWorkflowSourceFileBindingFromSave(context, workflowId, {
			versionId: saved.versionId,
			checksum: saved.checksum,
		});
		result.applied = [...appliedNodes];
	} catch (error) {
		const saveError = `Failed to save workflow: ${error instanceof Error ? error.message : 'Unknown error'}`;
		for (const nodeName of appliedNodes) {
			result.failed.push({ nodeName, error: saveError });
		}
		result.applied = [];
	}

	return result;
}

// ── Partial-apply reporting ──────────────────────────────────────────────────

/** Build a report of nodes that received credentials or parameters. */
export function buildCompletedReport(
	appliedCredentials?: Record<string, Record<string, string>>,
	appliedParameters?: Record<string, Record<string, unknown>>,
	appliedNodeNames?: string[],
): Array<{ nodeName: string; credentialType?: string; parametersSet?: string[] }> {
	const byNode = new Map<string, { credentialTypes: string[]; parameterNames: string[] }>();
	const appliedNodeNameSet = appliedNodeNames ? new Set(appliedNodeNames) : undefined;

	if (appliedCredentials) {
		for (const [nodeName, credMap] of Object.entries(appliedCredentials)) {
			if (appliedNodeNameSet && !appliedNodeNameSet.has(nodeName)) continue;
			for (const credType of Object.keys(credMap)) {
				let entry = byNode.get(nodeName);
				if (!entry) {
					entry = { credentialTypes: [], parameterNames: [] };
					byNode.set(nodeName, entry);
				}
				entry.credentialTypes.push(credType);
			}
		}
	}

	if (appliedParameters) {
		for (const [nodeName, params] of Object.entries(appliedParameters)) {
			if (appliedNodeNameSet && !appliedNodeNameSet.has(nodeName)) continue;
			let entry = byNode.get(nodeName);
			if (!entry) {
				entry = { credentialTypes: [], parameterNames: [] };
				byNode.set(nodeName, entry);
			}
			entry.parameterNames.push(...Object.keys(params));
		}
	}

	const result: Array<{ nodeName: string; credentialType?: string; parametersSet?: string[] }> = [];
	for (const [nodeName, entry] of byNode) {
		if (entry.credentialTypes.length > 0) {
			for (const credType of entry.credentialTypes) {
				result.push({
					nodeName,
					credentialType: credType,
					...(entry.parameterNames.length > 0 ? { parametersSet: entry.parameterNames } : {}),
				});
			}
		} else if (entry.parameterNames.length > 0) {
			result.push({ nodeName, parametersSet: entry.parameterNames });
		}
	}
	return result;
}

// ── Sub-node grouping ───────────────────────────────────────────────────────
type SubnodeRootNode = Pick<INode, 'name' | 'type' | 'typeVersion' | 'id'>;

export function buildSubnodeToRootNodeMap(
	nodes: NodeJSON[],
	connections: IConnections,
	executionOrder: string[],
): Map<string, SubnodeRootNode> {
	const connectionsByDestination = mapConnectionsByDestination(connections);

	const directSubnodesByNodeName = new Map<string, string[]>();
	for (const node of nodes) {
		if (!node.name) continue;
		// Non-main upstream nodes are direct sub-nodes of this node.
		const subs = getParentNodes(connectionsByDestination, node.name, 'ALL_NON_MAIN', 1);
		if (subs.length > 0) directSubnodesByNodeName.set(node.name, subs);
	}
	if (directSubnodesByNodeName.size === 0) return new Map();

	const allSubnodeNames = new Set<string>();
	for (const subs of directSubnodesByNodeName.values()) {
		for (const name of subs) allSubnodeNames.add(name);
	}
	const rootNodeNames = [...directSubnodesByNodeName.keys()].filter((n) => !allSubnodeNames.has(n));
	if (rootNodeNames.length === 0) return new Map();

	const nodeByName = new Map<string, NodeJSON>();
	for (const node of nodes) {
		if (node.name) nodeByName.set(node.name, node);
	}

	// Sort root nodes by execution order so the first to claim a sub-node
	// is the deterministic "owner" when multi-root ambiguity exists.
	const orderIndex = new Map<string, number>();
	for (let i = 0; i < executionOrder.length; i++) {
		orderIndex.set(executionOrder[i], i);
	}
	const sortedRootNodes = [...rootNodeNames].sort(
		(a, b) =>
			(orderIndex.get(a) ?? Number.MAX_SAFE_INTEGER) -
			(orderIndex.get(b) ?? Number.MAX_SAFE_INTEGER),
	);

	const subnodeToRootNode = new Map<string, SubnodeRootNode>();

	for (const rootNodeName of sortedRootNodes) {
		const rootNode = nodeByName.get(rootNodeName);
		if (!rootNode) continue;

		const subnodeRootNode: SubnodeRootNode = {
			name: rootNodeName,
			type: rootNode.type,
			typeVersion: rootNode.typeVersion ?? 1,
			id: rootNode.id ?? '',
		};

		const transitiveSubs = getParentNodes(
			connectionsByDestination,
			rootNodeName,
			'ALL_NON_MAIN',
			-1,
		);
		for (const subName of transitiveSubs) {
			if (subnodeToRootNode.has(subName)) continue;
			subnodeToRootNode.set(subName, subnodeRootNode);
		}
	}

	return subnodeToRootNode;
}

// ── Full workflow analysis ──────────────────────────────────────────────────

/**
 * Analyze all nodes in a workflow and produce sorted setup requests.
 * This is the main entry point — combines buildSetupRequests + sort + filter.
 */
export async function analyzeWorkflow(
	context: InstanceAiContext,
	workflowId: string,
	triggerResults?: Record<string, { status: 'success' | 'error' | 'listening'; error?: string }>,
	options?: {
		/** Keep settled requests (needsAction=false) in the result. For reporting
		 *  consumers only (e.g. the apply path surfacing a just-applied credential
		 *  whose test failed) — never for card rendering, where settled slots must
		 *  stay hidden. */
		includeSettled?: boolean;
		/** Credential types the user asked to create fresh: nothing is auto-applied
		 *  for them and their requests carry `preferNewCredential` so the card opens
		 *  unselected. */
		preferNewCredentialTypes?: readonly string[];
		/** Credential ids a setup resume just applied. A bound slot carrying one of
		 *  these is settled even when the credential list view lags it (scoped
		 *  discovery, e.g. eval allowlist pins) — the apply already resolved the id. */
		appliedCredentialIds?: readonly string[];
	},
): Promise<SetupRequest[]> {
	const workflowJson = await context.workflowService.getAsWorkflowJSON(workflowId);

	const preferNewCredentialTypes = options?.preferNewCredentialTypes?.length
		? new Set(options.preferNewCredentialTypes)
		: undefined;
	const appliedCredentialIds = options?.appliedCredentialIds?.length
		? new Set(options.appliedCredentialIds)
		: undefined;

	const cache = createCredentialCache();
	const allRequestArrays = await Promise.all(
		workflowJson.nodes.map(async (node) => {
			return await buildSetupRequests(
				context,
				node,
				triggerResults?.[node.name ?? ''],
				cache,
				workflowId,
				preferNewCredentialTypes,
				appliedCredentialIds,
			);
		}),
	);

	const setupRequests = allRequestArrays
		.flat()
		.filter(
			(req) =>
				req.credentialType !== undefined ||
				req.isTrigger ||
				(req.parameterIssues && Object.keys(req.parameterIssues).length > 0),
		)
		// Hide cards the user has nothing to do on: credentials already set
		// and no parameter issues. Settled trigger requests are hidden too —
		// no wizard step acts on them, so keeping them only renders an
		// already-complete credential card as if it were pending work.
		.filter((req) => options?.includeSettled === true || !!req.needsAction);

	sortByExecutionOrder(
		setupRequests,
		workflowJson.connections as unknown as Record<string, unknown>,
	);

	// Stamp `subnodeRootNode` on every sub-node setup request so the frontend can
	// render the group header even when the root node has no setup request of
	// its own. Sub-node membership is derived from the full workflow graph,
	// not just the (filtered) setup requests.
	const subnodeToRootNode = buildSubnodeToRootNodeMap(
		workflowJson.nodes,
		workflowJson.connections as unknown as IConnections,
		setupRequests.map((req) => req.node.name),
	);
	if (subnodeToRootNode.size > 0) {
		for (const req of setupRequests) {
			const subnodeRootNode = subnodeToRootNode.get(req.node.name);
			if (subnodeRootNode) {
				req.subnodeRootNode = subnodeRootNode;
			}
		}
	}

	return setupRequests;
}
