// SPDX-License-Identifier: AGPL-3.0-or-later

const {existsSync, readdirSync, readFileSync, statSync} = require('node:fs');
const os = require('node:os');
const {basename} = require('node:path');
const {spawnSync} = require('node:child_process');
const NATIVE_LOAD_ERROR_MARKER = Symbol.for('fluxer.nativeLoadError');
const MAX_TEXT_LENGTH = 6000;
const MAX_DIRECTORY_ENTRIES = 80;

function trimText(value, limit = MAX_TEXT_LENGTH) {
	const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
	const trimmed = text.trim();
	if (!trimmed) return null;
	return trimmed.length > limit ? `${trimmed.slice(0, limit)}\n...<truncated>` : trimmed;
}

function errorDiagnostic(error) {
	if (!error) return null;
	if (error instanceof Error) {
		return {
			name: error.name || 'Error',
			message: error.message,
			code: error.code || null,
			stack: trimText(error.stack || error.message),
		};
	}
	return {
		name: typeof error,
		message: trimText(String(error)),
		code: null,
		stack: null,
	};
}

function formatErrorDiagnostic(diagnostic) {
	if (!diagnostic) return null;
	const lines = [];
	if (diagnostic.code) lines.push(`code=${diagnostic.code}`);
	if (diagnostic.stack) lines.push(diagnostic.stack);
	else if (diagnostic.message) lines.push(diagnostic.message);
	return trimText(lines.join('\n'));
}

function fileDiagnostic(filePath) {
	if (!filePath) return {path: null, exists: false, error: 'not resolved'};
	try {
		const stat = statSync(filePath);
		return {
			path: filePath,
			exists: true,
			size: stat.size,
			mode: `0${(stat.mode & 0o777).toString(8)}`,
			mtime: stat.mtime.toISOString(),
			isFile: stat.isFile(),
			isDirectory: stat.isDirectory(),
		};
	} catch (error) {
		const reason = error instanceof Error ? error.message : String(error);
		return {path: filePath, exists: false, error: reason};
	}
}

function formatFileDiagnostic(diagnostic) {
	if (!diagnostic) return 'not resolved';
	if (!diagnostic.exists) return `exists=false, statError=${diagnostic.error || '<unknown>'}`;
	return [
		`exists=true`,
		`size=${diagnostic.size}`,
		`mode=${diagnostic.mode}`,
		`mtime=${diagnostic.mtime}`,
		`isFile=${diagnostic.isFile}`,
	].join(', ');
}

function directoryDiagnostic(dirPath) {
	if (!dirPath) return {path: null, ok: false, error: 'not resolved', entries: [], total: 0, omitted: 0};
	try {
		const entries = readdirSync(dirPath, {withFileTypes: true}).map((entry) => ({
			name: entry.name,
			type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other',
		}));
		entries.sort((a, b) => a.name.localeCompare(b.name));
		const visible = entries.slice(0, MAX_DIRECTORY_ENTRIES);
		return {
			path: dirPath,
			ok: true,
			entries: visible,
			total: entries.length,
			omitted: Math.max(0, entries.length - visible.length),
		};
	} catch (error) {
		return {
			path: dirPath,
			ok: false,
			error: error instanceof Error ? error.message : String(error),
			entries: [],
			total: 0,
			omitted: 0,
		};
	}
}

function formatDirectoryDiagnostic(diagnostic) {
	if (!diagnostic) return '<unavailable>';
	if (!diagnostic.ok) return `directory listing failed: ${diagnostic.error || '<unknown>'}`;
	const entries = diagnostic.entries.map((entry) => `${entry.name}${entry.type === 'directory' ? '/' : ''}`);
	const suffix = diagnostic.omitted > 0 ? [`...<${diagnostic.omitted} more entries>`] : [];
	return [...entries, ...suffix].join('\n') || '<empty>';
}

function selectedEnvironmentNames(skipNativeProbeEnv) {
	const names = [
		'ELECTRON_RUN_AS_NODE',
		'FLUXER_NATIVE_MODULE_PREFLIGHT_CHILD',
		'LD_LIBRARY_PATH',
		'DYLD_LIBRARY_PATH',
		'DISPLAY',
		'WAYLAND_DISPLAY',
		'XDG_CURRENT_DESKTOP',
		'XDG_SESSION_TYPE',
		'DBUS_SESSION_BUS_ADDRESS',
		'PULSE_SERVER',
		'PIPEWIRE_REMOTE',
		'PATH',
	];
	if (skipNativeProbeEnv) names.push(skipNativeProbeEnv);
	return names;
}

function environmentDiagnostics(skipNativeProbeEnv) {
	return Object.fromEntries(
		selectedEnvironmentNames(skipNativeProbeEnv).map((name) => [name, process.env[name] ?? null]),
	);
}

function formatEnvironment(diagnostic) {
	return Object.entries(diagnostic)
		.map(([name, value]) => `${name}=${value ?? '<unset>'}`)
		.join('\n');
}

function runtimeDiagnostics() {
	const versions = process.versions || {};
	let reportHeader = null;
	if (process.report && typeof process.report.getReport === 'function') {
		try {
			reportHeader = process.report.getReport().header || null;
		} catch {
			reportHeader = null;
		}
	}
	const glibcRuntime = versions.glibcVersionRuntime || reportHeader?.glibcVersionRuntime || '<unknown>';
	const glibcCompiler = versions.glibcVersionCompiler || reportHeader?.glibcVersionCompiler || '<unknown>';
	return {
		node: versions.node || null,
		electron: versions.electron || null,
		modules: versions.modules || null,
		napi: versions.napi || null,
		v8: versions.v8 || null,
		uv: versions.uv || null,
		openssl: versions.openssl || null,
		glibcRuntime,
		glibcCompiler,
		platform: process.platform,
		arch: process.arch,
		osType: os.type(),
		osRelease: os.release(),
		osVersion: typeof os.version === 'function' ? os.version() : null,
		execPath: process.execPath,
		resourcesPath: process.resourcesPath || null,
		cwd: process.cwd(),
	};
}

function formatRuntimeDiagnostics(diagnostic) {
	return [
		`node=${diagnostic.node || '<unknown>'}`,
		`electron=${diagnostic.electron || '<none>'}`,
		`modules=${diagnostic.modules || '<unknown>'}`,
		`napi=${diagnostic.napi || '<unknown>'}`,
		`v8=${diagnostic.v8 || '<unknown>'}`,
		`uv=${diagnostic.uv || '<unknown>'}`,
		`openssl=${diagnostic.openssl || '<unknown>'}`,
		`glibcRuntime=${diagnostic.glibcRuntime || '<unknown>'}`,
		`glibcCompiler=${diagnostic.glibcCompiler || '<unknown>'}`,
		`process=${diagnostic.platform}/${diagnostic.arch}`,
		`os=${diagnostic.osType} ${diagnostic.osRelease} ${diagnostic.osVersion || '<unknown>'}`,
		`execPath=${diagnostic.execPath}`,
		`resourcesPath=${diagnostic.resourcesPath || '<unknown>'}`,
		`cwd=${diagnostic.cwd}`,
	].join('\n');
}

const REDISTRIBUTABLE_RUNTIME_PATTERNS = [
	/^vcruntime\d+(?:_\d+)?\.dll$/i,
	/^msvcp\d+(?:_\d+)?\.dll$/i,
	/^msvcr\d+(?:_\d+)?\.dll$/i,
	/^concrt\d+\.dll$/i,
	/^vcamp\d+\.dll$/i,
	/^vcomp\d+\.dll$/i,
];

function readPeImports(filePath) {
	let buffer;
	try {
		buffer = readFileSync(filePath);
	} catch {
		return null;
	}
	if (buffer.length < 0x40) return null;
	const peOffset = buffer.readUInt32LE(0x3c);
	if (peOffset <= 0 || peOffset + 24 >= buffer.length) return null;
	if (buffer.readUInt32LE(peOffset) !== 0x4550) return null;
	const coffOffset = peOffset + 4;
	const numberOfSections = buffer.readUInt16LE(coffOffset + 2);
	const sizeOfOptionalHeader = buffer.readUInt16LE(coffOffset + 16);
	const optionalHeaderOffset = coffOffset + 20;
	if (optionalHeaderOffset + sizeOfOptionalHeader > buffer.length) return null;
	const magic = buffer.readUInt16LE(optionalHeaderOffset);
	if (magic !== 0x10b && magic !== 0x20b) return null;
	const dataDirectoriesOffset = optionalHeaderOffset + (magic === 0x20b ? 112 : 96);
	const importEntryOffset = dataDirectoriesOffset + 8;
	if (importEntryOffset + 8 > buffer.length) return null;
	const importRva = buffer.readUInt32LE(importEntryOffset);
	if (importRva === 0) return [];
	const sections = [];
	const sectionTableOffset = optionalHeaderOffset + sizeOfOptionalHeader;
	for (let i = 0; i < numberOfSections; i++) {
		const base = sectionTableOffset + i * 40;
		if (base + 40 > buffer.length) return null;
		sections.push({
			virtualSize: buffer.readUInt32LE(base + 8),
			virtualAddress: buffer.readUInt32LE(base + 12),
			rawSize: buffer.readUInt32LE(base + 16),
			rawPointer: buffer.readUInt32LE(base + 20),
		});
	}
	const rvaToOffset = (rva) => {
		for (const s of sections) {
			const span = Math.max(s.virtualSize, s.rawSize);
			if (rva >= s.virtualAddress && rva < s.virtualAddress + span) {
				return rva - s.virtualAddress + s.rawPointer;
			}
		}
		return -1;
	};
	const readCString = (offset) => {
		let end = offset;
		while (end < buffer.length && buffer[end] !== 0) end++;
		return buffer.toString('ascii', offset, end);
	};
	const importTableOffset = rvaToOffset(importRva);
	if (importTableOffset < 0) return [];
	const imports = new Set();
	for (let i = 0; i < 1024; i++) {
		const base = importTableOffset + i * 20;
		if (base + 20 > buffer.length) break;
		const lookupRva = buffer.readUInt32LE(base);
		const nameRva = buffer.readUInt32LE(base + 12);
		const iatRva = buffer.readUInt32LE(base + 16);
		if (lookupRva === 0 && nameRva === 0 && iatRva === 0) break;
		const nameOffset = rvaToOffset(nameRva);
		if (nameOffset < 0) continue;
		const name = readCString(nameOffset);
		if (name) imports.add(name);
	}
	return Array.from(imports);
}

function windowsImportProbe(nativePath) {
	const imports = readPeImports(nativePath);
	if (imports === null) return null;
	const sortedImports = [...imports].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
	const redistributable = sortedImports.filter((dll) =>
		REDISTRIBUTABLE_RUNTIME_PATTERNS.some((pattern) => pattern.test(dll)),
	);
	return {
		command: ['pe-imports', nativePath],
		status: 0,
		signal: null,
		error: null,
		stdout: sortedImports.join('\n') || null,
		stderr: null,
		missing: [],
		redistributable,
	};
}

function dependencyProbe(nativePath) {
	if (!nativePath || !existsSync(nativePath)) return null;
	if (process.platform === 'win32') return windowsImportProbe(nativePath);
	const command =
		process.platform === 'linux'
			? ['ldd', nativePath]
			: process.platform === 'darwin'
				? ['otool', '-L', nativePath]
				: null;
	if (!command) return null;
	const [bin, ...args] = command;
	const result = spawnSync(bin, args, {
		encoding: 'utf8',
		timeout: 4000,
		stdio: ['ignore', 'pipe', 'pipe'],
	});
	const stdout = trimText(result.stdout);
	const stderr = trimText(result.stderr);
	const missing =
		process.platform === 'linux' && stdout
			? stdout
					.split('\n')
					.map((line) => line.trim())
					.filter((line) => line.includes('not found'))
			: [];
	return {
		command,
		status: result.status,
		signal: result.signal || null,
		error: result.error ? result.error.message : null,
		stdout,
		stderr,
		missing,
		redistributable: [],
	};
}

function formatDependencyProbe(diagnostic) {
	if (!diagnostic) return null;
	const status = diagnostic.error
		? `error=${diagnostic.error}`
		: diagnostic.signal
			? `signal=${diagnostic.signal}`
			: `status=${diagnostic.status}`;
	return [
		`$ ${diagnostic.command.join(' ')}`,
		status,
		diagnostic.missing?.length ? `missing:\n${diagnostic.missing.join('\n')}` : null,
		diagnostic.redistributable?.length
			? `redistributableRuntimeImports (require VC++ redist on host):\n${diagnostic.redistributable.join('\n')}`
			: null,
		diagnostic.stdout ? `stdout:\n${diagnostic.stdout}` : null,
		diagnostic.stderr ? `stderr:\n${diagnostic.stderr}` : null,
	]
		.filter(Boolean)
		.join('\n');
}

function formatExtraDiagnostic(diagnostic) {
	if (!diagnostic) return null;
	if (typeof diagnostic === 'string') return diagnostic;
	if (typeof diagnostic === 'object' && diagnostic.name && diagnostic.text) {
		return `${diagnostic.name}:\n${diagnostic.text}`;
	}
	return `extra:\n${trimText(JSON.stringify(diagnostic, null, 2))}`;
}

function collectNativeDiagnostics({
	moduleName,
	nativePath,
	nativeRoot,
	packageDir,
	reason,
	cause,
	skipNativeProbeEnv,
	extraDiagnostics = [],
}) {
	return {
		schemaVersion: 1,
		moduleName,
		reason,
		target: {
			platform: process.platform,
			arch: process.arch,
		},
		packageDir: packageDir || null,
		nativeRoot: nativeRoot || null,
		nativePath: nativePath || null,
		nativeFile: nativePath ? basename(nativePath) : null,
		nativeFileStat: fileDiagnostic(nativePath),
		runtime: runtimeDiagnostics(),
		environment: environmentDiagnostics(skipNativeProbeEnv),
		nativeRootEntries: directoryDiagnostic(nativeRoot),
		dependencyProbe: dependencyProbe(nativePath),
		extraDiagnostics: extraDiagnostics.filter(Boolean),
		cause: errorDiagnostic(cause),
	};
}

function formatNativeDiagnostics(diagnostics) {
	const sections = [
		`module=${diagnostics.moduleName}`,
		`reason=${diagnostics.reason}`,
		`target=${diagnostics.target.platform}/${diagnostics.target.arch}`,
		`packageDir=${diagnostics.packageDir || '<unknown>'}`,
		`nativeRoot=${diagnostics.nativeRoot || '<unknown>'}`,
		`nativePath=${diagnostics.nativePath || '<unknown>'}`,
		`nativeFile=${diagnostics.nativeFile || '<unknown>'}`,
		`nativeFileStat=${formatFileDiagnostic(diagnostics.nativeFileStat)}`,
		`runtime:\n${formatRuntimeDiagnostics(diagnostics.runtime)}`,
		`environment:\n${formatEnvironment(diagnostics.environment)}`,
		`nativeRootEntries:\n${formatDirectoryDiagnostic(diagnostics.nativeRootEntries)}`,
		...diagnostics.extraDiagnostics.map(formatExtraDiagnostic).filter(Boolean),
	];
	const dependencyOutput = formatDependencyProbe(diagnostics.dependencyProbe);
	if (dependencyOutput) sections.push(`dependencyProbe:\n${dependencyOutput}`);
	const causeText = formatErrorDiagnostic(diagnostics.cause);
	if (causeText) sections.push(`cause:\n${causeText}`);
	return sections.join('\n');
}

function isNativeLoadError(error) {
	return Boolean(error?.[NATIVE_LOAD_ERROR_MARKER]);
}

function createNativeLoadError({
	moduleName,
	nativePath,
	nativeRoot,
	packageDir,
	reason,
	cause,
	skipNativeProbeEnv,
	extraDiagnostics = [],
}) {
	if (isNativeLoadError(cause)) return cause;
	const diagnostics = collectNativeDiagnostics({
		moduleName,
		nativePath,
		nativeRoot,
		packageDir,
		reason,
		cause,
		skipNativeProbeEnv,
		extraDiagnostics,
	});
	const error = new Error(`${moduleName} native module failed to load.\n${formatNativeDiagnostics(diagnostics)}`);
	error.name = 'NativeModuleLoadError';
	error[NATIVE_LOAD_ERROR_MARKER] = true;
	error.nativeDiagnostics = diagnostics;
	error.toJSON = () => ({
		name: error.name,
		message: error.message,
		nativeDiagnostics: diagnostics,
	});
	if (cause) error.cause = cause;
	return error;
}

function probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, timeoutMs = 5000}) {
	if (!skipNativeProbeEnv || process.env[skipNativeProbeEnv] === '1') {
		return null;
	}
	const result = spawnSync(process.execPath, ['-e', 'require(process.argv[1])', nativePath], {
		env: {...process.env, ELECTRON_RUN_AS_NODE: '1', [skipNativeProbeEnv]: '1'},
		encoding: 'utf8',
		stdio: ['ignore', 'pipe', 'pipe'],
		timeout: timeoutMs,
	});
	if (result.status === 0) return null;
	const reason = result.error
		? result.error.message
		: result.signal
			? `safety probe terminated by signal ${result.signal}`
			: `safety probe exited with code ${result.status}`;
	return createNativeLoadError({
		moduleName,
		nativePath,
		nativeRoot,
		packageDir,
		reason,
		skipNativeProbeEnv,
		extraDiagnostics: [
			result.stdout ? {name: 'probeStdout', text: trimText(result.stdout)} : null,
			result.stderr ? {name: 'probeStderr', text: trimText(result.stderr)} : null,
		],
	});
}

function loadNativeBinding({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, probe = true}) {
	if (!existsSync(nativePath)) {
		return {
			binding: null,
			loadError: createNativeLoadError({
				moduleName,
				nativePath,
				nativeRoot,
				packageDir,
				reason: 'native binary not found',
				skipNativeProbeEnv,
			}),
		};
	}
	const nativeProbeError = probe
		? probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv})
		: null;
	if (nativeProbeError) {
		return {binding: null, loadError: nativeProbeError};
	}
	try {
		return {binding: require(nativePath), loadError: null};
	} catch (error) {
		return {
			binding: null,
			loadError: createNativeLoadError({
				moduleName,
				nativePath,
				nativeRoot,
				packageDir,
				reason: 'require(nativePath) threw',
				cause: error,
				skipNativeProbeEnv,
			}),
		};
	}
}

module.exports = {
	collectNativeDiagnostics,
	createNativeLoadError,
	formatNativeDiagnostics,
	isNativeLoadError,
	loadNativeBinding,
	probeNativeBinary,
};
