import { expect, test } from '@playwright/test';
import AdmZip from 'adm-zip';
import { execFileSync, ExecFileSyncOptions } from 'child_process';
import crypto from 'crypto';
import { users } from 'data';
import fs from 'fs';
import path from 'path';
import { cleanupBackend } from 'utils/cleanup.util';
import { pathFromRoot, tmpDir } from 'utils/fs.util';

const containerName = 'pocket-id';
const setupDir = pathFromRoot('setup');
const exampleExportPath = pathFromRoot('resources/export');
const dockerCommandMaxBuffer = 100 * 1024 * 1024;
let mode: 'sqlite' | 'postgres' | 's3' = 'sqlite';

test.beforeAll(() => {
	const dockerComposeLs = runDockerCommand(['compose', 'ls', '--format', 'json']);
	if (dockerComposeLs.includes('postgres')) {
		mode = 'postgres';
	} else if (dockerComposeLs.includes('s3')) {
		mode = 's3';
	}
	console.log(`Running CLI tests in ${mode.toUpperCase()} mode`);
});

test('Export', async ({ baseURL }) => {
	// Reset the backend but with LDAP setup because the example export has no LDAP data
	await cleanupBackend({ skipLdapSetup: true });

	// Fetch the profile pictures because they get generated on demand
	await Promise.all([
		fetch(`${baseURL}/api/users/${users.craig.id}/profile-picture.png`),
		fetch(`${baseURL}/api/users/${users.tim.id}/profile-picture.png`)
	]);

	// Export the data from the seeded container
	const exportPath = path.join(tmpDir, 'export.zip');
	const extractPath = path.join(tmpDir, 'export-extracted');

	runExport(exportPath);
	unzipExport(exportPath, extractPath);

	compareExports(exampleExportPath, extractPath);
});

test('Export via stdout', async ({ baseURL }) => {
	await cleanupBackend({ skipLdapSetup: true });

	await Promise.all([
		fetch(`${baseURL}/api/users/${users.craig.id}/profile-picture.png`),
		fetch(`${baseURL}/api/users/${users.tim.id}/profile-picture.png`)
	]);

	const stdoutBuffer = runExportToStdout();
	const stdoutExtractPath = path.join(tmpDir, 'export-stdout-extracted');
	unzipExportBuffer(stdoutBuffer, stdoutExtractPath);

	compareExports(exampleExportPath, stdoutExtractPath);
});

test('Import SQLite export', async () => {
	// Reset the backend without seeding
	await cleanupBackend({ skipSeed: true });

	// Run the import with the example export data
	const exampleExportArchivePath = path.join(tmpDir, 'example-export.zip');
	archiveExampleExport(exampleExportArchivePath);

	try {
		runDockerComposeCommand(['stop', containerName]);
		runImport(exampleExportArchivePath);
	} finally {
		runDockerComposeCommand(['up', '-d', containerName]);
	}

	// Export again from the imported instance
	const exportPath = path.join(tmpDir, 'export.zip');
	const exportExtracted = path.join(tmpDir, 'export-extracted');
	runExport(exportPath);
	unzipExport(exportPath, exportExtracted);

	compareExports(exampleExportPath, exportExtracted);
});

test('Import SQLite export via stdin', async () => {
	await cleanupBackend({ skipSeed: true });

	const exampleExportArchivePath = path.join(tmpDir, 'example-export-stdin.zip');
	const exampleExportBuffer = archiveExampleExport(exampleExportArchivePath);

	try {
		runDockerComposeCommand(['stop', containerName]);
		runImportFromStdin(exampleExportBuffer);
	} finally {
		runDockerComposeCommand(['up', '-d', containerName]);
	}

	const exportPath = path.join(tmpDir, 'export-from-stdin.zip');
	const exportExtracted = path.join(tmpDir, 'export-from-stdin-extracted');
	runExport(exportPath);
	unzipExport(exportPath, exportExtracted);

	compareExports(exampleExportPath, exportExtracted);
});

function compareExports(dir1: string, dir2: string): void {
	const hashes1 = hashAllFiles(dir1);
	const hashes2 = hashAllFiles(dir2);

	const files1 = Object.keys(hashes1).sort();
	const files2 = Object.keys(hashes2)
		.sort()
		.filter((p) => !p.includes('.inited'));
	expect(files2).toEqual(files1);

	for (const file of files1) {
		const profilePictures = ['CF.png', 'CR.png'];
		if (profilePictures.includes(path.basename(file))) {
			continue; // Skip profile pictures because they get generated on demand and can differ between exports
		}
		expect(hashes2[file], `${file} hash should match`).toEqual(hashes1[file]);
	}

	// Compare database.json contents
	const expectedData = loadJSON(path.join(dir1, 'database.json'));
	const actualData = loadJSON(path.join(dir2, 'database.json'));

	// Check special fields
	validateSpecialFields(actualData);

	// Normalize and compare
	const normalizedExpected = normalizeJSON(expectedData);
	const normalizedActual = normalizeJSON(actualData);
	expect(normalizedActual).toEqual(normalizedExpected);

	// Compare francis.bin contents
	const file1 = path.join(dir1, 'francis.bin');
	const file2 = path.join(dir2, 'francis.bin');
	 
	for (const filePath of [file1, file2]) {
		expect(fs.existsSync(filePath), `${filePath} should exist`).toBe(true);

		const header = fs.readFileSync(filePath).subarray(0, 64).toString('latin1');
		expect(header).toContain('francis-backup');

		const fileSize = fs.statSync(filePath).size;
		expect(fileSize).toBeGreaterThan(1000);
	}
}

function archiveExampleExport(outputPath: string): Buffer {
	fs.rmSync(outputPath, { force: true });

	const zip = new AdmZip();
	const files = fs.readdirSync(exampleExportPath);
	for (const file of files) {
		const filePath = path.join(exampleExportPath, file);
		if (fs.statSync(filePath).isFile()) {
			zip.addLocalFile(filePath);
		} else if (fs.statSync(filePath).isDirectory()) {
			zip.addLocalFolder(filePath, file);
		}
	}

	const buffer = zip.toBuffer();
	fs.writeFileSync(outputPath, buffer);
	return buffer;
}


// Helper to load JSON files
function loadJSON(path: string) {
	return JSON.parse(fs.readFileSync(path, 'utf-8'));
}

function normalizeJSON(obj: any): any {
	if (typeof obj === 'string') {
		try {
			// Normalize JSON strings
			const parsed = JSON.parse(atob(obj));
			return JSON.stringify(normalizeJSON(parsed));
		} catch {
			return obj;
		}
	}

	if (Array.isArray(obj)) {
		// Sort arrays to make order irrelevant
		return obj
			.map(normalizeJSON)
			.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
	} else if (obj && typeof obj === 'object') {
		const ignoredKeys = ['id', 'created_at', 'expires_at', 'credentials', 'provider', 'version'];

		// Sort and normalize object keys, skipping ignored ones
		return Object.keys(obj)
			.filter((key) => !ignoredKeys.includes(key))
			.sort()
			.reduce(
				(acc, key) => {
					acc[key] = normalizeJSON(obj[key]);
					return acc;
				},
				{} as Record<string, any>
			);
	}

	return obj;
}

function validateSpecialFields(obj: any): void {
	if (Array.isArray(obj)) {
		for (const item of obj) validateSpecialFields(item);
	} else if (obj && typeof obj === 'object') {
		for (const [key, value] of Object.entries(obj)) {
			if (key === 'id') {
				expect(isUUID(value), `Expected '${value}' to be a valid UUID`).toBe(true);
			} else if (key === 'created_at' || key === 'expires_at') {
				expect(
					isValidISODate(value),
					`Expected '${key}' = ${value} to be a valid ISO 8601 date string`
				).toBe(true);
			} else if (key === 'provider') {
				expect(
					['postgres', 'sqlite'].includes(value as string),
					`Expected 'provider' to be either 'postgres' or 'sqlite', got '${value}'`
				).toBe(true);
			} else if (key === 'version') {
				expect(value).toBeGreaterThanOrEqual(20251001000000);
			} else {
				validateSpecialFields(value);
			}
		}
	}
}

function isUUID(value: any): boolean {
	if (typeof value !== 'string') return false;
	const uuidRegex = /^[^-]{8}-[^-]{4}-[^-]{4}-[^-]{4}-[^-]{12}$/;
	return uuidRegex.test(value);
}

function isValidISODate(value: any): boolean {
	const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
	if (!isoRegex.test(value)) return false;
	const date = new Date(value);
	return !isNaN(date.getTime());
}

function runImport(pathToFile: string) {
	const importContainerId = runDockerComposeCommand([
		'run',
		'-d',
		'-v',
		`${pathToFile}:/app/data/pocket-id-export.zip`,
		containerName,
		'/app/pocket-id',
		'import',
		'--path',
		'/app/data/pocket-id-export.zip',
		'--yes'
	]);
	try {
		runDockerCommand(['wait', importContainerId]);
	} finally {
		runDockerCommand(['rm', '-f', importContainerId]);
	}
}

function runImportFromStdin(archive: Buffer): void {
	runDockerComposeCommandRaw(
		['run', '--rm', '-T', containerName, '/app/pocket-id', 'import', '--yes', '--path', '-'],
		{ input: archive }
	);
}

function runExport(outputFile: string): void {
	const containerId = runDockerComposeCommand([
		'run',
		'-d',
		containerName,
		'/app/pocket-id',
		'export',
		'--path',
		'/app/data/pocket-id-export.zip'
	]);

	try {
		// Wait until export finishes
		runDockerCommand(['wait', containerId]);
		runDockerCommand(['cp', `${containerId}:/app/data/pocket-id-export.zip`, outputFile]);
	} finally {
		runDockerCommand(['rm', '-f', containerId]);
	}

	expect(fs.existsSync(outputFile)).toBe(true);
}

function runExportToStdout(): Buffer {
	const res = runDockerComposeCommandRaw([
		'run',
		'--rm',
		'-T',
		containerName,
		'/app/pocket-id',
		'export',
		'--path',
		'-'
	]);
	return res;
}

function unzipExport(zipFile: string, destDir: string): void {
	fs.rmSync(destDir, { recursive: true, force: true });
	const zip = new AdmZip(zipFile);
	zip.extractAllTo(destDir, true);
}

function unzipExportBuffer(zipBuffer: Buffer, destDir: string): void {
	fs.rmSync(destDir, { recursive: true, force: true });
	const zip = new AdmZip(zipBuffer);
	zip.extractAllTo(destDir, true);
}

function hashFile(filePath: string): string {
	const buffer = fs.readFileSync(filePath);
	return crypto.createHash('sha256').update(buffer).digest('hex');
}

function getAllFiles(dir: string, root = dir): string[] {
	return fs.readdirSync(dir).flatMap((entry) => {
		// The actor host's data is not part of the example export and its contents differ between runs, so it is checked separately
		if (['.DS_Store', 'database.json', 'francis.bin'].includes(entry)) return [];

		const fullPath = path.join(dir, entry);
		const stat = fs.statSync(fullPath);
		return stat.isDirectory() ? getAllFiles(fullPath, root) : [path.relative(root, fullPath)];
	});
}

function hashAllFiles(dir: string): Record<string, string> {
	const files = getAllFiles(dir);
	const hashes: Record<string, string> = {};
	for (const relativePath of files) {
		const fullPath = path.join(dir, relativePath);
		hashes[relativePath] = hashFile(fullPath);
	}
	return hashes;
}

function runDockerCommand(args: string[], options?: ExecFileSyncOptions): string {
	return execFileSync('docker', args, {
		cwd: setupDir,
		stdio: 'pipe',
		maxBuffer: dockerCommandMaxBuffer,
		...options
	})
		.toString()
		.trim();
}

function runDockerComposeCommand(args: string[]): string {
	return runDockerComposeCommandRaw(args).toString().trim();
}

function runDockerComposeCommandRaw(args: string[], options?: ExecFileSyncOptions): Buffer {
	return execFileSync('docker', dockerComposeArgs(args), {
		cwd: setupDir,
		stdio: 'pipe',
		maxBuffer: dockerCommandMaxBuffer,
		...options
	}) as Buffer;
}

function dockerComposeArgs(args: string[]): string[] {
	let dockerComposeFile = 'docker-compose.yml';
	switch (mode) {
		case 'postgres':
			dockerComposeFile = 'docker-compose-postgres.yml';
			break;
		case 's3':
			dockerComposeFile = 'docker-compose-s3.yml';
			break;
	}
	return ['compose', '-f', dockerComposeFile, ...args];
}
