import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
import type { WorkflowJSON, NodeJSON } from '@n8n/workflow-sdk';
import type { Mock } from 'vitest';

import type { InstanceAiContext } from '../../../types';
import type { SetupRequest } from '../setup-workflow.schema';
import {
	buildSetupRequests,
	analyzeWorkflow,
	applyCredentialHints,
	applyNodeChanges,
	applyNodeCredentials,
	buildCompletedReport,
	createCredentialCache,
	stripStaleCredentialsFromWorkflow,
	type CredentialHintInput,
} from '../setup-workflow.service';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function createMockContext(overrides?: Partial<InstanceAiContext>): InstanceAiContext {
	return {
		userId: 'test-user',
		threadId: 'thread-1',
		workflowService: {
			list: vi.fn(),
			get: vi.fn(),
			getAsWorkflowJSON: vi.fn(),
			getWorkflowHead: vi.fn(),
			getWorkflowSnapshot: vi.fn(),
			createFromWorkflowJSON: vi.fn(),
			updateFromWorkflowJSON: vi.fn().mockResolvedValue({
				id: 'wf-1',
				versionId: 'v-next',
				checksum: 'checksum-next',
			}),
			archive: vi.fn(),
			unarchive: vi.fn(),
			publish: vi.fn(),
			unpublish: vi.fn(),
			clearAiTemporary: vi.fn(),
			archiveIfAiTemporary: vi.fn(),
		},
		executionService: {
			list: vi.fn(),
			run: vi.fn(),
			getStatus: vi.fn(),
			getResult: vi.fn(),
			stop: vi.fn(),
			getDebugInfo: vi.fn(),
			getNodeOutput: vi.fn(),
			getResolvedNodeParameters: vi.fn(),
		},
		credentialService: {
			list: vi.fn(),
			get: vi.fn(),
			delete: vi.fn(),
			test: vi.fn(),
		},
		nodeService: {
			listAvailable: vi.fn(),
			getDescription: vi.fn(),
			listSearchable: vi.fn(),
		},
		dataTableService: {
			list: vi.fn(),
			create: vi.fn(),
			delete: vi.fn(),
			getSchema: vi.fn(),
			addColumn: vi.fn(),
			deleteColumn: vi.fn(),
			renameColumn: vi.fn(),
			queryRows: vi.fn(),
			insertRows: vi.fn(),
			updateRows: vi.fn(),
			deleteRows: vi.fn(),
		},
		workflowTemplateService: {
			getTemplate: vi.fn(),
		},
		logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never,
		...overrides,
	};
}

function makeNode(overrides: Partial<NodeJSON> = {}): NodeJSON {
	return {
		name: 'Slack',
		type: 'n8n-nodes-base.slack',
		typeVersion: 2,
		parameters: {},
		position: [250, 300] as [number, number],
		id: 'node-1',
		...overrides,
	} as NodeJSON;
}

function makeWorkflowJSON(
	nodes: NodeJSON[] = [],
	connections: Record<string, unknown> = {},
): WorkflowJSON {
	return { nodes, connections } as unknown as WorkflowJSON;
}

// ---------------------------------------------------------------------------
// buildSetupRequests
// ---------------------------------------------------------------------------

describe('buildSetupRequests', () => {
	let context: InstanceAiContext;

	beforeEach(() => {
		context = createMockContext();
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});
		// Default: credential test passes (override in specific tests for failure cases)
		(context.credentialService.test as Mock).mockResolvedValue({ success: true });
	});

	describe('credential-scoped parameter availability', () => {
		function mockOpenAiNode() {
			(context.nodeService.getDescription as Mock).mockResolvedValue({
				group: [],
				credentials: [{ name: 'openAiApi' }],
				properties: [{ displayName: 'Model', name: 'model', type: 'resourceLocator' }],
			});
			(context.credentialService.list as Mock).mockResolvedValue([
				{ id: 'cred-free', name: 'n8n free OpenAI API credits' },
			]);
		}

		function mockUnavailable() {
			const findUnavailableLocatorValues = vi
				.fn()
				.mockResolvedValue([{ name: 'model', displayName: 'Model', currentValue: 'gpt-6-mini' }]);
			(context.nodeService as unknown as Record<string, unknown>).findUnavailableLocatorValues =
				findUnavailableLocatorValues;
			return findUnavailableLocatorValues;
		}

		function makeOpenAiNode(modelValue: unknown) {
			return makeNode({
				name: 'OpenAI Chat Model',
				type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
				typeVersion: 1.3,
				parameters: { model: modelValue },
				credentials: { openAiApi: { id: 'cred-free', name: 'n8n free OpenAI API credits' } },
			} as Partial<NodeJSON>);
		}

		it('raises an issue naming the unusable value and the credential', async () => {
			mockOpenAiNode();
			mockUnavailable();

			const node = makeOpenAiNode({ __rl: true, mode: 'id', value: 'gpt-6-mini' });
			const result = await buildSetupRequests(context, node);

			expect(result).toHaveLength(1);
			expect(result[0].parameterIssues?.model).toEqual([
				'"gpt-6-mini" isn\'t available with the connected credential "n8n free OpenAI API credits". ' +
					'Pick a value the credential offers instead — use nodes(action="explore-resources") to list allowed models.',
			]);
			expect(result[0].needsAction).toBe(true);
		});

		it("leaves the configured value untouched — repair is the caller's job", async () => {
			mockOpenAiNode();
			mockUnavailable();

			const node = makeOpenAiNode({ __rl: true, mode: 'id', value: 'gpt-6-mini' });
			const result = await buildSetupRequests(context, node);

			expect(result[0].node.parameters.model).toEqual({
				__rl: true,
				mode: 'id',
				value: 'gpt-6-mini',
			});
		});

		it('raises nothing when the host reports nothing unavailable', async () => {
			mockOpenAiNode();
			(context.nodeService as unknown as Record<string, unknown>).findUnavailableLocatorValues = vi
				.fn()
				.mockResolvedValue([]);

			const node = makeOpenAiNode({ __rl: true, mode: 'id', value: 'gpt-5-mini' });
			const result = await buildSetupRequests(context, node);

			expect(result[0].parameterIssues).toBeUndefined();
		});

		it('degrades quietly when the availability lookup throws', async () => {
			mockOpenAiNode();
			(context.nodeService as unknown as Record<string, unknown>).findUnavailableLocatorValues = vi
				.fn()
				.mockRejectedValue(new Error('provider unreachable'));

			const node = makeOpenAiNode({ __rl: true, mode: 'id', value: 'gpt-6-mini' });
			const result = await buildSetupRequests(context, node);

			expect(result[0].parameterIssues).toBeUndefined();
		});

		it('does not probe when no credential is resolved for the slot', async () => {
			mockOpenAiNode();
			(context.credentialService.list as Mock).mockResolvedValue([]);
			const probe = mockUnavailable();

			const node = makeNode({
				name: 'OpenAI Chat Model',
				type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
				typeVersion: 1.3,
				parameters: { model: { __rl: true, mode: 'id', value: 'gpt-6-mini' } },
			} as Partial<NodeJSON>);
			await buildSetupRequests(context, node);

			expect(probe).not.toHaveBeenCalled();
		});
	});

	it('skips disabled nodes', async () => {
		const node = makeNode({ disabled: true });
		const result = await buildSetupRequests(context, node);
		expect(result).toHaveLength(0);
	});

	it('skips nodes without a name', async () => {
		const node = makeNode({ name: '' });
		const result = await buildSetupRequests(context, node);
		expect(result).toHaveLength(0);
	});

	it('detects credential types from node description', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);

		const node = makeNode();
		const result = await buildSetupRequests(context, node);

		expect(result).toHaveLength(1);
		expect(result[0].credentialType).toBe('slackApi');
		expect(result[0].existingCredentials).toEqual([{ id: 'cred-1', name: 'My Slack' }]);
	});

	it('falls back to node description credentials when getNodeCredentialTypes returns empty', async () => {
		// Simulate production: getNodeCredentialTypes is available but returns []
		// (e.g. node lookup miss in the adapter). The fallback should still detect
		// credentials from the node description.
		(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
			.fn()
			.mockResolvedValue([]);
		(context.credentialService.list as Mock).mockResolvedValue([]);

		const node = makeNode();
		const result = await buildSetupRequests(context, node);

		expect(result).toHaveLength(1);
		expect(result[0].credentialType).toBe('slackApi');
		expect(result[0].needsAction).toBe(true);
	});

	it('falls back to node description credentials when getNodeCredentialTypes throws', async () => {
		(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
			.fn()
			.mockRejectedValue(new Error('Node lookup failed'));
		(context.credentialService.list as Mock).mockResolvedValue([]);

		const node = makeNode();
		const result = await buildSetupRequests(context, node);

		expect(result).toHaveLength(1);
		expect(result[0].credentialType).toBe('slackApi');
		expect(result[0].needsAction).toBe(true);
	});

	it('excludes credentials whose displayOptions do not match current parameters', async () => {
		(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
			.fn()
			.mockResolvedValue([]);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [
				{ name: 'httpSslAuth', displayOptions: { show: { provideSslCertificates: [true] } } },
			],
		});
		(context.credentialService.list as Mock).mockResolvedValue([]);

		const node = makeNode({ type: 'n8n-nodes-base.httpRequest', typeVersion: 4.4 });
		const result = await buildSetupRequests(context, node);

		// displayOptions require provideSslCertificates=true, but it's not set
		expect(result.find((r) => r.credentialType === 'httpSslAuth')).toBeUndefined();
	});

	it('includes credentials whose displayOptions match current parameters', async () => {
		(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
			.fn()
			.mockResolvedValue([]);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [
				{ name: 'httpSslAuth', displayOptions: { show: { provideSslCertificates: [true] } } },
			],
		});
		(context.credentialService.list as Mock).mockResolvedValue([]);

		const node = makeNode({
			type: 'n8n-nodes-base.httpRequest',
			typeVersion: 4.4,
			parameters: { provideSslCertificates: true },
		});
		const result = await buildSetupRequests(context, node);

		expect(result.find((r) => r.credentialType === 'httpSslAuth')).toBeDefined();
	});

	it('resolves dynamic credential from genericAuthType parameter', async () => {
		(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
			.fn()
			.mockResolvedValue([]);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [
				{ name: 'httpSslAuth', displayOptions: { show: { provideSslCertificates: [true] } } },
			],
		});
		(context.credentialService.list as Mock).mockResolvedValue([]);

		const node = makeNode({
			type: 'n8n-nodes-base.httpRequest',
			typeVersion: 4.4,
			parameters: {
				authentication: 'genericCredentialType',
				genericAuthType: 'httpQueryAuth',
				url: 'https://api.example.com',
			},
		});
		const result = await buildSetupRequests(context, node);

		expect(result).toHaveLength(1);
		expect(result[0].credentialType).toBe('httpQueryAuth');
		expect(result[0].needsAction).toBe(true);
	});

	it('resolves dynamic credential from predefinedCredentialType parameter', async () => {
		(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
			.fn()
			.mockResolvedValue([]);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [],
		});
		(context.credentialService.list as Mock).mockResolvedValue([]);

		const node = makeNode({
			type: 'n8n-nodes-base.httpRequest',
			typeVersion: 4.4,
			parameters: {
				authentication: 'predefinedCredentialType',
				nodeCredentialType: 'openWeatherMapApi',
				url: 'https://api.openweathermap.org',
			},
		});
		const result = await buildSetupRequests(context, node);

		expect(result).toHaveLength(1);
		expect(result[0].credentialType).toBe('openWeatherMapApi');
		expect(result[0].needsAction).toBe(true);
	});

	it('sets needsAction=true when no credential is set', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);

		const node = makeNode();
		const result = await buildSetupRequests(context, node);

		expect(result[0].needsAction).toBe(true);
	});

	it('sets needsAction=false when credential is set and test passes', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as Mock).mockResolvedValue({
			success: true,
		});

		const node = makeNode({
			credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
		});
		const result = await buildSetupRequests(context, node);

		expect(result[0].needsAction).toBe(false);
	});

	it('keeps AI Gateway-managed credentials complete without testing them', async () => {
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'googlePalmApi' }],
		});
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Gemini', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);

		const node = makeNode({
			name: 'Gemini',
			type: 'n8n-nodes-base.lmChatGoogleGemini',
			credentials: {
				googlePalmApi: { id: null, name: '', __aiGatewayManaged: true },
			} as unknown as NodeJSON['credentials'],
		});
		const result = await buildSetupRequests(context, node);

		expect(result[0].isAutoApplied).toBeFalsy();
		expect(result[0].needsAction).toBe(false);
		expect(result[0].node.credentials?.googlePalmApi).toEqual({
			id: null,
			name: '',
			__aiGatewayManaged: true,
		});
		expect(context.credentialService.test).not.toHaveBeenCalled();
	});

	it('keeps needsAction=false when a bound stored credential fails its live test', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as Mock).mockResolvedValue({
			success: false,
			message: 'Invalid token',
		});

		const node = makeNode({
			credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
		});
		const result = await buildSetupRequests(context, node);

		expect(result[0].needsAction).toBe(false);
		expect(result[0].credentialTestResult).toEqual({ success: false, message: 'Invalid token' });
	});

	it('sets needsAction=true when the bound credential id is not a stored credential', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as Mock).mockResolvedValue({
			success: false,
			message: 'Credential not found',
		});

		const node = makeNode({
			credentials: { slackApi: { id: 'cred-gone', name: 'Imported Slack' } },
		});
		const result = await buildSetupRequests(context, node);

		expect(result[0].needsAction).toBe(true);
	});

	it('sets needsAction=true when parameter issues exist', async () => {
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [],
			properties: [{ name: 'resource', displayName: 'Resource', type: 'string' }],
		});
		(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = vi
			.fn()
			.mockResolvedValue({
				resource: ['Parameter "resource" is required'],
			});

		const node = makeNode();
		const result = await buildSetupRequests(context, node);

		expect(result).toHaveLength(1);
		expect(result[0].needsAction).toBe(true);
		expect(result[0].parameterIssues).toBeDefined();
	});

	it('reports credentialNeedsAction only for the credential slot itself', async () => {
		// A connected credential with an unfilled parameter needs action, but not about the
		// service — skipping that card must not be read as "I don't want this credential".
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as Mock).mockResolvedValue({ success: true });
		(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = vi
			.fn()
			.mockResolvedValue({ resource: ['Parameter "resource" is required'] });

		const withCredential = await buildSetupRequests(
			context,
			makeNode({ credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } } }),
		);
		expect(withCredential[0].needsAction).toBe(true);
		expect(withCredential[0].credentialNeedsAction).toBeFalsy();

		const withoutCredential = await buildSetupRequests(context, makeNode());
		expect(withoutCredential[0].credentialNeedsAction).toBe(true);
	});

	it('auto-applies the only credential when node has none', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);

		const node = makeNode();
		const result = await buildSetupRequests(context, node);

		expect(result[0].isAutoApplied).toBe(true);
		expect(result[0].existingCredentials?.[0].id).toBe('cred-1');
	});

	it('does not auto-apply a sole generic auth credential', async () => {
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'httpBearerAuth' }],
		});
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-bearer', name: 'Bearer Auth account', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);

		const node = makeNode({
			name: 'MCP Client',
			type: '@n8n/n8n-nodes-langchain.mcpClientTool',
			parameters: {
				authentication: 'bearerAuth',
			},
		});
		const result = await buildSetupRequests(context, node);

		expect(result[0].isAutoApplied).toBeFalsy();
		expect(result[0].node.credentials?.httpBearerAuth).toBeUndefined();
		expect(result[0].existingCredentials).toEqual([
			{ id: 'cred-bearer', name: 'Bearer Auth account' },
		]);
		expect(result[0].needsAction).toBe(true);
		// No credential was bound, so no test was run either.
		expect(context.credentialService.test).not.toHaveBeenCalled();
	});

	it('does not auto-apply when multiple credentials of the same type exist', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-2', name: 'Newer Slack', updatedAt: '2025-06-01T00:00:00.000Z' },
			{ id: 'cred-1', name: 'Older Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);

		const node = makeNode();
		const result = await buildSetupRequests(context, node);

		expect(result[0].isAutoApplied).toBeFalsy();
		expect(result[0].node.credentials?.slackApi).toBeUndefined();
		expect(result[0].existingCredentials).toHaveLength(2);
		expect(result[0].needsAction).toBe(true);
		// No credential was picked, so no test was run either.
		expect(context.credentialService.test).not.toHaveBeenCalled();
	});

	it('sets isAutoApplied=false when node already has credential', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);

		const node = makeNode({
			credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
		});
		const result = await buildSetupRequests(context, node);

		expect(result[0].isAutoApplied).toBeFalsy();
	});

	it('auto-applies the n8n Connect credential when no stored one exists and the type is gateway-supported', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([]);
		(
			context.credentialService as unknown as { isAiGatewayCredentialType: Mock }
		).isAiGatewayCredentialType = vi.fn().mockResolvedValue(true);

		const node = makeNode();
		const result = await buildSetupRequests(context, node);

		expect(result[0].isAutoApplied).toBe(true);
		expect(result[0].node.credentials?.slackApi).toEqual({
			id: null,
			name: 'Gateway credits',
			__aiGatewayManaged: true,
		});
	});

	it('does not fire the n8n Connect auto-apply when the type is not gateway-supported', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([]);
		(
			context.credentialService as unknown as { isAiGatewayCredentialType: Mock }
		).isAiGatewayCredentialType = vi.fn().mockResolvedValue(false);

		const node = makeNode();
		const result = await buildSetupRequests(context, node);

		expect(result[0].isAutoApplied).toBeFalsy();
		expect(result[0].node.credentials?.slackApi).toBeUndefined();
	});

	it('prefers a stored credential over n8n Connect when the user already has one', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(
			context.credentialService as unknown as { isAiGatewayCredentialType: Mock }
		).isAiGatewayCredentialType = vi.fn().mockResolvedValue(true);

		const node = makeNode();
		const result = await buildSetupRequests(context, node);

		// User has a stored credential of this type — it wins; the managed
		// gateway is never applied over a saved key.
		expect(result[0].isAutoApplied).toBe(true);
		expect(result[0].node.credentials?.slackApi).toEqual({
			id: 'cred-1',
			name: 'My Slack',
		});
	});

	it('respects an explicit stored credential on the node even when gateway supports the type', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(
			context.credentialService as unknown as { isAiGatewayCredentialType: Mock }
		).isAiGatewayCredentialType = vi.fn().mockResolvedValue(true);

		const node = makeNode({
			credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
		});
		const result = await buildSetupRequests(context, node);

		expect(result[0].isAutoApplied).toBeFalsy();
		expect(result[0].node.credentials?.slackApi).toEqual({
			id: 'cred-1',
			name: 'My Slack',
		});
	});

	it('does not emit assignment telemetry at proposal (build) time', async () => {
		const track = vi.fn();
		(context.credentialService.list as Mock).mockResolvedValue([]);
		(
			context.credentialService as unknown as { isAiGatewayCredentialType: Mock }
		).isAiGatewayCredentialType = vi.fn().mockResolvedValue(true);
		(context as unknown as { trackTelemetry: Mock }).trackTelemetry = track;

		await buildSetupRequests(context, makeNode());

		// Attribution fires when the credential is actually written to the node
		// (applyCredentialsToNode), not when it is proposed here.
		expect(track).not.toHaveBeenCalled();
	});

	it('does not auto-apply n8n Connect when a stored credential exists for the type', async () => {
		const track = vi.fn();
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		const isAiGatewayCredentialType = vi.fn().mockResolvedValue(true);
		(
			context.credentialService as unknown as { isAiGatewayCredentialType: Mock }
		).isAiGatewayCredentialType = isAiGatewayCredentialType;
		(context as unknown as { trackTelemetry: Mock }).trackTelemetry = track;

		await buildSetupRequests(context, makeNode());

		// Stored key wins — the gateway lookup is skipped and nothing is tracked
		// at proposal time.
		expect(isAiGatewayCredentialType).not.toHaveBeenCalled();
		expect(track).not.toHaveBeenCalled();
	});

	it('uses credential cache to avoid duplicate fetches', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);

		const cache = createCredentialCache();
		const node1 = makeNode({ name: 'Slack 1', id: 'n1' });
		const node2 = makeNode({ name: 'Slack 2', id: 'n2' });

		await buildSetupRequests(context, node1, undefined, cache);
		await buildSetupRequests(context, node2, undefined, cache);

		// list should only be called once due to caching
		expect(context.credentialService.list).toHaveBeenCalledTimes(1);
	});

	it('forwards workflowId to credentialService.list so candidates match save-time scope', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([]);

		const node = makeNode();
		await buildSetupRequests(context, node, undefined, undefined, 'wf-1');

		expect(context.credentialService.list).toHaveBeenCalledWith({
			type: 'slackApi',
			workflowId: 'wf-1',
		});
	});

	it('omits workflowId from credentialService.list when not provided', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([]);

		const node = makeNode();
		await buildSetupRequests(context, node);

		expect(context.credentialService.list).toHaveBeenCalledWith({ type: 'slackApi' });
	});

	it('cache discriminates by workflowId so a shared cache stays correct across workflows', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([]);

		const cache = createCredentialCache();
		const node = makeNode();

		await buildSetupRequests(context, node, undefined, cache, 'wf-1');
		await buildSetupRequests(context, node, undefined, cache, 'wf-1');
		expect(context.credentialService.list).toHaveBeenCalledTimes(1);

		await buildSetupRequests(context, node, undefined, cache, 'wf-2');
		expect(context.credentialService.list).toHaveBeenCalledTimes(2);
		expect(context.credentialService.list).toHaveBeenLastCalledWith({
			type: 'slackApi',
			workflowId: 'wf-2',
		});
	});

	it('does not generate credential request for HTTP Request with auth=none and stale node.credentials', async () => {
		(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
			.fn()
			.mockResolvedValue([]);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [
				{
					name: 'httpHeaderAuth',
					displayOptions: { show: { authentication: ['genericCredentialType'] } },
				},
			],
		});
		(context.credentialService.list as Mock).mockResolvedValue([]);

		const node = makeNode({
			name: 'HTTP Request',
			type: 'n8n-nodes-base.httpRequest',
			typeVersion: 4.4,
			parameters: { authentication: 'none', url: 'https://api.example.com' },
			credentials: { httpHeaderAuth: { id: 'old-cred', name: 'Stale Header Auth' } },
		});
		const result = await buildSetupRequests(context, node);

		expect(result.find((r) => r.credentialType === 'httpHeaderAuth')).toBeUndefined();
	});

	it('fallback: displayOptions filtering takes priority over stale node.credentials', async () => {
		// Remove getNodeCredentialTypes to force fallback path
		delete (context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes;
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [
				{
					name: 'httpHeaderAuth',
					displayOptions: { show: { authentication: ['genericCredentialType'] } },
				},
			],
		});
		(context.credentialService.list as Mock).mockResolvedValue([]);

		const node = makeNode({
			name: 'HTTP Request',
			type: 'n8n-nodes-base.httpRequest',
			typeVersion: 4.4,
			parameters: { authentication: 'none', url: 'https://api.example.com' },
			credentials: { httpHeaderAuth: { id: 'old-cred', name: 'Stale Header Auth' } },
		});
		const result = await buildSetupRequests(context, node);

		expect(result.find((r) => r.credentialType === 'httpHeaderAuth')).toBeUndefined();
	});

	it('fallback: node with assigned credentials matching description is still detected', async () => {
		(context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes = vi
			.fn()
			.mockResolvedValue([]);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);

		const node = makeNode({
			credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
		});
		const result = await buildSetupRequests(context, node);

		expect(result.find((r) => r.credentialType === 'slackApi')).toBeDefined();
	});

	it('fallback: node.credentials with types not in description are excluded', async () => {
		// Remove getNodeCredentialTypes to force fallback path
		delete (context.nodeService as unknown as Record<string, unknown>).getNodeCredentialTypes;
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);

		const node = makeNode({
			credentials: {
				slackApi: { id: 'cred-1', name: 'My Slack' },
				httpHeaderAuth: { id: 'stale', name: 'Stale Auth' },
			},
		});
		const result = await buildSetupRequests(context, node);

		expect(result.find((r) => r.credentialType === 'slackApi')).toBeDefined();
		expect(result.find((r) => r.credentialType === 'httpHeaderAuth')).toBeUndefined();
	});

	it('treats placeholder values as parameter issues', async () => {
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [],
			properties: [{ name: 'email', displayName: 'Email', type: 'string' }],
		});

		const node = makeNode({
			parameters: { email: '<__PLACEHOLDER_VALUE__your_email__>' },
		});
		const result = await buildSetupRequests(context, node);

		expect(result).toHaveLength(1);
		expect(result[0].parameterIssues).toBeDefined();
		expect(result[0].parameterIssues!.email).toEqual(
			expect.arrayContaining([expect.stringContaining('your_email')]),
		);
	});

	it('adds placeholder issue even when param already has validation issues', async () => {
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [],
			properties: [{ name: 'email', displayName: 'Email', type: 'string', required: true }],
		});
		(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = vi
			.fn()
			.mockResolvedValue({ email: ['Parameter "Email" is required'] });

		const node = makeNode({
			parameters: { email: '<__PLACEHOLDER_VALUE__your_email__>' },
		});
		const result = await buildSetupRequests(context, node);

		expect(result).toHaveLength(1);
		const issues = result[0].parameterIssues!.email;
		expect(issues).toHaveLength(2);
		expect(issues).toEqual(
			expect.arrayContaining([
				'Parameter "Email" is required',
				expect.stringContaining('your_email'),
			]),
		);
	});
});

// ---------------------------------------------------------------------------
// analyzeWorkflow
// ---------------------------------------------------------------------------

describe('analyzeWorkflow', () => {
	let context: InstanceAiContext;

	beforeEach(() => {
		context = createMockContext();
	});

	it('returns empty array for workflow with no actionable nodes', async () => {
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
			makeWorkflowJSON([makeNode({ name: 'NoOp', type: 'n8n-nodes-base.noOp' })]),
		);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [],
		});

		const result = await analyzeWorkflow(context, 'wf-1');
		expect(result).toHaveLength(0);
	});

	it('includes nodes with credential types', async () => {
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
			makeWorkflowJSON([makeNode()]),
		);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});
		(context.credentialService.list as Mock).mockResolvedValue([]);

		const result = await analyzeWorkflow(context, 'wf-1');
		expect(result).toHaveLength(1);
		expect(result[0].credentialType).toBe('slackApi');
	});

	it('hides credential-only requests whose credential is already set and tests OK', async () => {
		const node = makeNode({
			credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
		});
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(makeWorkflowJSON([node]));
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as Mock).mockResolvedValue({ success: true });

		const result = await analyzeWorkflow(context, 'wf-1');

		expect(result).toHaveLength(0);
	});

	it('drops credential-only requests whose bound stored credential fails its test', async () => {
		const node = makeNode({
			credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
		});
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(makeWorkflowJSON([node]));
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as Mock).mockResolvedValue({
			success: false,
			message: 'Invalid token',
		});

		const result = await analyzeWorkflow(context, 'wf-1');

		expect(result).toHaveLength(0);
	});

	it('keeps settled failing-test requests when includeSettled is set', async () => {
		const node = makeNode({
			credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
		});
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(makeWorkflowJSON([node]));
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as Mock).mockResolvedValue({
			success: false,
			message: 'Invalid token',
		});

		const result = await analyzeWorkflow(context, 'wf-1', undefined, { includeSettled: true });

		expect(result).toHaveLength(1);
		expect(result[0].needsAction).toBe(false);
		expect(result[0].credentialTestResult).toEqual({ success: false, message: 'Invalid token' });
	});

	it('keeps credential-only requests whose bound credential id is not stored', async () => {
		const node = makeNode({
			credentials: { slackApi: { id: 'cred-gone', name: 'Imported Slack' } },
		});
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(makeWorkflowJSON([node]));
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as Mock).mockResolvedValue({
			success: false,
			message: 'Credential not found',
		});

		const result = await analyzeWorkflow(context, 'wf-1');

		expect(result).toHaveLength(1);
		expect(result[0].needsAction).toBe(true);
	});

	it('hides settled trigger requests whose credential is already valid', async () => {
		const trigger = makeNode({
			name: 'Webhook',
			type: 'n8n-nodes-base.webhook',
			id: 'n-trigger',
			credentials: { httpHeaderAuth: { id: 'cred-1', name: 'My Auth' } },
		});
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
			makeWorkflowJSON([trigger]),
		);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: ['trigger'],
			credentials: [{ name: 'httpHeaderAuth' }],
			webhooks: [{}],
		});
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Auth', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as Mock).mockResolvedValue({ success: true });

		const result = await analyzeWorkflow(context, 'wf-1');

		expect(result).toHaveLength(0);

		const settled = await analyzeWorkflow(context, 'wf-1', undefined, { includeSettled: true });

		expect(settled).toHaveLength(1);
		expect(settled[0].isTrigger).toBe(true);
		expect(settled[0].isTestable).toBe(true);
		expect(settled[0].needsAction).toBe(false);
	});

	it('keeps requests with parameter issues regardless of credential validity', async () => {
		const node = makeNode({
			credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } },
		});
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(makeWorkflowJSON([node]));
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
			properties: [{ name: 'resource', displayName: 'Resource', type: 'string' }],
		});
		(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = vi
			.fn()
			.mockResolvedValue({
				resource: ['Parameter "resource" is required'],
			});
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as Mock).mockResolvedValue({ success: true });

		const result = await analyzeWorkflow(context, 'wf-1');

		expect(result).toHaveLength(1);
		expect(result[0].needsAction).toBe(true);
		expect(result[0].parameterIssues).toBeDefined();
	});

	it('sorts by execution order with triggers first', async () => {
		const trigger = makeNode({
			name: 'Webhook',
			type: 'n8n-nodes-base.webhook',
			id: 'n-trigger',
			position: [100, 100] as [number, number],
		});
		const action = makeNode({
			name: 'Slack',
			type: 'n8n-nodes-base.slack',
			id: 'n-action',
			position: [400, 100] as [number, number],
		});
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
			makeWorkflowJSON([action, trigger], {
				Webhook: { main: [[{ node: 'Slack', type: 'main', index: 0 }]] },
			}),
		);
		(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
			if (type === 'n8n-nodes-base.webhook') {
				return await Promise.resolve({
					group: ['trigger'],
					credentials: [],
					webhooks: [{}],
				});
			}
			return await Promise.resolve({ group: [], credentials: [{ name: 'slackApi' }] });
		});
		(context.credentialService.list as Mock).mockResolvedValue([]);

		const result = await analyzeWorkflow(context, 'wf-1');

		// Trigger should come first (execution order)
		const names = result.map((r) => r.node.name);
		expect(names.indexOf('Webhook')).toBeLessThan(names.indexOf('Slack'));
	});

	describe('subnodeRootNode stamping for sub-nodes', () => {
		it('stamps subnodeRootNode on every sub-node connected to an agent', async () => {
			const agent = makeNode({
				name: 'Agent',
				type: '@n8n/n8n-nodes-langchain.agent',
				typeVersion: 1,
				id: 'agent-1',
			});
			const model = makeNode({
				name: 'OpenAI Model',
				type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
				typeVersion: 1,
				id: 'model-1',
			});
			const memory = makeNode({
				name: 'Memory',
				type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
				typeVersion: 1,
				id: 'memory-1',
			});
			(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
				makeWorkflowJSON([agent, model, memory], {
					'OpenAI Model': {
						ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]],
					},
					Memory: { ai_memory: [[{ node: 'Agent', type: 'ai_memory', index: 0 }]] },
				}),
			);
			(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
				if (type === '@n8n/n8n-nodes-langchain.lmChatOpenAi') {
					return await Promise.resolve({
						group: [],
						credentials: [{ name: 'openAiApi' }],
					});
				}
				if (type === '@n8n/n8n-nodes-langchain.memoryBufferWindow') {
					return await Promise.resolve({ group: [], credentials: [] });
				}
				return await Promise.resolve({ group: [], credentials: [] });
			});
			(context.credentialService.list as Mock).mockResolvedValue([]);

			const result = await analyzeWorkflow(context, 'wf-1');

			const modelReq = result.find((r) => r.node.name === 'OpenAI Model');
			expect(modelReq?.subnodeRootNode).toEqual({
				name: 'Agent',
				type: '@n8n/n8n-nodes-langchain.agent',
				typeVersion: 1,
				id: 'agent-1',
			});
		});

		it('stamps the topmost root node for transitively nested sub-agents', async () => {
			const agent = makeNode({
				name: 'Agent',
				type: '@n8n/n8n-nodes-langchain.agent',
				typeVersion: 1,
				id: 'agent-1',
			});
			const tool = makeNode({
				name: 'Tool',
				type: '@n8n/n8n-nodes-langchain.toolWorkflow',
				typeVersion: 1,
				id: 'tool-1',
			});
			const subModel = makeNode({
				name: 'Sub Model',
				type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
				typeVersion: 1,
				id: 'sub-model-1',
			});
			(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
				makeWorkflowJSON([agent, tool, subModel], {
					Tool: { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] },
					'Sub Model': {
						ai_languageModel: [[{ node: 'Tool', type: 'ai_languageModel', index: 0 }]],
					},
				}),
			);
			(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
				if (type === '@n8n/n8n-nodes-langchain.lmChatOpenAi') {
					return await Promise.resolve({
						group: [],
						credentials: [{ name: 'openAiApi' }],
					});
				}
				return await Promise.resolve({ group: [], credentials: [] });
			});
			(context.credentialService.list as Mock).mockResolvedValue([]);

			const result = await analyzeWorkflow(context, 'wf-1');

			const subModelReq = result.find((r) => r.node.name === 'Sub Model');
			expect(subModelReq?.subnodeRootNode?.name).toBe('Agent');
		});

		it('keeps subnodeRootNode metadata even when the root node itself produced no setup request', async () => {
			const agent = makeNode({
				name: 'Agent',
				type: '@n8n/n8n-nodes-langchain.agent',
				typeVersion: 1,
				id: 'agent-1',
			});
			const model = makeNode({
				name: 'Model',
				type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
				typeVersion: 1,
				id: 'model-1',
			});
			(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
				makeWorkflowJSON([agent, model], {
					Model: { ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]] },
				}),
			);
			(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
				if (type === '@n8n/n8n-nodes-langchain.lmChatOpenAi') {
					return await Promise.resolve({
						group: [],
						credentials: [{ name: 'openAiApi' }],
					});
				}
				// Agent itself returns no credentials → no setup request for it.
				return await Promise.resolve({ group: [], credentials: [] });
			});
			(context.credentialService.list as Mock).mockResolvedValue([]);

			const result = await analyzeWorkflow(context, 'wf-1');

			expect(result.find((r) => r.node.name === 'Agent')).toBeUndefined();
			const modelReq = result.find((r) => r.node.name === 'Model');
			expect(modelReq?.subnodeRootNode).toEqual({
				name: 'Agent',
				type: '@n8n/n8n-nodes-langchain.agent',
				typeVersion: 1,
				id: 'agent-1',
			});
		});

		it('attaches a multi-root sub-node to the first root node in execution order', async () => {
			const trigger = makeNode({
				name: 'Trigger',
				type: 'n8n-nodes-base.webhook',
				id: 'trigger-1',
				position: [0, 0] as [number, number],
			});
			const agentA = makeNode({
				name: 'Agent A',
				type: '@n8n/n8n-nodes-langchain.agent',
				typeVersion: 1,
				id: 'agent-a',
				position: [200, 0] as [number, number],
			});
			const agentB = makeNode({
				name: 'Agent B',
				type: '@n8n/n8n-nodes-langchain.agent',
				typeVersion: 1,
				id: 'agent-b',
				position: [400, 0] as [number, number],
			});
			const sharedModel = makeNode({
				name: 'Shared Model',
				type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
				typeVersion: 1,
				id: 'shared-1',
			});
			(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
				makeWorkflowJSON([trigger, agentA, agentB, sharedModel], {
					Trigger: {
						main: [
							[
								{ node: 'Agent A', type: 'main', index: 0 },
								{ node: 'Agent B', type: 'main', index: 0 },
							],
						],
					},
					'Shared Model': {
						ai_languageModel: [
							[
								{ node: 'Agent A', type: 'ai_languageModel', index: 0 },
								{ node: 'Agent B', type: 'ai_languageModel', index: 0 },
							],
						],
					},
				}),
			);
			(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
				if (type === 'n8n-nodes-base.webhook') {
					return await Promise.resolve({
						group: ['trigger'],
						credentials: [],
						webhooks: [{}],
					});
				}
				if (type === '@n8n/n8n-nodes-langchain.lmChatOpenAi') {
					return await Promise.resolve({
						group: [],
						credentials: [{ name: 'openAiApi' }],
					});
				}
				return await Promise.resolve({ group: [], credentials: [] });
			});
			(context.credentialService.list as Mock).mockResolvedValue([]);

			const result = await analyzeWorkflow(context, 'wf-1');

			const sharedReq = result.find((r) => r.node.name === 'Shared Model');
			// Agent A executes first (left-most), so it claims the shared sub-node.
			expect(sharedReq?.subnodeRootNode?.name).toBe('Agent A');
		});

		it('does not classify a sub-node by following a Main edge', async () => {
			const trigger = makeNode({
				name: 'Trigger',
				type: 'n8n-nodes-base.webhook',
				id: 'trigger-1',
				position: [0, 0] as [number, number],
			});
			const httpAction = makeNode({
				name: 'HTTP',
				type: 'n8n-nodes-base.httpRequest',
				id: 'http-1',
				position: [200, 0] as [number, number],
			});
			(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
				makeWorkflowJSON([trigger, httpAction], {
					Trigger: { main: [[{ node: 'HTTP', type: 'main', index: 0 }]] },
				}),
			);
			(context.nodeService.getDescription as Mock).mockImplementation(async (type: string) => {
				if (type === 'n8n-nodes-base.webhook') {
					return await Promise.resolve({
						group: ['trigger'],
						credentials: [],
						webhooks: [{}],
					});
				}
				return await Promise.resolve({
					group: [],
					credentials: [{ name: 'httpBasicAuth' }],
				});
			});
			(context.credentialService.list as Mock).mockResolvedValue([]);

			const result = await analyzeWorkflow(context, 'wf-1');

			const httpReq = result.find((r) => r.node.name === 'HTTP');
			expect(httpReq?.subnodeRootNode).toBeUndefined();
		});
	});
});

// ---------------------------------------------------------------------------
// applyNodeChanges
// ---------------------------------------------------------------------------

describe('applyNodeChanges', () => {
	let context: InstanceAiContext;

	beforeEach(() => {
		context = createMockContext();
	});

	it('applies credentials and parameters atomically', async () => {
		const wfJson = makeWorkflowJSON([
			makeNode({ name: 'Slack', id: 'n1' }),
			makeNode({ name: 'Gmail', id: 'n2', type: 'n8n-nodes-base.gmail' }),
		]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as Mock).mockImplementation(
			async (id: string) => await Promise.resolve({ id, name: `Cred ${id}` }),
		);
		(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
			id: 'wf-1',
			versionId: 'v-1',
			checksum: 'checksum-1',
		});

		const result = await applyNodeChanges(
			context,
			'wf-1',
			{ Slack: { slackApi: 'cred-1' } },
			{ Gmail: { resource: 'message' } },
		);

		expect(result.applied).toContain('Slack');
		expect(result.applied).toContain('Gmail');
		expect(result.failed).toHaveLength(0);
		// Single save for both changes
		expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledTimes(1);
	});

	it('reports failures when credential is not found', async () => {
		const wfJson = makeWorkflowJSON([makeNode({ name: 'Slack', id: 'n1' })]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as Mock).mockResolvedValue(undefined);
		(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
			id: 'wf-1',
			versionId: 'v-1',
			checksum: 'checksum-1',
		});

		const result = await applyNodeChanges(context, 'wf-1', {
			Slack: { slackApi: 'nonexistent' },
		});

		expect(result.failed).toHaveLength(1);
		expect(result.failed[0].nodeName).toBe('Slack');
	});

	it('emits source instance-ai-confirmed and kind own when a credential is picked among several', async () => {
		const track = vi.fn();
		(context as unknown as { trackTelemetry: Mock }).trackTelemetry = track;
		const wfJson = makeWorkflowJSON([makeNode({ name: 'Slack', id: 'n1' })]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as Mock).mockResolvedValue({ id: 'cred-1', name: 'My Slack' });
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack' },
			{ id: 'cred-2', name: 'Other Slack' },
		]);
		(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);

		await applyNodeChanges(context, 'wf-1', { Slack: { slackApi: 'cred-1' } });

		expect(track).toHaveBeenCalledWith('Node credential assigned', {
			credential_type: 'slackApi',
			node_type: 'n8n-nodes-base.slack',
			workflow_id: 'wf-1',
			credential_id: 'cred-1',
			thread_id: 'thread-1',
			credential_kind: 'own',
			source: 'instance-ai-confirmed',
		});
	});

	it('keeps source instance-ai-confirmed when a sole generic auth credential is applied', async () => {
		const track = vi.fn();
		(context as unknown as { trackTelemetry: Mock }).trackTelemetry = track;
		const wfJson = makeWorkflowJSON([
			makeNode({
				name: 'HTTP Request',
				id: 'n1',
				type: 'n8n-nodes-base.httpRequest',
			}),
		]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as Mock).mockResolvedValue({
			id: 'cred-bearer',
			name: 'Bearer Auth account',
		});
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-bearer', name: 'Bearer Auth account' },
		]);
		(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);

		await applyNodeChanges(context, 'wf-1', {
			'HTTP Request': { httpBearerAuth: 'cred-bearer' },
		});

		expect(track).toHaveBeenCalledWith('Node credential assigned', {
			credential_type: 'httpBearerAuth',
			node_type: 'n8n-nodes-base.httpRequest',
			workflow_id: 'wf-1',
			credential_id: 'cred-bearer',
			thread_id: 'thread-1',
			credential_kind: 'own',
			source: 'instance-ai-confirmed',
		});
	});

	it("emits source instance-ai-auto and kind own when the user's sole stored credential is applied", async () => {
		const track = vi.fn();
		(context as unknown as { trackTelemetry: Mock }).trackTelemetry = track;
		const wfJson = makeWorkflowJSON([makeNode({ name: 'Slack', id: 'n1' })]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as Mock).mockResolvedValue({ id: 'cred-1', name: 'My Slack' });
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack' },
		]);
		(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);

		await applyNodeChanges(context, 'wf-1', { Slack: { slackApi: 'cred-1' } });

		expect(track).toHaveBeenCalledWith('Node credential assigned', {
			credential_type: 'slackApi',
			node_type: 'n8n-nodes-base.slack',
			workflow_id: 'wf-1',
			credential_id: 'cred-1',
			thread_id: 'thread-1',
			credential_kind: 'own',
			source: 'instance-ai-auto',
		});
	});

	it('keeps source instance-ai-confirmed when the node already had a credential', async () => {
		const track = vi.fn();
		(context as unknown as { trackTelemetry: Mock }).trackTelemetry = track;
		const wfJson = makeWorkflowJSON([
			makeNode({ name: 'Slack', id: 'n1', credentials: { slackApi: { id: 'old', name: 'Old' } } }),
		]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as Mock).mockResolvedValue({ id: 'cred-1', name: 'My Slack' });
		// A sole stored credential would otherwise classify as auto — the prior
		// credential on the node short-circuits that to confirmed.
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack' },
		]);
		(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);

		await applyNodeChanges(context, 'wf-1', { Slack: { slackApi: 'cred-1' } });

		expect(track).toHaveBeenCalledWith(
			'Node credential assigned',
			expect.objectContaining({ source: 'instance-ai-confirmed', credential_id: 'cred-1' }),
		);
		expect(context.credentialService.list).not.toHaveBeenCalled();
	});

	it('tags source instance-ai-auto when n8n Connect is applied and the user has no credential of their own', async () => {
		const track = vi.fn();
		(context as unknown as { trackTelemetry: Mock }).trackTelemetry = track;
		const wfJson = makeWorkflowJSON([makeNode({ name: 'Slack', id: 'n1' })]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(
			context.credentialService as unknown as { isAiGatewayCredentialType: Mock }
		).isAiGatewayCredentialType = vi.fn().mockResolvedValue(true);
		(context.credentialService.list as Mock).mockResolvedValue([]);
		(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);

		await applyNodeChanges(context, 'wf-1', { Slack: { slackApi: AI_GATEWAY_MANAGED_TAG } });

		expect(track).toHaveBeenCalledWith(
			'Node credential assigned',
			expect.objectContaining({
				credential_type: 'slackApi',
				credential_id: null,
				thread_id: 'thread-1',
				credential_kind: 'n8n_connect',
				source: 'instance-ai-auto',
			}),
		);
	});

	it('tags source instance-ai-confirmed when n8n Connect is chosen despite the user having a stored credential', async () => {
		const track = vi.fn();
		(context as unknown as { trackTelemetry: Mock }).trackTelemetry = track;
		const wfJson = makeWorkflowJSON([makeNode({ name: 'Slack', id: 'n1' })]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(
			context.credentialService as unknown as { isAiGatewayCredentialType: Mock }
		).isAiGatewayCredentialType = vi.fn().mockResolvedValue(true);
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack' },
		]);
		(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined);

		await applyNodeChanges(context, 'wf-1', { Slack: { slackApi: AI_GATEWAY_MANAGED_TAG } });

		expect(track).toHaveBeenCalledWith(
			'Node credential assigned',
			expect.objectContaining({
				credential_id: null,
				credential_kind: 'n8n_connect',
				source: 'instance-ai-confirmed',
			}),
		);
	});

	it('rolls back applied nodes on save failure', async () => {
		const wfJson = makeWorkflowJSON([makeNode({ name: 'Slack', id: 'n1' })]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as Mock).mockResolvedValue({
			id: 'cred-1',
			name: 'My Slack',
		});
		(context.workflowService.updateFromWorkflowJSON as Mock).mockRejectedValue(
			new Error('DB error'),
		);

		const result = await applyNodeChanges(context, 'wf-1', {
			Slack: { slackApi: 'cred-1' },
		});

		expect(result.applied).toHaveLength(0);
		expect(result.failed).toHaveLength(1);
		expect(result.failed[0].error).toContain('Failed to save workflow');
	});

	it('strips credentials not valid for the current parameters', async () => {
		const node = makeNode({
			name: 'HTTP Request',
			type: 'n8n-nodes-base.httpRequest',
			typeVersion: 4.4,
			parameters: { authentication: 'none', url: 'https://api.example.com' },
			credentials: { httpHeaderAuth: { id: 'stale', name: 'Stale Header Auth' } },
		});
		const wfJson = makeWorkflowJSON([node]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [
				{
					name: 'httpHeaderAuth',
					displayOptions: { show: { authentication: ['genericCredentialType'] } },
				},
			],
		});
		(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
			id: 'wf-1',
			versionId: 'v-1',
			checksum: 'checksum-1',
		});

		await applyNodeChanges(context, 'wf-1');

		const calls = (context.workflowService.updateFromWorkflowJSON as Mock).mock.calls as Array<
			[string, WorkflowJSON]
		>;
		const savedJson = calls[0][1];
		const savedNode = savedJson.nodes.find((n) => n.name === 'HTTP Request');
		expect(savedNode?.credentials).toBeUndefined();
	});

	it('preserves just-applied credentials even if description would exclude them', async () => {
		const node = makeNode({
			name: 'HTTP Request',
			type: 'n8n-nodes-base.httpRequest',
			typeVersion: 4.4,
			parameters: { authentication: 'none' },
		});
		const wfJson = makeWorkflowJSON([node]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [],
		});
		(context.credentialService.get as Mock).mockResolvedValue({
			id: 'cred-1',
			name: 'My Header Auth',
		});
		(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
			id: 'wf-1',
			versionId: 'v-1',
			checksum: 'checksum-1',
		});

		await applyNodeChanges(context, 'wf-1', {
			'HTTP Request': { httpHeaderAuth: 'cred-1' },
		});

		const calls = (context.workflowService.updateFromWorkflowJSON as Mock).mock.calls as Array<
			[string, WorkflowJSON]
		>;
		const savedJson = calls[0][1];
		const savedNode = savedJson.nodes.find((n) => n.name === 'HTTP Request');
		expect(savedNode?.credentials).toEqual({
			httpHeaderAuth: { id: 'cred-1', name: 'My Header Auth' },
		});
	});

	it('applies AI Gateway-managed credentials from the setup tag', async () => {
		const node = makeNode({ name: 'Gemini', type: 'n8n-nodes-base.lmChatGoogleGemini' });
		const wfJson = makeWorkflowJSON([node]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
			id: 'wf-1',
			versionId: 'v-1',
			checksum: 'checksum-1',
		});

		const result = await applyNodeChanges(context, 'wf-1', {
			Gemini: { googlePalmApi: AI_GATEWAY_MANAGED_TAG },
		});

		expect(result.applied).toEqual(['Gemini']);
		expect(result.failed).toEqual([]);
		expect(context.credentialService.get).not.toHaveBeenCalled();
		expect(node.credentials).toEqual({
			googlePalmApi: { id: null, name: '', __aiGatewayManaged: true },
		});
	});

	it('persists merged parameter values into the saved workflow JSON', async () => {
		// Regression: the FE was sending `nodeParameters` correctly on apply but
		// the saved workflow still had the original (empty) parameter value.
		const node = makeNode({
			name: 'HTTP Request',
			type: 'n8n-nodes-base.httpRequest',
			typeVersion: 4.2,
			parameters: { method: 'GET', url: '', authentication: 'none' },
		});
		const wfJson = makeWorkflowJSON([node]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [],
		});
		(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
			id: 'wf-1',
			versionId: 'v-1',
			checksum: 'checksum-1',
		});

		const result = await applyNodeChanges(context, 'wf-1', undefined, {
			'HTTP Request': { url: 'https://example.com/api' },
		});

		expect(result.applied).toContain('HTTP Request');
		expect(result.failed).toHaveLength(0);

		const calls = (context.workflowService.updateFromWorkflowJSON as Mock).mock.calls as Array<
			[string, WorkflowJSON]
		>;
		expect(calls).toHaveLength(1);
		const savedNode = calls[0][1].nodes.find((n) => n.name === 'HTTP Request');
		expect(savedNode?.parameters).toEqual({
			method: 'GET',
			url: 'https://example.com/api',
			authentication: 'none',
		});
	});

	it('reports unknown setup node keys instead of silently ignoring them', async () => {
		const node = makeNode({
			name: 'Send Rain Alert',
			type: 'n8n-nodes-base.slack',
			typeVersion: 2.2,
			parameters: {
				channelId: { __rl: true, mode: 'id', value: '' },
			},
		});
		const wfJson = makeWorkflowJSON([node]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [],
		});
		(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
			id: 'wf-1',
			versionId: 'v-1',
			checksum: 'checksum-1',
		});

		const result = await applyNodeChanges(context, 'wf-1', undefined, {
			channelId: { __rl: true, mode: 'name', value: '#berlin-weather-rain' },
		});

		expect(result.applied).toHaveLength(0);
		expect(result.failed).toEqual([
			{
				nodeName: 'channelId',
				error: 'Node "channelId" was not found in the workflow',
			},
		]);

		const calls = (context.workflowService.updateFromWorkflowJSON as Mock).mock.calls as Array<
			[string, WorkflowJSON]
		>;
		const savedNode = calls[0][1].nodes.find((n) => n.name === 'Send Rain Alert');
		expect(savedNode?.parameters).toEqual({
			channelId: { __rl: true, mode: 'id', value: '' },
		});
	});

	it('keeps credentials matching description displayOptions', async () => {
		const node = makeNode({
			name: 'HTTP Request',
			type: 'n8n-nodes-base.httpRequest',
			typeVersion: 4.4,
			parameters: {
				authentication: 'genericCredentialType',
				genericAuthType: 'httpHeaderAuth',
			},
			credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Header Auth' } },
		});
		const wfJson = makeWorkflowJSON([node]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [],
		});
		(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
			id: 'wf-1',
			versionId: 'v-1',
			checksum: 'checksum-1',
		});

		await applyNodeChanges(context, 'wf-1');

		const calls = (context.workflowService.updateFromWorkflowJSON as Mock).mock.calls as Array<
			[string, WorkflowJSON]
		>;
		const savedJson = calls[0][1];
		const savedNode = savedJson.nodes.find((n) => n.name === 'HTTP Request');
		expect(savedNode?.credentials).toEqual({
			httpHeaderAuth: { id: 'cred-1', name: 'Header Auth' },
		});
	});
});

// ---------------------------------------------------------------------------
// buildCompletedReport
// ---------------------------------------------------------------------------

describe('buildCompletedReport', () => {
	it('builds report from credentials and parameters', () => {
		const report = buildCompletedReport(
			{ Slack: { slackApi: 'cred-1' } },
			{ Slack: { channel: '#general' } },
		);

		expect(report).toHaveLength(1);
		expect(report[0]).toEqual({
			nodeName: 'Slack',
			credentialType: 'slackApi',
			parametersSet: ['channel'],
		});
	});

	it('reports parameter-only nodes', () => {
		const report = buildCompletedReport(undefined, { Gmail: { resource: 'message' } });

		expect(report).toHaveLength(1);
		expect(report[0]).toEqual({
			nodeName: 'Gmail',
			parametersSet: ['resource'],
		});
	});

	it('filters completed report to nodes confirmed as applied', () => {
		const report = buildCompletedReport(
			undefined,
			{
				Slack: { channelId: '#general' },
				channelId: { __rl: true, value: '#general' },
			},
			['Slack'],
		);

		expect(report).toEqual([{ nodeName: 'Slack', parametersSet: ['channelId'] }]);
	});

	it('returns empty array when nothing was applied', () => {
		const report = buildCompletedReport(undefined, undefined);
		expect(report).toHaveLength(0);
	});
});

// ---------------------------------------------------------------------------
// stripStaleCredentialsFromWorkflow
// ---------------------------------------------------------------------------

describe('stripStaleCredentialsFromWorkflow', () => {
	let context: InstanceAiContext;

	beforeEach(() => {
		context = createMockContext();
	});

	it('removes credential entries that no longer match the node parameters', async () => {
		const node = makeNode({
			name: 'HTTP Request',
			type: 'n8n-nodes-base.httpRequest',
			typeVersion: 4.4,
			parameters: { authentication: 'none', url: 'https://api.example.com' },
			credentials: { httpHeaderAuth: { id: 'stale', name: 'Stale Header Auth' } },
		});
		const wfJson = makeWorkflowJSON([node]);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [
				{
					name: 'httpHeaderAuth',
					displayOptions: { show: { authentication: ['genericCredentialType'] } },
				},
			],
		});

		await stripStaleCredentialsFromWorkflow(context, wfJson);

		expect(wfJson.nodes[0].credentials).toBeUndefined();
	});

	it('keeps credential entries that match the current parameters', async () => {
		const node = makeNode({
			name: 'HTTP Request',
			type: 'n8n-nodes-base.httpRequest',
			typeVersion: 4.4,
			parameters: {
				authentication: 'genericCredentialType',
				genericAuthType: 'httpHeaderAuth',
			},
			credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Header Auth' } },
		});
		const wfJson = makeWorkflowJSON([node]);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [],
		});

		await stripStaleCredentialsFromWorkflow(context, wfJson);

		expect(wfJson.nodes[0].credentials).toEqual({
			httpHeaderAuth: { id: 'cred-1', name: 'Header Auth' },
		});
	});

	it('strips per-node — clean nodes are unaffected, stale nodes are scrubbed', async () => {
		const cleanNode = makeNode({
			name: 'OpenRouter',
			type: 'n8n-nodes-base.httpRequest',
			typeVersion: 4.4,
			parameters: {
				authentication: 'genericCredentialType',
				genericAuthType: 'httpHeaderAuth',
			},
			credentials: { httpHeaderAuth: { id: 'cred-1', name: 'OpenRouter Auth' } },
		});
		const staleNode = makeNode({
			name: 'Joke API',
			id: 'node-2',
			type: 'n8n-nodes-base.httpRequest',
			typeVersion: 4.4,
			parameters: { authentication: 'none', url: 'https://icanhazdadjoke.com/' },
			credentials: { httpHeaderAuth: { id: 'cred-1', name: 'OpenRouter Auth' } },
		});
		const wfJson = makeWorkflowJSON([cleanNode, staleNode]);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [
				{
					name: 'httpHeaderAuth',
					displayOptions: { show: { authentication: ['genericCredentialType'] } },
				},
			],
		});

		await stripStaleCredentialsFromWorkflow(context, wfJson);

		expect(wfJson.nodes[0].credentials).toEqual({
			httpHeaderAuth: { id: 'cred-1', name: 'OpenRouter Auth' },
		});
		expect(wfJson.nodes[1].credentials).toBeUndefined();
	});

	it('is a no-op for nodes without credentials', async () => {
		const node = makeNode({
			parameters: { authentication: 'none' },
		});
		const wfJson = makeWorkflowJSON([node]);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [],
		});

		await stripStaleCredentialsFromWorkflow(context, wfJson);

		expect(wfJson.nodes[0].credentials).toBeUndefined();
		expect(context.nodeService.getDescription).not.toHaveBeenCalled();
	});
});

// ---------------------------------------------------------------------------
// applyNodeCredentials — credential ownership revalidation (IDOR boundary)
//
// `nodeCredentials` arrives in the confirmation payload from the client and
// can carry arbitrary credential IDs. Each ID must be resolved through
// `context.credentialService.get()`, which the adapter binds to the
// requesting user — IDs the user can't access throw and are recorded as
// failures rather than being silently written to the workflow.
// ---------------------------------------------------------------------------

describe('applyNodeCredentials — credential ownership revalidation', () => {
	let context: InstanceAiContext;

	beforeEach(() => {
		context = createMockContext();
	});

	it('applies a credential the user is allowed to read', async () => {
		const node = makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' });
		const wfJson = makeWorkflowJSON([node]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as Mock).mockResolvedValue({
			id: 'cred-mine',
			name: 'My Slack',
			type: 'slackApi',
		});

		const result = await applyNodeCredentials(context, 'wf-1', {
			Slack: { slackApi: 'cred-mine' },
		});

		expect(context.credentialService.get).toHaveBeenCalledWith('cred-mine');
		expect(node.credentials).toEqual({ slackApi: { id: 'cred-mine', name: 'My Slack' } });
		expect(result.applied).toEqual(['Slack']);
		expect(result.failed).toEqual([]);
		expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledWith('wf-1', wfJson);
	});

	it('applies AI Gateway-managed credentials from the setup tag', async () => {
		const node = makeNode({ name: 'Gemini', type: 'n8n-nodes-base.lmChatGoogleGemini' });
		const wfJson = makeWorkflowJSON([node]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);

		const result = await applyNodeCredentials(context, 'wf-1', {
			Gemini: { googlePalmApi: AI_GATEWAY_MANAGED_TAG },
		});

		expect(context.credentialService.get).not.toHaveBeenCalled();
		expect(node.credentials).toEqual({
			googlePalmApi: { id: null, name: '', __aiGatewayManaged: true },
		});
		expect(result.applied).toEqual(['Gemini']);
		expect(result.failed).toEqual([]);
	});

	it('does not write a credential the user cannot access', async () => {
		const node = makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' });
		const wfJson = makeWorkflowJSON([node]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as Mock).mockRejectedValue(
			new Error('Credential with ID "cred-other" could not be found.'),
		);

		const result = await applyNodeCredentials(context, 'wf-1', {
			Slack: { slackApi: 'cred-other' },
		});

		expect(node.credentials).toBeUndefined();
		expect(result.applied).toEqual([]);
		expect(result.failed).toHaveLength(1);
		expect(result.failed[0]).toMatchObject({ nodeName: 'Slack' });
		expect(result.failed[0].error).toContain('cred-other');
	});

	it('applies allowed credentials and rejects forbidden ones in a mixed payload', async () => {
		const slack = makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' });
		const github = makeNode({ name: 'GitHub', type: 'n8n-nodes-base.github', id: 'node-2' });
		const wfJson = makeWorkflowJSON([slack, github]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as Mock).mockImplementation(async (credId: string) => {
			if (credId === 'cred-mine') {
				return await Promise.resolve({ id: 'cred-mine', name: 'My Slack', type: 'slackApi' });
			}
			throw new Error(`Credential with ID "${credId}" could not be found.`);
		});

		const result = await applyNodeCredentials(context, 'wf-1', {
			Slack: { slackApi: 'cred-mine' },
			GitHub: { githubApi: 'cred-other' },
		});

		expect(slack.credentials).toEqual({ slackApi: { id: 'cred-mine', name: 'My Slack' } });
		expect(github.credentials).toBeUndefined();
		expect(result.applied).toEqual(['Slack']);
		expect(result.failed).toHaveLength(1);
		expect(result.failed[0]).toMatchObject({ nodeName: 'GitHub' });
	});

	it('marks a node as failed when any of its credentials are rejected', async () => {
		const node = makeNode({ name: 'HTTP', type: 'n8n-nodes-base.httpRequest' });
		const wfJson = makeWorkflowJSON([node]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as Mock).mockImplementation(async (credId: string) => {
			if (credId === 'cred-mine') {
				return await Promise.resolve({ id: 'cred-mine', name: 'Auth', type: 'httpHeaderAuth' });
			}
			throw new Error(`Credential with ID "${credId}" could not be found.`);
		});

		const result = await applyNodeCredentials(context, 'wf-1', {
			HTTP: { httpHeaderAuth: 'cred-mine', httpQueryAuth: 'cred-other' },
		});

		// Allowed cred is still written, but the node is reported as failed and
		// not in `applied` so callers know one of its credentials was rejected.
		expect(node.credentials).toEqual({ httpHeaderAuth: { id: 'cred-mine', name: 'Auth' } });
		expect(result.applied).toEqual([]);
		expect(result.failed).toHaveLength(1);
		expect(result.failed[0].error).toContain('cred-other');
	});

	it('reports credentials for nodes not present in the workflow', async () => {
		const node = makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' });
		const wfJson = makeWorkflowJSON([node]);
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson);

		const result = await applyNodeCredentials(context, 'wf-1', {
			GhostNode: { slackApi: 'cred-other' },
		});

		expect(context.credentialService.get).not.toHaveBeenCalled();
		expect(result.applied).toEqual([]);
		expect(result.failed).toEqual([
			{
				nodeName: 'GhostNode',
				error: 'Node "GhostNode" was not found in the workflow',
			},
		]);
	});
});

describe('applyCredentialHints', () => {
	const request = (nodeName: string, credentialType?: string): SetupRequest => ({
		node: {
			name: nodeName,
			type: 'n8n-nodes-base.httpRequest',
			typeVersion: 4.4,
			parameters: {},
			position: [0, 0],
			id: `${nodeName}-id`,
		},
		...(credentialType ? { credentialType } : {}),
		isTrigger: false,
	});

	const hint = (overrides: Partial<CredentialHintInput> = {}): CredentialHintInput => ({
		template: { headers: { Authorization: 'Key {{api_key}}' } },
		placeholders: [{ name: 'api_key', title: 'API key' }],
		...overrides,
	});

	it('attaches a type-wide hint to Templated Custom Auth requests only', () => {
		const requests = [
			request('Call fal.ai', 'httpTemplatedCustomAuth'),
			request('Post to Slack', 'slackApi'),
			request('No credential'),
		];

		applyCredentialHints(requests, [hint({ suggestedName: 'fal.ai API Key' })]);

		expect(requests[0].setupHint).toMatchObject({ suggestedName: 'fal.ai API Key' });
		expect(requests[0].setupHint).not.toHaveProperty('nodeName');
		expect(requests[1].setupHint).toBeUndefined();
		expect(requests[2].setupHint).toBeUndefined();
	});

	it('prefers a node-scoped hint over a type-wide one', () => {
		const requests = [
			request('Call fal.ai', 'httpTemplatedCustomAuth'),
			request('Call replicate', 'httpTemplatedCustomAuth'),
		];

		applyCredentialHints(requests, [
			hint({ suggestedName: 'Fallback' }),
			hint({ nodeName: 'Call replicate', suggestedName: 'Replicate API Key' }),
		]);

		expect(requests[0].setupHint).toMatchObject({ suggestedName: 'Fallback' });
		expect(requests[1].setupHint).toMatchObject({ suggestedName: 'Replicate API Key' });
	});

	it('stamps the service host derived from the node URL, expressions included', () => {
		const requests = [request('Call fal.ai', 'httpTemplatedCustomAuth')];
		requests[0].node.parameters = { url: '=https://queue.fal.run/fal-ai/flux/{{ $json.id }}' };

		applyCredentialHints(requests, [hint()]);

		expect(requests[0].setupHint?.serviceHost).toBe('queue.fal.run');
		expect(requests[0].setupHint?.serviceOrigin).toBe('https://queue.fal.run');
	});

	it('stamps each request with its own node host from a shared type-wide hint', () => {
		const requests = [
			request('Call Pexels', 'httpTemplatedCustomAuth'),
			request('Call Apify', 'httpTemplatedCustomAuth'),
		];
		requests[0].node.parameters = { url: 'https://api.pexels.com/v1/search' };
		requests[1].node.parameters = { url: 'https://api.apify.com/v2/acts' };
		const shared = hint();

		applyCredentialHints(requests, [shared]);

		expect(requests[0].setupHint?.serviceHost).toBe('api.pexels.com');
		expect(requests[1].setupHint?.serviceHost).toBe('api.apify.com');
		expect(shared).not.toHaveProperty('serviceHost');
		expect(requests[0].setupHint?.serviceOrigin).toBe('https://api.pexels.com');
		expect(requests[1].setupHint?.serviceOrigin).toBe('https://api.apify.com');
		expect(shared).not.toHaveProperty('serviceOrigin');
	});

	it('omits service identity when the node URL is not derivable', () => {
		const requests = [request('Call fal.ai', 'httpTemplatedCustomAuth')];
		requests[0].node.parameters = { url: '={{ $json.url }}' };

		applyCredentialHints(requests, [hint({ testUrl: 'https://fal.run/v1/models' })]);

		expect(requests[0].setupHint).toBeDefined();
		expect(requests[0].setupHint).not.toHaveProperty('serviceHost');
		expect(requests[0].setupHint).not.toHaveProperty('serviceOrigin');
	});
});

// ---------------------------------------------------------------------------
// buildSetupRequests — Templated Custom Auth service identity
// ---------------------------------------------------------------------------

describe('buildSetupRequests — Templated Custom Auth service identity', () => {
	let context: InstanceAiContext;

	const templatedNode = (url?: string) =>
		makeNode({
			name: 'Call API',
			type: 'n8n-nodes-base.httpRequest',
			parameters: {
				authentication: 'genericCredentialType',
				genericAuthType: 'httpTemplatedCustomAuth',
				...(url ? { url } : {}),
			},
		});

	const withHosts = (hosts: Record<string, string | null>) => {
		(context.credentialService as unknown as Record<string, unknown>).getTemplatedCredentialHosts =
			vi.fn().mockResolvedValue(hosts);
	};

	beforeEach(() => {
		context = createMockContext();
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [],
		});
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-pexels', name: 'Pexels API' },
		]);
		(context.credentialService.test as Mock).mockResolvedValue({ success: true });
	});

	it("drops another service's credential from the offer", async () => {
		withHosts({ 'cred-pexels': 'api.pexels.com' });

		const result = await buildSetupRequests(
			context,
			templatedNode('https://api.apify.com/v2/acts/run'),
		);

		expect(result[0].existingCredentials).toBeUndefined();
		expect(result[0].isAutoApplied).toBeFalsy();
		expect(result[0].needsAction).toBe(true);
	});

	it('offers a same-service credential but never auto-applies it', async () => {
		withHosts({ 'cred-pexels': 'api.pexels.com' });

		const result = await buildSetupRequests(
			context,
			templatedNode('https://api.pexels.com/v1/search?query=x'),
		);

		expect(result[0].existingCredentials).toEqual([{ id: 'cred-pexels', name: 'Pexels API' }]);
		expect(result[0].isAutoApplied).toBeFalsy();
		expect(result[0].node.credentials).toBeUndefined();
	});

	it('matches subdomains of the same service', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-fal', name: 'fal.ai API Key' },
		]);
		withHosts({ 'cred-fal': 'fal.run' });

		const result = await buildSetupRequests(
			context,
			templatedNode('=https://queue.fal.run/fal-ai/kling/{{ $json.id }}'),
		);

		expect(result[0].existingCredentials).toEqual([{ id: 'cred-fal', name: 'fal.ai API Key' }]);
	});

	it('drops untagged legacy credentials', async () => {
		withHosts({ 'cred-pexels': null });

		const result = await buildSetupRequests(
			context,
			templatedNode('https://api.pexels.com/v1/search'),
		);

		expect(result[0].existingCredentials).toBeUndefined();
	});

	it('offers nothing when the node host cannot be derived', async () => {
		withHosts({ 'cred-pexels': 'api.pexels.com' });

		const result = await buildSetupRequests(context, templatedNode('={{ $json.url }}'));

		expect(result[0].existingCredentials).toBeUndefined();
	});

	it('leaves dedicated credential types unfiltered', async () => {
		withHosts({});
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-slack', name: 'My Slack' },
		]);
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});

		const result = await buildSetupRequests(context, makeNode());

		expect(result[0].existingCredentials).toEqual([{ id: 'cred-slack', name: 'My Slack' }]);
	});
});

// ---------------------------------------------------------------------------
// preferNewCredentialTypes (INS-361)
// ---------------------------------------------------------------------------

describe('setup analysis for credentials the user asked to create fresh', () => {
	let context: InstanceAiContext;

	beforeEach(() => {
		context = createMockContext();
		(context.nodeService.getDescription as Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});
		(context.credentialService.test as Mock).mockResolvedValue({ success: true });
	});

	it('does not auto-apply the sole existing credential and flags the request', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'Slack account', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);

		const result = await buildSetupRequests(
			context,
			makeNode(),
			undefined,
			undefined,
			undefined,
			new Set(['slackApi']),
		);

		expect(result[0].isAutoApplied).toBeFalsy();
		expect(result[0].preferNewCredential).toBe(true);
		expect(result[0].node.credentials).toBeUndefined();
		expect(result[0].needsAction).toBe(true);
		// The user can still change their mind — candidates stay listed.
		expect(result[0].existingCredentials).toEqual([{ id: 'cred-1', name: 'Slack account' }]);
	});

	it('does not auto-apply n8n credits either', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([]);
		(context.credentialService as unknown as Record<string, unknown>).isAiGatewayCredentialType = vi
			.fn()
			.mockResolvedValue(true);

		const result = await buildSetupRequests(
			context,
			makeNode(),
			undefined,
			undefined,
			undefined,
			new Set(['slackApi']),
		);

		expect(result[0].isAutoApplied).toBeFalsy();
		expect(result[0].preferNewCredential).toBe(true);
		expect(result[0].node.credentials).toBeUndefined();
	});

	it('leaves other credential types auto-applying as usual', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'Slack account', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);

		const result = await buildSetupRequests(
			context,
			makeNode(),
			undefined,
			undefined,
			undefined,
			new Set(['telegramApi']),
		);

		expect(result[0].isAutoApplied).toBe(true);
		expect(result[0].preferNewCredential).toBeUndefined();
	});

	// INS-1271: a bound credential (e.g. one whose token the user is replacing)
	// must not settle the slot — the card renders it unbound and actionable.
	it('renders a bound credential slot as unbound and needing action', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'Slack account', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		const node = makeNode({
			credentials: { slackApi: { id: 'cred-1', name: 'Slack account' } },
		} as Partial<NodeJSON>);

		const result = await buildSetupRequests(
			context,
			node,
			undefined,
			undefined,
			undefined,
			new Set(['slackApi']),
		);

		expect(result[0].preferNewCredential).toBe(true);
		expect(result[0].node.credentials).toBeUndefined();
		expect(result[0].needsAction).toBe(true);
		expect(result[0].credentialNeedsAction).toBe(true);
		// The bound credential is the one being replaced — don't test or report it.
		expect(result[0].credentialTestResult).toBeUndefined();
		expect(context.credentialService.test).not.toHaveBeenCalled();
		expect(result[0].existingCredentials).toEqual([{ id: 'cred-1', name: 'Slack account' }]);
	});

	it('keeps a bound credential settled for types not asked fresh', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'Slack account', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		const node = makeNode({
			credentials: { slackApi: { id: 'cred-1', name: 'Slack account' } },
		} as Partial<NodeJSON>);

		const result = await buildSetupRequests(
			context,
			node,
			undefined,
			undefined,
			undefined,
			new Set(['telegramApi']),
		);

		expect(result[0].node.credentials).toEqual({
			slackApi: { id: 'cred-1', name: 'Slack account' },
		});
		expect(result[0].credentialNeedsAction).toBeUndefined();
	});

	// INS-1271: a slot bound to a credential the resume just applied is settled
	// even when the credential list view lags the new credential (scoped
	// discovery, e.g. eval allowlist pins) — re-reporting it as unconfigured
	// made the agent re-ask for a token the user had just saved.
	it('settles a bound slot whose credential id was just applied even if unlisted', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-old', name: 'Slack account', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		const node = makeNode({
			credentials: { slackApi: { id: 'cred-new', name: 'Slack account 2' } },
		} as Partial<NodeJSON>);

		const result = await buildSetupRequests(
			context,
			node,
			undefined,
			undefined,
			undefined,
			undefined,
			new Set(['cred-new']),
		);

		expect(result[0].credentialNeedsAction).toBeUndefined();
		expect(result[0].needsAction).toBeFalsy();
	});

	it('keeps an unlisted bound slot needing action when its id was not applied', async () => {
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-old', name: 'Slack account', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		const node = makeNode({
			credentials: { slackApi: { id: 'cred-new', name: 'Slack account 2' } },
		} as Partial<NodeJSON>);

		const result = await buildSetupRequests(
			context,
			node,
			undefined,
			undefined,
			undefined,
			undefined,
			new Set(['cred-other']),
		);

		expect(result[0].credentialNeedsAction).toBe(true);
		expect(result[0].needsAction).toBe(true);
	});

	it('hands analyzeWorkflow an unfilled card where the default pre-fills one', async () => {
		(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(
			makeWorkflowJSON([makeNode()]),
		);
		(context.credentialService.list as Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'Slack account', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);

		const [byDefault] = await analyzeWorkflow(context, 'wf-1');
		expect(byDefault.isAutoApplied).toBe(true);
		expect(byDefault.node.credentials).toEqual({
			slackApi: { id: 'cred-1', name: 'Slack account' },
		});

		const [asked] = await analyzeWorkflow(context, 'wf-1', undefined, {
			preferNewCredentialTypes: ['slackApi'],
		});
		expect(asked.preferNewCredential).toBe(true);
		expect(asked.isAutoApplied).toBeFalsy();
		expect(asked.node.credentials).toBeUndefined();
		expect(asked.needsAction).toBe(true);
	});
});
