import { createTeamProject, mockInstance, testDb } from '@n8n/backend-test-utils';
import type { Project, User } from '@n8n/db';
import { ProjectRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';

import { CredentialTypes } from '@/credential-types';
import { EventService } from '@/events/event.service';
import {
	buildImportPackageBuffer,
	serializedWorkflow,
	serializedWorkflowWithCredential,
} from '@/modules/n8n-packages/__tests__/fixtures/package-fixtures';
import { TarPackageWriter } from '@/modules/n8n-packages/io/tar/tar-package-writer';
import { Telemetry } from '@/telemetry';

import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
import { getVariableByKey } from '../shared/db/variables';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/';

mockInstance(Telemetry);

const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });

let owner: User;
let ownerPersonalProject: Project;
let authOwnerAgent: SuperAgentTest;

beforeAll(async () => {
	const credentialTypesMock = mockInstance(CredentialTypes);
	credentialTypesMock.recognizes.mockReturnValue(true);

	// Register node types so imports pass the default fail-on-missing-node-type check.
	await utils.initNodeTypes();

	owner = await createOwnerWithApiKey();
	Container.get(InstanceSettings).markAsLeader();
	ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
		owner.id,
	);
});

beforeEach(async () => {
	await testDb.truncate([
		'WorkflowEntity',
		'SharedWorkflow',
		'CredentialsEntity',
		'SharedCredentials',
		'Variables',
	]);
	authOwnerAgent = testServer.publicApiAgentFor(owner);
});

const testWithAPIKey = (method: 'post', url: string, apiKey: string | null) => async () => {
	void authOwnerAgent.set({ 'X-N8N-API-KEY': apiKey });
	const response = await authOwnerAgent[method](url);
	expect(response.statusCode).toBe(401);
};

async function buildImportPackage(
	options: { variable?: { name: string; value: string } } = {},
): Promise<Buffer> {
	const writer = new TarPackageWriter();
	const wfId = 'wf-http-source';
	const variable = options.variable
		? { ...options.variable, target: `variables/${options.variable.name}` }
		: undefined;
	writer.writeFile(
		'manifest.json',
		JSON.stringify({
			packageFormatVersion: '1',
			exportedAt: new Date().toISOString(),
			sourceN8nVersion: '1.0.0',
			sourceId: 'http-integration-source',
			workflows: [{ id: wfId, name: 'HTTP Imported', target: `workflows/${wfId}` }],
			...(variable
				? {
						variables: [{ id: 'var-http-source', name: variable.name, target: variable.target }],
						requirements: {
							variables: [{ name: variable.name, usedByWorkflows: [wfId] }],
						},
					}
				: {}),
		}),
	);
	writer.writeDirectory(`workflows/${wfId}`);
	writer.writeFile(
		`workflows/${wfId}/workflow.json`,
		JSON.stringify({
			id: wfId,
			name: 'HTTP Imported',
			nodes: [
				{
					id: 'manual-trigger',
					name: 'Manual Trigger',
					type: 'n8n-nodes-base.manualTrigger',
					typeVersion: 1,
					position: [0, 0],
					parameters: {},
				},
			],
			connections: {},
			versionId: 'wire-version-id',
			parentFolderId: null,
			isPublished: false,
			isArchived: false,
		}),
	);

	if (variable) {
		writer.writeDirectory(variable.target);
		writer.writeFile(
			`${variable.target}/variable.json`,
			JSON.stringify({ name: variable.name, type: 'string', value: variable.value }),
		);
	}

	const stream = writer.finalize();
	const chunks: Buffer[] = [];
	for await (const chunk of stream) {
		chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBuffer));
	}
	return Buffer.concat(chunks);
}

describe('POST /n8n-packages/import', () => {
	test('should fail due to missing API Key', testWithAPIKey('post', '/n8n-packages/import', null));

	test(
		'should fail due to invalid API Key',
		testWithAPIKey('post', '/n8n-packages/import', 'abcXYZ'),
	);

	test('rejects unsupported Content-Type', async () => {
		const response = await authOwnerAgent
			.post('/n8n-packages/import')
			.set('Content-Type', 'application/json')
			.send({ not: 'a tar' });

		expect(response.statusCode).toBe(415);
	});

	test('rejects multipart request without package file', async () => {
		const response = await authOwnerAgent.post('/n8n-packages/import').field('projectId', '');

		expect(response.statusCode).toBe(400);
	});

	test('rejects import when the API key lacks workflow:import scope', async () => {
		const limitedOwner = await createOwnerWithApiKey({ scopes: ['workflow:export'] });
		const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
		const tarBuffer = await buildImportPackage();

		const response = await testServer
			.publicApiAgentFor(limitedOwner)
			.post('/n8n-packages/import')
			.field('workflowConflictPolicy', 'fail')
			.attach('package', tarBuffer, 'import.n8np');

		expect(response.statusCode).toBe(403);
		expect(emitSpy).toHaveBeenCalledWith(
			'n8n-package-import-failed',
			expect.objectContaining({ reason: 'access-denied' }),
		);
	});

	test('rejects import into a project the caller has no access to', async () => {
		const projectOwner = await createOwnerWithApiKey();
		const project = await createTeamProject('Someone else project', projectOwner);
		const outsider = await createMemberWithApiKey();
		const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
		const tarBuffer = await buildImportPackage();

		const response = await testServer
			.publicApiAgentFor(outsider)
			.post('/n8n-packages/import')
			.field('projectId', project.id)
			.field('workflowConflictPolicy', 'fail')
			.attach('package', tarBuffer, 'import.n8np');

		expect(response.statusCode).toBe(403);
		expect(emitSpy).toHaveBeenCalledWith(
			'n8n-package-import-failed',
			expect.objectContaining({ reason: 'access-denied', projectId: project.id }),
		);
	});

	test('rejects import when the projectId does not exist', async () => {
		const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
		const tarBuffer = await buildImportPackage();

		const response = await authOwnerAgent
			.post('/n8n-packages/import')
			.field('projectId', 'does-not-exist')
			.field('workflowConflictPolicy', 'fail')
			.attach('package', tarBuffer, 'import.n8np');

		expect(response.statusCode).toBe(404);
		expect(emitSpy).toHaveBeenCalledWith(
			'n8n-package-import-failed',
			expect.objectContaining({ reason: 'entity-not-found', projectId: 'does-not-exist' }),
		);
	});

	test('rejects bindings keyed by an unsupported entity type', async () => {
		const tarBuffer = await buildImportPackage();

		const response = await authOwnerAgent
			.post('/n8n-packages/import')
			.field('workflowConflictPolicy', 'fail')
			// "credential" (no trailing s) is a plausible typo that must error, not silently no-op.
			.field('bindings', '{"credential":{"source":"target"}}')
			.attach('package', tarBuffer, 'import.n8np');

		expect(response.statusCode).toBe(400);
		expect(response.body.message).toContain('Unrecognized key');
		expect(response.body.message).toContain('credential');
	});

	test('imports a package and returns the rich ImportResult', async () => {
		const tarBuffer = await buildImportPackage();

		const response = await authOwnerAgent
			.post('/n8n-packages/import')
			.field('workflowConflictPolicy', 'fail')
			.field('workflowIdPolicy', 'new')
			.attach('package', tarBuffer, 'import.n8np');

		expect(response.statusCode).toBe(200);
		expect(response.body).toEqual({
			package: {
				sourceN8nVersion: '1.0.0',
				sourceId: 'http-integration-source',
				exportedAt: expect.any(String),
			},
			workflows: [
				{
					sourceWorkflowId: 'wf-http-source',
					localId: expect.any(String),
					name: 'HTTP Imported',
					projectId: ownerPersonalProject.id,
					parentFolderId: null,
					activeVersionId: null,
					publishing: { state: 'unchanged' },
					status: 'created',
				},
			],
			removedWorkflows: [],
			removedFolders: [],
			folders: [],
			projects: [],
			bindings: {
				workflows: { 'wf-http-source': expect.any(String) },
				credentials: {},
			},
			credentials: {
				matched: [],
				stubbed: [],
			},
			dataTables: {
				matched: 0,
				created: 0,
			},
			variables: {
				matched: [],
				missing: [],
				created: [],
				stubbed: [],
				updated: [],
			},
			tags: {
				matched: [],
				created: [],
				renamed: [],
				reconciled: [],
				skipped: [],
			},
		});

		expect(response.body.workflows[0].localId).not.toBe('wf-http-source');
	});

	test('creates a missing variable with the package value when variableMissingMode is omitted', async () => {
		testServer.license.enable('feat:variables');
		const tarBuffer = await buildImportPackage({
			variable: { name: 'API_URL', value: 'https://packaged.example.com' },
		});

		const response = await authOwnerAgent
			.post('/n8n-packages/import')
			.field('workflowConflictPolicy', 'fail')
			.attach('package', tarBuffer, 'import.n8np');

		expect(response.statusCode).toBe(200);
		expect(response.body.variables).toEqual({
			matched: [],
			missing: [],
			created: ['API_URL'],
			stubbed: [],
			updated: [],
		});
		const created = await getVariableByKey('API_URL');
		expect(created).toMatchObject({ value: 'https://packaged.example.com' });
	});

	test('accepts a request that supplies every documented form field', async () => {
		const tarBuffer = await buildImportPackage();

		const response = await authOwnerAgent
			.post('/n8n-packages/import')
			.field('projectId', ownerPersonalProject.id)
			.field('folderId', '')
			.field('credentialMatchingMode', 'id-only')
			.field('credentialMissingMode', 'must-preexist')
			.field('bindings', '{}')
			.field('workflowConflictPolicy', 'fail')
			.field('workflowIdPolicy', 'new')
			.field('missingNodeTypeMode', 'fail')
			.field('dataTableMatchingMode', 'by-id')
			.field('dataTableMissingMode', 'must-preexist')
			.field('dataTableSchemaConflictPolicy', 'fail')
			.field('variableMissingMode', 'create-with-value')
			.field('variableConflictPolicy', 'overwrite')
			.field('variableParentPolicy', 'project')
			.field('tagMissingMode', 'do-nothing')
			.field('tagConflictPolicy', 'fail')
			.attach('package', tarBuffer, 'import.n8np');

		expect(response.statusCode).toBe(200);
		expect(response.body.workflows[0].localId).not.toBe('wf-http-source');
	});

	test('rejects an unsupported dataTableMissingMode value', async () => {
		const tarBuffer = await buildImportPackage();

		const response = await authOwnerAgent
			.post('/n8n-packages/import')
			.field('workflowConflictPolicy', 'fail')
			.field('dataTableMissingMode', 'recreate')
			.attach('package', tarBuffer, 'import.n8np');

		expect(response.statusCode).toBe(400);
	});

	test('returns 409 with conflict metadata when a workflow already exists under fail policy', async () => {
		const firstBuffer = await buildImportPackage();

		const first = await authOwnerAgent
			.post('/n8n-packages/import')
			.field('credentialMatchingMode', 'id-only')
			.field('credentialMissingMode', 'must-preexist')
			.field('workflowConflictPolicy', 'fail')
			.attach('package', firstBuffer, 'import.n8np');
		expect(first.statusCode).toBe(200);
		const existingWorkflowId = first.body.workflows[0].localId;

		const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
		const secondBuffer = await buildImportPackage();
		const response = await authOwnerAgent
			.post('/n8n-packages/import')
			.field('credentialMatchingMode', 'id-only')
			.field('credentialMissingMode', 'must-preexist')
			.field('workflowConflictPolicy', 'fail')
			.attach('package', secondBuffer, 'import.n8np');

		expect(response.statusCode).toBe(409);
		expect(response.body).toMatchObject({
			message: expect.stringContaining('Import blocked'),
			issues: [
				{
					type: 'workflow-conflict',
					sourceWorkflowId: 'wf-http-source',
					existingWorkflowId,
					name: 'HTTP Imported',
				},
			],
		});
		expect(emitSpy).toHaveBeenCalledWith(
			'n8n-package-import-failed',
			expect.objectContaining({ reason: 'blocked' }),
		);
	});

	test('returns 422 when credential references cannot be resolved under must-preexist', async () => {
		const tarBuffer = await buildImportPackageBuffer(
			[
				serializedWorkflowWithCredential({
					id: 'wf-miss',
					name: 'Missing Credential',
					credentialId: 'non-existent-credential',
					credentialName: 'Missing',
				}),
			],
			{ sourceId: 'http-integration-credential-fail' },
		);

		const response = await authOwnerAgent
			.post('/n8n-packages/import')
			.field('workflowConflictPolicy', 'fail')
			.field('credentialMissingMode', 'must-preexist')
			.attach('package', tarBuffer, 'import.n8np');

		expect(response.statusCode).toBe(422);
		expect(response.body).toMatchObject({
			message: expect.stringContaining('Import blocked'),
			issues: [
				expect.objectContaining({
					type: 'credential-unresolved',
					kind: 'not_found',
					sourceId: 'non-existent-credential',
				}),
			],
		});
	});

	const unknownNodeTypePackage = async (sourceId: string) =>
		await buildImportPackageBuffer(
			[
				serializedWorkflow({
					id: 'wf-unknown-node',
					name: 'Unknown Node Type',
					// Published in the source, so a publish-intent policy would publish it.
					isPublished: true,
					nodes: [
						{
							id: 'unknown-node',
							name: 'Unknown Node',
							type: 'n8n-nodes-community.chatBot',
							typeVersion: 1,
							position: [0, 0],
							parameters: {},
						},
					],
				}),
			],
			{ sourceId },
		);

	test('returns 422 by default when a workflow uses an unknown node type', async () => {
		const tarBuffer = await unknownNodeTypePackage('http-integration-missing-node-type-fail');

		const response = await authOwnerAgent
			.post('/n8n-packages/import')
			.field('workflowConflictPolicy', 'fail')
			.attach('package', tarBuffer, 'import.n8np');

		expect(response.statusCode).toBe(422);
		expect(response.body).toMatchObject({
			message: expect.stringContaining('Import blocked'),
			issues: [
				{
					type: 'missing-node-type',
					nodeType: 'n8n-nodes-community.chatBot',
					typeVersion: 1,
					usedByWorkflows: ['wf-unknown-node'],
				},
			],
		});
	});

	test('honors missingNodeTypeMode=import-anyway for a package with an unknown node type', async () => {
		const tarBuffer = await unknownNodeTypePackage('http-integration-missing-node-type-anyway');

		const response = await authOwnerAgent
			.post('/n8n-packages/import')
			.field('workflowConflictPolicy', 'fail')
			.field('missingNodeTypeMode', 'import-anyway')
			.field('workflowPublishingPolicy', 'match-source')
			.attach('package', tarBuffer, 'import.n8np');

		expect(response.statusCode).toBe(200);
		expect(response.body.workflows).toHaveLength(1);
		// match-source wanted to publish it, but the missing node type blocks that.
		expect(response.body.workflows[0].publishing).toEqual({
			state: 'blocked',
			blockedReason: 'missing-node-type',
		});
		expect(response.body.workflows[0].activeVersionId).toBeNull();
	});

	test('creates stub credentials by default when references are missing', async () => {
		const tarBuffer = await buildImportPackageBuffer(
			[
				serializedWorkflowWithCredential({
					id: 'wf-stub',
					name: 'Stub Credential Workflow',
					credentialId: 'missing-credential',
					credentialName: 'Missing',
				}),
			],
			{ sourceId: 'http-integration-credential-stub' },
		);

		const response = await authOwnerAgent
			.post('/n8n-packages/import')
			.field('workflowConflictPolicy', 'fail')
			.attach('package', tarBuffer, 'import.n8np');

		expect(response.statusCode).toBe(200);
		expect(response.body.credentials).toEqual({
			matched: [],
			stubbed: ['missing-credential'],
		});
		expect(response.body.bindings.credentials).toEqual({
			'missing-credential': expect.any(String),
		});
		expect(response.body.workflows).toHaveLength(1);
	});
});
