import type { SourceControlledFile } from '@n8n/api-types';
import {
	createTeamProject,
	createWorkflowWithHistory,
	mockInstance,
	testDb,
} from '@n8n/backend-test-utils';
import {
	CredentialsEntity,
	CredentialsRepository,
	GLOBAL_ADMIN_ROLE,
	type Project,
	ProjectRepository,
	SharedCredentialsRepository,
	type User,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { Cipher, InstanceSettings } from 'n8n-core';
import { readFile as fsReadFile, writeFile as fsWriteFile } from 'node:fs/promises';
import path from 'node:path';
import { v4 as uuid } from 'uuid';

import { SourceControlExportService } from '@/modules/source-control.ee/source-control-export.service.ee';
import type { ExportableCredential } from '@/modules/source-control.ee/types/exportable-credential';
import { SourceControlContext } from '@/modules/source-control.ee/types/source-control-context';

import { createCredentials } from '../shared/db/credentials';
import { assignTagToWorkflow, createTag } from '../shared/db/tags';
import { createUser } from '../shared/db/users';

// Mock file system operations
jest.mock('node:fs/promises');

describe('SourceControlExportService Integration', () => {
	let exportService: SourceControlExportService;
	let testUser: User;
	let personalProject: Project;
	let teamProject: Project;
	let exportDirectory: string;

	// Repositories
	let credentialsRepository: CredentialsRepository;
	let sharedCredentialsRepository: SharedCredentialsRepository;
	let projectRepository: ProjectRepository;

	// Mocked functions
	let mockFsWriteFile: jest.MockedFunction<typeof fsWriteFile>;
	let mockFsReadFile: jest.MockedFunction<typeof fsReadFile>;

	beforeAll(async () => {
		await testDb.init();

		// Get repositories
		credentialsRepository = Container.get(CredentialsRepository);
		sharedCredentialsRepository = Container.get(SharedCredentialsRepository);
		projectRepository = Container.get(ProjectRepository);

		// Create test user
		testUser = await createUser({ role: GLOBAL_ADMIN_ROLE });
		personalProject = await projectRepository.getPersonalProjectForUserOrFail(testUser.id);
		teamProject = await createTeamProject('Test Team Project', testUser);

		// Setup export directory for testing (no longer creating real directories)
		exportDirectory = path.join(process.cwd(), 'test-exports-' + uuid());

		// Mock the instance settings to use our test directory
		mockInstance(InstanceSettings, {
			n8nFolder: exportDirectory,
		});

		// Get the service from container (this will use real dependencies)
		exportService = Container.get(SourceControlExportService);
	});

	afterAll(async () => {
		await testDb.terminate();
		// No cleanup needed since we're not creating real directories
	});

	beforeEach(async () => {
		// Setup mocks before each test
		mockFsWriteFile = jest.mocked(fsWriteFile);
		mockFsWriteFile.mockClear();
		mockFsWriteFile.mockResolvedValue();

		mockFsReadFile = jest.mocked(fsReadFile);
		mockFsReadFile.mockClear();
		// Default to empty tags file
		mockFsReadFile.mockResolvedValue(JSON.stringify({ tags: [], mappings: [] }));
	});

	afterEach(async () => {
		// Clear test data between tests
		await testDb.truncate([
			'SharedCredentials',
			'CredentialsEntity',
			'SharedWorkflow',
			'WorkflowEntity',
			'WorkflowTagMapping',
			'TagEntity',
		]);
		// Reset mocks
		jest.clearAllMocks();
	});

	describe('exportCredentialsToWorkFolder', () => {
		// Helper to get the expected file path for a credential
		const getExpectedCredentialPath = (credentialId: string): string => {
			return path.join(exportDirectory, 'git', 'credential_stubs', `${credentialId}.json`);
		};

		// Helper to parse the JSON content that was written to a file
		const getWrittenCredentialData = (credentialId: string): ExportableCredential => {
			const expectedPath = getExpectedCredentialPath(credentialId);
			const writeCall = mockFsWriteFile.mock.calls.find(([filePath]) => filePath === expectedPath);
			if (!writeCall) {
				throw new Error(
					`No write call found for credential ${credentialId} at path ${expectedPath}`,
				);
			}
			const [, content] = writeCall;
			return JSON.parse(content as string);
		};

		it('should export personal project credentials with correct owner information', async () => {
			// Arrange
			const credentialData = {
				clientId: 'test-client-id',
				clientSecret: 'test-secret',
				apiKey: 'test-api-key',
			};

			const credential = await createCredentials(
				{
					name: 'Test Personal Credential',
					type: 'testCredentialType',
					data: Container.get(Cipher).encrypt(credentialData),
				},
				personalProject,
			);

			const candidates = [{ id: credential.id }] as SourceControlledFile[];

			// Act
			const result = await exportService.exportCredentialsToWorkFolder(candidates);

			// Assert
			expect(result.count).toBe(1);
			expect(result.files).toHaveLength(1);
			expect(result.missingIds).toEqual([]);

			// Verify file write was called with correct path and content
			expect(mockFsWriteFile).toHaveBeenCalledTimes(1);
			const expectedPath = getExpectedCredentialPath(credential.id);
			expect(mockFsWriteFile).toHaveBeenCalledWith(
				expectedPath,
				expect.stringContaining('"name": "Test Personal Credential"'),
			);

			// Verify the exported credential content
			const exportedCredential = getWrittenCredentialData(credential.id);
			expect(exportedCredential).toMatchObject({
				id: credential.id,
				name: 'Test Personal Credential',
				type: 'testCredentialType',
				data: {
					clientId: '',
					clientSecret: '',
					apiKey: '',
				},
				ownedBy: {
					type: 'personal',
					projectId: personalProject.id,
					projectName: personalProject.name,
					personalEmail: testUser.email,
				},
			});

			// Verify sensitive data was removed
			expect(exportedCredential.data.clientId).toBe('');
			expect(exportedCredential.data.clientSecret).toBe('');
			expect(exportedCredential.data.apiKey).toBe('');
		});

		it('should export team project credentials with correct team information', async () => {
			// Arrange

			const credentialData = {
				serverUrl: 'https://example.com',
				token: 'secret-token',
			};

			const credential = await createCredentials(
				{
					name: 'Test Team Credential',
					type: 'teamCredentialType',
					data: Container.get(Cipher).encrypt(credentialData),
				},
				teamProject,
			);

			const candidates = [{ id: credential.id }] as SourceControlledFile[];

			// Act
			const result = await exportService.exportCredentialsToWorkFolder(candidates);

			// Assert
			expect(result.count).toBe(1);
			expect(result.files).toHaveLength(1);
			expect(result.missingIds).toEqual([]);

			// Verify file write was called
			expect(mockFsWriteFile).toHaveBeenCalledTimes(1);
			const expectedPath = getExpectedCredentialPath(credential.id);
			expect(mockFsWriteFile).toHaveBeenCalledWith(
				expectedPath,
				expect.stringContaining('"name": "Test Team Credential"'),
			);

			// Verify file was created and has correct content
			const exportedCredential = getWrittenCredentialData(credential.id);

			expect(exportedCredential).toMatchObject({
				id: credential.id,
				name: 'Test Team Credential',
				type: 'teamCredentialType',
				data: {
					serverUrl: '',
					token: '',
				},
				ownedBy: {
					type: 'team',
					teamId: teamProject.id,
					teamName: teamProject.name,
				},
			});

			// Verify sensitive data was removed
			expect(exportedCredential.data.serverUrl).toBe('');
			expect(exportedCredential.data.token).toBe('');
		});

		it('should handle missing credentials and return correct missingIds', async () => {
			// Arrange

			const nonExistentCredentialId = uuid();
			const candidates = [
				{ id: nonExistentCredentialId },
				{ id: 'another-missing-id' },
			] as SourceControlledFile[];

			// Act
			const result = await exportService.exportCredentialsToWorkFolder(candidates);

			// Assert
			expect(result.count).toBe(0);
			expect(result.files).toHaveLength(0);
			expect(result.missingIds).toEqual([nonExistentCredentialId, 'another-missing-id']);

			// Verify no file writes were attempted
			expect(mockFsWriteFile).not.toHaveBeenCalled();
		});

		it('should correctly handle partial missing credentials', async () => {
			// Arrange

			const credential = await createCredentials(
				{
					name: 'Existing Credential',
					type: 'existingType',
					data: Container.get(Cipher).encrypt({ apiKey: 'test' }),
				},
				personalProject,
			);

			const nonExistentId = uuid();
			const candidates = [{ id: credential.id }, { id: nonExistentId }] as SourceControlledFile[];

			// Act
			const result = await exportService.exportCredentialsToWorkFolder(candidates);

			// Assert
			expect(result.count).toBe(1);
			expect(result.files).toHaveLength(1);
			expect(result.missingIds).toEqual([nonExistentId]);

			// Verify only one file write was attempted (for the existing credential)
			expect(mockFsWriteFile).toHaveBeenCalledTimes(1);

			// Verify the existing credential was exported correctly
			const exportedCredential = getWrittenCredentialData(credential.id);
			expect(exportedCredential.name).toBe('Existing Credential');
		});

		it('should handle role access correctly', async () => {
			// Arrange

			// Create a credential with explicit role setup
			const credential = new CredentialsEntity();
			Object.assign(credential, {
				name: 'Role Access Test Credential',
				type: 'roleTestType',
				data: Container.get(Cipher).encrypt({ testField: 'test-value' }),
			});

			const savedCredential = await credentialsRepository.save(credential);

			// Create the shared credential relationship
			await sharedCredentialsRepository.save(
				sharedCredentialsRepository.create({
					credentials: savedCredential,
					project: personalProject,
					role: 'credential:owner',
				}),
			);

			const candidates = [{ id: savedCredential.id }] as SourceControlledFile[];

			// Act & Assert - This should not throw an error accessing pr.role.slug
			const result = await exportService.exportCredentialsToWorkFolder(candidates);

			expect(result.count).toBe(1);
			expect(result.files).toHaveLength(1);

			// Verify file write was called
			expect(mockFsWriteFile).toHaveBeenCalledTimes(1);

			// Verify the role access worked and owner information is correct
			const exportedCredential = getWrittenCredentialData(savedCredential.id);
			expect(exportedCredential.ownedBy).toEqual({
				type: 'personal',
				projectId: personalProject.id,
				projectName: personalProject.name,
				personalEmail: testUser.email,
			});
		});

		it('should handle OAuth credentials and exclude oauthTokenData', async () => {
			// Arrange

			const credentialData = {
				clientId: 'oauth-client-id',
				clientSecret: 'oauth-secret',
				accessTokenUrl: 'https://oauth.example.com/token',
				oauthTokenData: {
					access_token: 'secret-access-token',
					refresh_token: 'secret-refresh-token',
					expires_in: 3600,
				},
			};

			const credential = await createCredentials(
				{
					name: 'OAuth Test Credential',
					type: 'oauth2Credential',
					data: Container.get(Cipher).encrypt(credentialData),
				},
				personalProject,
			);

			const candidates = [{ id: credential.id }] as SourceControlledFile[];

			// Act
			const result = await exportService.exportCredentialsToWorkFolder(candidates);

			// Assert
			expect(result.count).toBe(1);

			// Verify file write was called
			expect(mockFsWriteFile).toHaveBeenCalledTimes(1);

			const exportedCredential = getWrittenCredentialData(credential.id);

			// Verify oauthTokenData is excluded
			expect(exportedCredential.data).not.toHaveProperty('oauthTokenData');

			// Verify other fields are present but sanitized
			expect(exportedCredential.data).toMatchObject({
				clientId: '',
				clientSecret: '',
				accessTokenUrl: '',
			});
		});

		it('should handle credentials with nested data objects', async () => {
			// Arrange

			const credentialData = {
				connection: {
					host: 'db.example.com',
					port: 5432,
					username: 'user',
					password: 'secret',
				},
				settings: {
					ssl: true,
					timeout: 30,
				},
			};

			const credential = await createCredentials(
				{
					name: 'Nested Data Credential',
					type: 'databaseCredential',
					data: Container.get(Cipher).encrypt(credentialData),
				},
				teamProject,
			);

			const candidates = [{ id: credential.id }] as SourceControlledFile[];

			// Act
			const result = await exportService.exportCredentialsToWorkFolder(candidates);

			// Assert
			expect(result.count).toBe(1);

			// Verify file write was called
			expect(mockFsWriteFile).toHaveBeenCalledTimes(1);

			const exportedCredential = getWrittenCredentialData(credential.id);

			// Verify nested structure is preserved but values are sanitized
			expect(exportedCredential.data).toMatchObject({
				connection: {
					host: '',
					username: '',
					password: '',
				},
				settings: {
					// Numbers should be preserved according to TODO comment in service
					timeout: 30,
				},
			});

			// Verify numbers and booleans are preserved
			expect((exportedCredential.data.connection as any).port).toBe(5432);
			expect((exportedCredential.data.settings as any).ssl).toBe(true);
		});

		it('should export global credentials with isGlobal flag set to true', async () => {
			// Arrange
			const credentialData = {
				apiKey: 'global-api-key',
				apiSecret: 'global-secret',
			};

			const credential = await createCredentials(
				{
					name: 'Global Test Credential',
					type: 'globalCredentialType',
					data: Container.get(Cipher).encrypt(credentialData),
					isGlobal: true,
				},
				personalProject,
			);

			const candidates = [{ id: credential.id }] as SourceControlledFile[];

			// Act
			const result = await exportService.exportCredentialsToWorkFolder(candidates);

			// Assert
			expect(result.count).toBe(1);
			expect(result.files).toHaveLength(1);

			// Verify file write was called
			expect(mockFsWriteFile).toHaveBeenCalledTimes(1);

			const exportedCredential = getWrittenCredentialData(credential.id);

			// Verify isGlobal flag is exported
			expect(exportedCredential).toMatchObject({
				id: credential.id,
				name: 'Global Test Credential',
				type: 'globalCredentialType',
				isGlobal: true,
				data: {
					apiKey: '',
					apiSecret: '',
				},
			});

			// Verify isGlobal is explicitly true
			expect(exportedCredential.isGlobal).toBe(true);
		});

		it('should export non-global credentials with isGlobal flag set to false', async () => {
			// Arrange
			const credentialData = {
				username: 'test-user',
				password: 'test-password',
			};

			const credential = await createCredentials(
				{
					name: 'Non-Global Credential',
					type: 'standardCredentialType',
					data: Container.get(Cipher).encrypt(credentialData),
					isGlobal: false,
				},
				teamProject,
			);

			const candidates = [{ id: credential.id }] as SourceControlledFile[];

			// Act
			const result = await exportService.exportCredentialsToWorkFolder(candidates);

			// Assert
			expect(result.count).toBe(1);
			expect(mockFsWriteFile).toHaveBeenCalledTimes(1);

			const exportedCredential = getWrittenCredentialData(credential.id);

			// Verify isGlobal flag is false
			expect(exportedCredential).toMatchObject({
				id: credential.id,
				name: 'Non-Global Credential',
				type: 'standardCredentialType',
				isGlobal: false,
			});

			// Verify isGlobal is explicitly false
			expect(exportedCredential.isGlobal).toBe(false);
		});
	});

	describe('exportTagsToWorkFolder', () => {
		const tagsFilePath = () => path.join(exportDirectory, 'git', 'tags.json');

		function getWrittenTagsData(): {
			tags: Array<{ id: string; name: string }>;
			mappings: Array<{ tagId: string; workflowId: string }>;
		} {
			const writeCall = mockFsWriteFile.mock.calls.find(
				([filePath]) => filePath === tagsFilePath(),
			);
			if (!writeCall) {
				throw new Error('No write call found for tags file');
			}
			return JSON.parse(writeCall[1] as string);
		}

		async function exportTags() {
			return await exportService.exportTagsToWorkFolder(new SourceControlContext(testUser));
		}

		it('should export tags and mappings across multiple teams', async () => {
			const team1 = await createTeamProject('Team 1', testUser);
			const team2 = await createTeamProject('Team 2', testUser);

			const [workflow1, workflow2, workflow3] = await Promise.all([
				createWorkflowWithHistory({ id: 'wf1', name: 'Workflow 1' }, team1),
				createWorkflowWithHistory({ id: 'wf2', name: 'Workflow 2' }, team1),
				createWorkflowWithHistory({ id: 'wf3', name: 'Workflow 3' }, team2),
			]);

			const [tag1, tag2] = await Promise.all([
				createTag({ id: 'tag1', name: 'Tag 1' }),
				createTag({ id: 'tag2', name: 'Tag 2' }),
			]);

			await Promise.all([
				assignTagToWorkflow(tag1, workflow1),
				assignTagToWorkflow(tag2, workflow1),
				assignTagToWorkflow(tag1, workflow2),
				assignTagToWorkflow(tag2, workflow3),
			]);

			const result = await exportTags();

			expect(result.count).toBe(2);
			expect(result.files).toHaveLength(1);

			const { tags, mappings } = getWrittenTagsData();
			expect(tags).toEqual(
				expect.arrayContaining([
					{ id: 'tag1', name: 'Tag 1' },
					{ id: 'tag2', name: 'Tag 2' },
				]),
			);
			expect(mappings).toEqual(
				expect.arrayContaining([
					{ tagId: 'tag1', workflowId: 'wf1' },
					{ tagId: 'tag2', workflowId: 'wf1' },
					{ tagId: 'tag1', workflowId: 'wf2' },
					{ tagId: 'tag2', workflowId: 'wf3' },
				]),
			);
		});

		it('should export empty tags file when no tags exist', async () => {
			const result = await exportTags();

			expect(result.count).toBe(0);
			expect(result.files).toHaveLength(1);
			expect(mockFsWriteFile).toHaveBeenCalledWith(
				tagsFilePath(),
				JSON.stringify({ tags: [], mappings: [] }, null, 2),
			);
		});

		it('should export tags without mappings when tags are unassigned', async () => {
			await Promise.all([
				createTag({ id: 'tag1', name: 'Tag 1' }),
				createTag({ id: 'tag2', name: 'Tag 2' }),
			]);

			const result = await exportTags();

			expect(result.count).toBe(2);

			const { tags, mappings } = getWrittenTagsData();
			expect(tags).toHaveLength(2);
			expect(mappings).toHaveLength(0);
		});

		it('should export all mappings when multiple tags are on same workflow', async () => {
			const team = await createTeamProject('Team', testUser);
			const workflow = await createWorkflowWithHistory({ id: 'wf1', name: 'Workflow 1' }, team);

			const tags = await Promise.all([
				createTag({ id: 'tag1', name: 'Tag 1' }),
				createTag({ id: 'tag2', name: 'Tag 2' }),
				createTag({ id: 'tag3', name: 'Tag 3' }),
			]);

			await Promise.all(tags.map(async (tag) => await assignTagToWorkflow(tag, workflow)));

			const result = await exportTags();

			expect(result.count).toBe(3);

			const { tags: exportedTags, mappings } = getWrittenTagsData();
			expect(exportedTags).toHaveLength(3);
			expect(mappings).toEqual(
				expect.arrayContaining([
					{ tagId: 'tag1', workflowId: 'wf1' },
					{ tagId: 'tag2', workflowId: 'wf1' },
					{ tagId: 'tag3', workflowId: 'wf1' },
				]),
			);
		});
	});
});
