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

const {EventEmitter} = require('node:events');
const {existsSync, readdirSync, readFileSync} = require('node:fs');
const {join, sep} = require('node:path');
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
const MODULE_NAME = '@fluxer/linux-audio-capture';
const SKIP_NATIVE_PROBE_ENV = 'FLUXER_LINUX_AUDIO_CAPTURE_SKIP_NATIVE_PROBE';

function resolveNativeRoot() {
	const asarSegment = `${sep}app.asar${sep}`;
	if (!__dirname.includes(asarSegment)) return __dirname;
	const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
	return existsSync(unpackedDir) ? unpackedDir : __dirname;
}

function nativeFileName() {
	if (process.platform !== 'linux') {
		throw new Error(`@fluxer/linux-audio-capture is only supported on Linux, got ${process.platform}`);
	}
	switch (process.arch) {
		case 'x64':
			return 'linux-audio-capture.linux-x64-gnu.node';
		case 'arm64':
			return 'linux-audio-capture.linux-arm64-gnu.node';
		default:
			throw new Error(`Unsupported Linux architecture: ${process.arch}`);
	}
}

let binding;

try {
	const nativeRoot = resolveNativeRoot();
	const nativePath = join(nativeRoot, nativeFileName());
	const loadedNative = loadNativeBinding({
		moduleName: MODULE_NAME,
		nativePath,
		nativeRoot,
		packageDir: __dirname,
		skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
	});
	if (loadedNative.loadError) {
		throw loadedNative.loadError;
	}
	binding = loadedNative.binding;
} catch (error) {
	throw createNativeLoadError({
		moduleName: MODULE_NAME,
		nativeRoot: resolveNativeRoot(),
		packageDir: __dirname,
		reason: 'native loader threw before binding load completed',
		cause: error,
		skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
	});
}

function normalizeRoutingRule(rule) {
	if (!rule || typeof rule !== 'object') return {};
	const normalizeList = (value) =>
		Array.isArray(value)
			? value
					.filter((entry) => entry && typeof entry === 'object' && !Array.isArray(entry))
					.map((entry) =>
						Object.fromEntries(
							Object.entries(entry)
								.filter(([, v]) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
								.map(([k, v]) => [k, String(v)]),
						),
					)
			: undefined;
	return {
		include: normalizeList(rule.include),
		exclude: normalizeList(rule.exclude),
		workaround: normalizeList(rule.workaround),
		ignoreDevices: rule.ignoreDevices ?? rule.ignore_devices,
		onlySpeakers: rule.onlySpeakers ?? rule.only_speakers,
		onlyDefaultSpeakers: rule.onlyDefaultSpeakers ?? rule.only_default_speakers,
	};
}

function readProcParentMap() {
	const parents = new Map();
	let entries = [];
	try {
		entries = readdirSync('/proc', {withFileTypes: true});
	} catch {
		return parents;
	}
	for (const entry of entries) {
		if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
		try {
			const stat = readFileSync(`/proc/${entry.name}/stat`, 'utf8');
			const end = stat.lastIndexOf(')');
			if (end < 0) continue;
			const fields = stat
				.slice(end + 1)
				.trim()
				.split(/\s+/);
			const parentPid = Number(fields[1]);
			if (Number.isSafeInteger(parentPid) && parentPid > 0) {
				parents.set(Number(entry.name), parentPid);
			}
		} catch {}
	}
	return parents;
}

function isDescendantPid(pid, rootPid, parents) {
	let current = pid;
	const seen = new Set();
	while (parents.has(current) && !seen.has(current)) {
		seen.add(current);
		const parent = parents.get(current);
		if (parent === rootPid) return true;
		current = parent;
	}
	return false;
}

function targetPidList(pid, includeProcessTree) {
	if (!includeProcessTree) return [pid];
	const parents = readProcParentMap();
	const pids = [pid];
	for (const candidate of parents.keys()) {
		if (candidate !== pid && isDescendantPid(candidate, pid, parents)) {
			pids.push(candidate);
		}
	}
	return pids;
}

function appendUniquePattern(patterns, pattern) {
	if (!pattern || typeof pattern !== 'object') return;
	const entries = Object.entries(pattern).filter(([, value]) => typeof value === 'string' && value.length > 0);
	if (entries.length === 0) return;
	const normalized = Object.fromEntries(entries);
	const key = JSON.stringify(Object.entries(normalized).sort(([a], [b]) => a.localeCompare(b)));
	if (
		patterns.some((existing) => JSON.stringify(Object.entries(existing).sort(([a], [b]) => a.localeCompare(b))) === key)
	) {
		return;
	}
	patterns.push(normalized);
}

function inventoryPatternsForTargetPids(pids) {
	if (!(binding && typeof binding.AudioBridge === 'function')) return [];
	const wanted = new Set(pids.map((pid) => String(pid)));
	const patterns = [];
	let bridge = null;
	try {
		bridge = new binding.AudioBridge();
		const inventory = bridge.inventory();
		if (!Array.isArray(inventory)) return patterns;
		for (const props of inventory) {
			if (!props || typeof props !== 'object') continue;
			if (props['media.class'] !== 'Stream/Output/Audio') continue;
			const processId = props['application.process.id'] || props['pipewire.sec.pid'];
			if (!wanted.has(String(processId || ''))) continue;
			appendUniquePattern(patterns, {'object.serial': String(props['object.serial'] || '')});
			appendUniquePattern(patterns, {'node.name': String(props['node.name'] || '')});
			appendUniquePattern(patterns, {'client.id': String(props['client.id'] || '')});
		}
	} catch {
	} finally {
		try {
			bridge?.release?.();
		} catch {}
	}
	return patterns;
}

function routingRuleFromTarget(target, options) {
	if (target && typeof target === 'object') {
		return normalizeRoutingRule(target.linuxRule || target);
	}
	const pid = Number(target);
	if (!Number.isSafeInteger(pid) || pid <= 0) {
		throw new TypeError('ProcessLoopback target pid must be a positive integer');
	}
	const targetPids = targetPidList(pid, Boolean(options?.includeProcessTree));
	const include = inventoryPatternsForTargetPids(targetPids);
	for (const candidate of targetPids) {
		appendUniquePattern(include, {'application.process.id': String(candidate)});
		appendUniquePattern(include, {'pipewire.sec.pid': String(candidate)});
	}
	return normalizeRoutingRule({
		include,
		ignoreDevices: options?.ignoreDevices ?? true,
	});
}

const LATE_SPAWN_REFRESH_INTERVAL_MS = 2_000;
const MAX_DRAIN_FRAMES_PER_TICK = 16;
const MAX_IDLE_DIRECT_CAPTURES = 2;
const MIX_TICK_PERIOD_MS = 20;

let idleDirectCaptures = [];

function acquireDirectAudioCapture() {
	if (typeof binding.DirectAudioCapture !== 'function') {
		throw new Error('DirectAudioCapture native export missing');
	}
	const pooled = idleDirectCaptures.pop();
	return pooled ?? new binding.DirectAudioCapture();
}

function releaseDirectAudioCapture(capture) {
	if (!capture || idleDirectCaptures.includes(capture)) return;
	if (idleDirectCaptures.length < MAX_IDLE_DIRECT_CAPTURES) {
		idleDirectCaptures.push(capture);
	}
}

function clearIdleDirectCapturePool() {
	idleDirectCaptures = [];
}

function patternsEqual(a, b) {
	if (a === b) return true;
	if (!Array.isArray(a) || !Array.isArray(b)) return false;
	if (a.length !== b.length) return false;
	const serialize = (entry) =>
		JSON.stringify(
			Object.entries(entry)
				.filter(([, v]) => typeof v === 'string')
				.sort(([x], [y]) => x.localeCompare(y)),
		);
	const aSet = a.map(serialize).sort();
	const bSet = b.map(serialize).sort();
	for (let i = 0; i < aSet.length; i++) {
		if (aSet[i] !== bSet[i]) return false;
	}
	return true;
}

class ProcessLoopback extends EventEmitter {
	constructor(target, options = {}) {
		super();
		this.capture = acquireDirectAudioCapture();
		if (typeof this.capture.setLifecycleCallback === 'function') {
			this.capture.setLifecycleCallback((type, message) => this.handleNativeLifecycle(type, message));
		}
		this.targetPid = null;
		this.includeProcessTree = false;
		if (!(target && typeof target === 'object')) {
			const pid = Number(target);
			if (Number.isSafeInteger(pid) && pid > 0) {
				this.targetPid = pid;
				this.includeProcessTree = Boolean(options?.includeProcessTree);
			}
		}
		this.rule = routingRuleFromTarget(target, options);
		this.options = options;
		this.timer = null;
		this.refreshTimer = null;
		this.closed = false;
		this.started = false;
	}

	handleNativeLifecycle(type, message) {
		if (type === 'error') {
			this.emit('error', new Error(message || 'Linux direct audio capture stopped'));
			if (!this.closed) void this.stop();
			return;
		}
		if (type === 'closed' || type === 'closed-clean') {
			if (!this.closed) void this.stop();
			return;
		}
		if (type === 'diagnostic') {
			this.emit('diagnostic', message || '');
		}
	}

	start() {
		if (this.closed) {
			throw new Error('ProcessLoopback already closed');
		}
		if (this.started) return;
		if (!this.capture.start(this.rule)) {
			throw new Error('failed to start Linux direct audio capture');
		}
		this.started = true;
		this.timer = setInterval(() => this.tick(), MIX_TICK_PERIOD_MS);
		this.timer.unref?.();
		if (this.targetPid !== null && this.includeProcessTree && typeof this.capture.setRule === 'function') {
			this.refreshTimer = setInterval(() => this.refreshRuleForLateChildren(), LATE_SPAWN_REFRESH_INTERVAL_MS);
			this.refreshTimer.unref?.();
		}
	}

	refreshRuleForLateChildren() {
		if (this.closed || !this.started || this.targetPid === null) return;
		let nextRule;
		try {
			nextRule = routingRuleFromTarget(this.targetPid, {
				...(this.options ?? {}),
				includeProcessTree: this.includeProcessTree,
			});
		} catch {
			return;
		}
		if (patternsEqual(nextRule.include, this.rule.include) && patternsEqual(nextRule.exclude, this.rule.exclude)) {
			return;
		}
		this.rule = nextRule;
		try {
			this.capture.setRule(this.rule);
		} catch {}
	}

	tick() {
		if (this.closed || !this.started) return;
		try {
			this.drainCaptureFrames();
		} catch (error) {
			this.emit('error', error instanceof Error ? error : new Error(String(error)));
			void this.stop();
		}
	}

	drainCaptureFrames() {
		for (let i = 0; i < MAX_DRAIN_FRAMES_PER_TICK; i++) {
			const frame = this.capture.read();
			if (!frame || !(frame.samples instanceof ArrayBuffer) || frame.samples.byteLength === 0) return;
			this.emit('frame', {
				samples: new Float32Array(frame.samples),
				sampleRate: frame.sampleRate,
				channels: frame.channels,
				timestampUs: frame.timestampUs,
			});
		}
	}

	routingGraph() {
		return this.capture && typeof this.capture.routingGraph === 'function' ? this.capture.routingGraph() : null;
	}

	setScreenAudioSink(handle) {
		if (!this.capture || typeof this.capture.setScreenAudioSink !== 'function') return false;
		try {
			return this.capture.setScreenAudioSink(handle) !== false;
		} catch {
			return false;
		}
	}

	clearScreenAudioSink() {
		if (this.capture && typeof this.capture.clearScreenAudioSink === 'function') {
			this.capture.clearScreenAudioSink();
		}
	}

	async stop() {
		if (this.closed) return;
		this.closed = true;
		this.clearScreenAudioSink();
		if (this.timer) clearInterval(this.timer);
		this.timer = null;
		if (this.refreshTimer) clearInterval(this.refreshTimer);
		this.refreshTimer = null;
		const wasStarted = this.started;
		this.started = false;
		const capture = this.capture;
		this.capture = null;
		let stopped = false;
		try {
			capture?.stop();
			stopped = true;
		} finally {
			if (stopped && wasStarted) {
				releaseDirectAudioCapture(capture);
			}
			this.emit('closed');
		}
	}
}

module.exports = {
	AudioBridge: binding.AudioBridge,
	DirectAudioCapture: binding.DirectAudioCapture,
	AudioMixRuntimeHandle: binding.AudioMixRuntimeHandle,
	ProcessLoopback,
	pipeWireAvailable: binding.pipeWireAvailable,
	audioBackend: binding.audioBackend ?? (() => (binding.pipeWireAvailable?.() ? 'pipewire' : 'none')),
	__setBindingForTests(nextBinding) {
		binding = nextBinding;
		clearIdleDirectCapturePool();
	},
	__getIdleDirectCaptureCountForTests() {
		return idleDirectCaptures.length;
	},
};
