import type { WorkflowJSON, NodeJSON } from '@n8n/workflow-sdk';

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

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

function createMockContext(overrides?: Partial<InstanceAiContext>): InstanceAiContext {
	return {
		userId: 'test-user',
		workflowService: {
			list: jest.fn(),
			get: jest.fn(),
			getAsWorkflowJSON: jest.fn(),
			createFromWorkflowJSON: jest.fn(),
			updateFromWorkflowJSON: jest.fn(),
			archive: jest.fn(),
			unarchive: jest.fn(),
			publish: jest.fn(),
			unpublish: jest.fn(),
			clearAiTemporary: jest.fn(),
			archiveIfAiTemporary: jest.fn(),
		},
		executionService: {
			list: jest.fn(),
			run: jest.fn(),
			getStatus: jest.fn(),
			getResult: jest.fn(),
			stop: jest.fn(),
			getDebugInfo: jest.fn(),
			getNodeOutput: jest.fn(),
			getResolvedNodeParameters: jest.fn(),
		},
		credentialService: {
			list: jest.fn(),
			get: jest.fn(),
			delete: jest.fn(),
			test: jest.fn(),
		},
		nodeService: {
			listAvailable: jest.fn(),
			getDescription: jest.fn(),
			listSearchable: jest.fn(),
		},
		dataTableService: {
			list: jest.fn(),
			create: jest.fn(),
			delete: jest.fn(),
			getSchema: jest.fn(),
			addColumn: jest.fn(),
			deleteColumn: jest.fn(),
			renameColumn: jest.fn(),
			queryRows: jest.fn(),
			insertRows: jest.fn(),
			updateRows: jest.fn(),
			deleteRows: jest.fn(),
		},
		...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 jest.Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});
		// Default: credential test passes (override in specific tests for failure cases)
		(context.credentialService.test as jest.Mock).mockResolvedValue({ success: true });
	});

	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 jest.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 = jest
			.fn()
			.mockResolvedValue([]);
		(context.credentialService.list as jest.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 = jest
			.fn()
			.mockRejectedValue(new Error('Node lookup failed'));
		(context.credentialService.list as jest.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 = jest
			.fn()
			.mockResolvedValue([]);
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: [],
			credentials: [
				{ name: 'httpSslAuth', displayOptions: { show: { provideSslCertificates: [true] } } },
			],
		});
		(context.credentialService.list as jest.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 = jest
			.fn()
			.mockResolvedValue([]);
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: [],
			credentials: [
				{ name: 'httpSslAuth', displayOptions: { show: { provideSslCertificates: [true] } } },
			],
		});
		(context.credentialService.list as jest.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 = jest
			.fn()
			.mockResolvedValue([]);
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: [],
			credentials: [
				{ name: 'httpSslAuth', displayOptions: { show: { provideSslCertificates: [true] } } },
			],
		});
		(context.credentialService.list as jest.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 = jest
			.fn()
			.mockResolvedValue([]);
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: [],
			credentials: [],
		});
		(context.credentialService.list as jest.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 jest.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 jest.Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as jest.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('sets needsAction=true when credential test fails', async () => {
		(context.credentialService.list as jest.Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as jest.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(true);
	});

	it('sets needsAction=true when parameter issues exist', async () => {
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: [],
			credentials: [],
			properties: [{ name: 'resource', displayName: 'Resource', type: 'string' }],
		});
		(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = jest
			.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('auto-applies the only credential when node has none', async () => {
		(context.credentialService.list as jest.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 when multiple credentials of the same type exist', async () => {
		(context.credentialService.list as jest.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 jest.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('uses credential cache to avoid duplicate fetches', async () => {
		(context.credentialService.list as jest.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 jest.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 jest.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 jest.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 = jest
			.fn()
			.mockResolvedValue([]);
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: [],
			credentials: [
				{
					name: 'httpHeaderAuth',
					displayOptions: { show: { authentication: ['genericCredentialType'] } },
				},
			],
		});
		(context.credentialService.list as jest.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 jest.Mock).mockResolvedValue({
			group: [],
			credentials: [
				{
					name: 'httpHeaderAuth',
					displayOptions: { show: { authentication: ['genericCredentialType'] } },
				},
			],
		});
		(context.credentialService.list as jest.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 = jest
			.fn()
			.mockResolvedValue([]);
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});
		(context.credentialService.list as jest.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 jest.Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});
		(context.credentialService.list as jest.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 jest.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 jest.Mock).mockResolvedValue({
			group: [],
			credentials: [],
			properties: [{ name: 'email', displayName: 'Email', type: 'string', required: true }],
		});
		(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = jest
			.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 jest.Mock).mockResolvedValue(
			makeWorkflowJSON([makeNode({ name: 'NoOp', type: 'n8n-nodes-base.noOp' })]),
		);
		(context.nodeService.getDescription as jest.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 jest.Mock).mockResolvedValue(
			makeWorkflowJSON([makeNode()]),
		);
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});
		(context.credentialService.list as jest.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 jest.Mock).mockResolvedValue(
			makeWorkflowJSON([node]),
		);
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
		});
		(context.credentialService.list as jest.Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as jest.Mock).mockResolvedValue({ success: true });

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

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

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

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

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

	it('keeps testable trigger requests even when their 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 jest.Mock).mockResolvedValue(
			makeWorkflowJSON([trigger]),
		);
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: ['trigger'],
			credentials: [{ name: 'httpHeaderAuth' }],
			webhooks: [{}],
		});
		(context.credentialService.list as jest.Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Auth', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as jest.Mock).mockResolvedValue({ success: true });

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

		expect(result).toHaveLength(1);
		expect(result[0].isTrigger).toBe(true);
		expect(result[0].isTestable).toBe(true);
		expect(result[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 jest.Mock).mockResolvedValue(
			makeWorkflowJSON([node]),
		);
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: [],
			credentials: [{ name: 'slackApi' }],
			properties: [{ name: 'resource', displayName: 'Resource', type: 'string' }],
		});
		(context.nodeService as unknown as Record<string, unknown>).getParameterIssues = jest
			.fn()
			.mockResolvedValue({
				resource: ['Parameter "resource" is required'],
			});
		(context.credentialService.list as jest.Mock).mockResolvedValue([
			{ id: 'cred-1', name: 'My Slack', updatedAt: '2025-01-01T00:00:00.000Z' },
		]);
		(context.credentialService.test as jest.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 jest.Mock).mockResolvedValue(
			makeWorkflowJSON([action, trigger], {
				Webhook: { main: [[{ node: 'Slack', type: 'main', index: 0 }]] },
			}),
		);
		(context.nodeService.getDescription as jest.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 jest.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 jest.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 jest.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 jest.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 jest.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 jest.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 jest.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 jest.Mock).mockResolvedValue(
				makeWorkflowJSON([agent, model], {
					Model: { ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]] },
				}),
			);
			(context.nodeService.getDescription as jest.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 jest.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 jest.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 jest.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 jest.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 jest.Mock).mockResolvedValue(
				makeWorkflowJSON([trigger, httpAction], {
					Trigger: { main: [[{ node: 'HTTP', type: 'main', index: 0 }]] },
				}),
			);
			(context.nodeService.getDescription as jest.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 jest.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 jest.Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as jest.Mock).mockImplementation(
			async (id: string) => await Promise.resolve({ id, name: `Cred ${id}` }),
		);
		(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);

		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 jest.Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as jest.Mock).mockResolvedValue(undefined);
		(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);

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

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

	it('rolls back applied nodes on save failure', async () => {
		const wfJson = makeWorkflowJSON([makeNode({ name: 'Slack', id: 'n1' })]);
		(context.workflowService.getAsWorkflowJSON as jest.Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as jest.Mock).mockResolvedValue({
			id: 'cred-1',
			name: 'My Slack',
		});
		(context.workflowService.updateFromWorkflowJSON as jest.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 jest.Mock).mockResolvedValue(wfJson);
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: [],
			credentials: [
				{
					name: 'httpHeaderAuth',
					displayOptions: { show: { authentication: ['genericCredentialType'] } },
				},
			],
		});
		(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);

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

		const calls = (context.workflowService.updateFromWorkflowJSON as jest.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 jest.Mock).mockResolvedValue(wfJson);
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: [],
			credentials: [],
		});
		(context.credentialService.get as jest.Mock).mockResolvedValue({
			id: 'cred-1',
			name: 'My Header Auth',
		});
		(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);

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

		const calls = (context.workflowService.updateFromWorkflowJSON as jest.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('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 jest.Mock).mockResolvedValue(wfJson);
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: [],
			credentials: [],
		});
		(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);

		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 jest.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('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 jest.Mock).mockResolvedValue(wfJson);
		(context.nodeService.getDescription as jest.Mock).mockResolvedValue({
			group: [],
			credentials: [],
		});
		(context.workflowService.updateFromWorkflowJSON as jest.Mock).mockResolvedValue(undefined);

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

		const calls = (context.workflowService.updateFromWorkflowJSON as jest.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('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 jest.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 jest.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 jest.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 jest.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 jest.Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as jest.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('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 jest.Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as jest.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 jest.Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as jest.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 jest.Mock).mockResolvedValue(wfJson);
		(context.credentialService.get as jest.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('skips 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 jest.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([]);
	});
});
