/**
 * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
 * Node module: @schukai/monster
 *
 * SPDX-License-Identifier: AGPL-3.0
 */

import { getGlobal } from "../../../types/global.mjs";

export {
	FLOATING_LAYOUT_REASON,
	cancelFloatingLayout,
	enqueueFloatingLayout,
	flushFloatingLayoutQueueForTests,
};

const FLOATING_LAYOUT_REASON = Object.freeze({
	OPEN: 1 << 0,
	RESIZE: 1 << 1,
	CONTENT: 1 << 2,
	POSITION: 1 << 3,
	VIEWPORT: 1 << 4,
	SETTLE: 1 << 5,
});

const jobs = new Map();
let queueFrame = null;
let queueTimeout = null;
let flushing = false;
const OSCILLATION_HISTORY_LIMIT = 4;
const OSCILLATION_TIME_WINDOW_MS = 1000;
const OSCILLATION_SAFE_REASONS =
	FLOATING_LAYOUT_REASON.POSITION |
	FLOATING_LAYOUT_REASON.RESIZE |
	FLOATING_LAYOUT_REASON.SETTLE;

function enqueueFloatingLayout({
	popperElement,
	owner = null,
	reason = FLOATING_LAYOUT_REASON.POSITION,
	measure = undefined,
	mutate = undefined,
	position = undefined,
	isActive = undefined,
	onError = undefined,
} = {}) {
	if (!(popperElement instanceof HTMLElement)) {
		return Promise.resolve();
	}

	let job = jobs.get(popperElement);
	if (!job) {
		job = createJob(popperElement);
		jobs.set(popperElement, job);
	}

	job.owner = owner || job.owner;
	job.reasons |= reason;
	if (measure instanceof Function) {
		job.measure = measure;
	}
	if (mutate instanceof Function) {
		job.mutate = mutate;
	}
	if (position instanceof Function) {
		job.position = position;
	}
	job.isActive = isActive instanceof Function ? isActive : job.isActive;
	job.onError = onError instanceof Function ? onError : job.onError;

	if (job.running) {
		job.pending = true;
		return Promise.resolve();
	}

	scheduleQueueFlush();
	return job.promise;
}

function cancelFloatingLayout(popperElement) {
	const job = jobs.get(popperElement);
	if (!job) {
		return;
	}

	job.cancelled = true;
	job.resolve();
	jobs.delete(popperElement);
}

function flushFloatingLayoutQueueForTests() {
	cancelQueueFrame();
	return flushQueue();
}

function createJob(popperElement) {
	let resolve;
	const promise = new Promise((res) => {
		resolve = res;
	});

	return {
		popperElement,
		owner: null,
		reasons: 0,
		measure: null,
		mutate: null,
		position: null,
		isActive: null,
		onError: null,
		running: false,
		pending: false,
		cancelled: false,
		layoutSignatures: [],
		promise,
		resolve,
	};
}

function scheduleQueueFlush() {
	if (queueFrame !== null) {
		return;
	}

	const globalObject = getGlobal();
	const schedule =
		globalObject?.requestAnimationFrame instanceof Function
			? globalObject.requestAnimationFrame.bind(globalObject)
			: (callback) => globalObject.setTimeout(callback, 16);

	const runScheduledFlush = () => {
		if (queueFrame === null && queueTimeout === null) {
			return;
		}
		queueFrame = null;
		if (queueTimeout !== null) {
			globalObject.clearTimeout(queueTimeout);
			queueTimeout = null;
		}
		flushQueue().catch((error) => {
			setTimeout(() => {
				throw error;
			}, 0);
		});
	};

	queueFrame = schedule(runScheduledFlush);
	queueTimeout = globalObject.setTimeout(runScheduledFlush, 32);
}

function cancelQueueFrame() {
	if (queueFrame === null && queueTimeout === null) {
		return;
	}

	const globalObject = getGlobal();
	if (
		queueFrame !== null &&
		globalObject?.cancelAnimationFrame instanceof Function
	) {
		globalObject.cancelAnimationFrame(queueFrame);
	} else if (queueFrame !== null) {
		globalObject.clearTimeout(queueFrame);
	}
	queueFrame = null;
	if (queueTimeout !== null) {
		globalObject.clearTimeout(queueTimeout);
		queueTimeout = null;
	}
}

async function flushQueue() {
	if (flushing) {
		return;
	}

	flushing = true;
	const currentJobs = Array.from(jobs.values());

	for (const job of currentJobs) {
		await flushJob(job);
	}

	flushing = false;

	if (Array.from(jobs.values()).some((job) => job.pending && !job.cancelled)) {
		for (const job of jobs.values()) {
			if (job.pending) {
				job.pending = false;
			}
		}
		scheduleQueueFlush();
	}
}

async function flushJob(job) {
	if (job.cancelled || !jobs.has(job.popperElement)) {
		return;
	}

	const isActive =
		job.isActive instanceof Function ? job.isActive(job.popperElement) : true;
	if (!isActive) {
		job.resolve();
		jobs.delete(job.popperElement);
		return;
	}

	const reasons = job.reasons;
	job.reasons = 0;
	job.running = true;

	try {
		const measurement =
			job.measure instanceof Function ? job.measure(reasons) : undefined;
		if (job.mutate instanceof Function) {
			job.mutate(measurement, reasons);
		}
		if (job.position instanceof Function) {
			await job.position(measurement, reasons);
		}
	} catch (error) {
		if (job.onError instanceof Function) {
			job.onError(error);
		} else {
			throw error;
		}
	} finally {
		job.running = false;
		recordLayoutSignature(job);
		if (job.pending && shouldSuppressOscillatingPendingLayout(job)) {
			job.pending = false;
			job.reasons = 0;
		}
		if (!job.pending && job.reasons === 0) {
			job.resolve();
			jobs.delete(job.popperElement);
		}
	}
}

function recordLayoutSignature(job) {
	if (!(job?.popperElement instanceof HTMLElement)) {
		return;
	}

	const rect = job.popperElement.getBoundingClientRect();
	const signature = [
		Math.round(rect.x),
		Math.round(rect.y),
		Math.round(rect.width),
		Math.round(rect.height),
	].join("/");

	job.layoutSignatures.push({
		signature,
		time: performanceNow(),
	});
	if (job.layoutSignatures.length > OSCILLATION_HISTORY_LIMIT) {
		job.layoutSignatures.shift();
	}
}

function shouldSuppressOscillatingPendingLayout(job) {
	if ((job.reasons & ~OSCILLATION_SAFE_REASONS) !== 0) {
		return false;
	}

	const signatures = job.layoutSignatures;
	if (signatures.length < OSCILLATION_HISTORY_LIMIT) {
		return false;
	}

	const [a, b, c, d] = signatures;
	if (d.time - a.time > OSCILLATION_TIME_WINDOW_MS) {
		return false;
	}

	return (
		a.signature === c.signature &&
		b.signature === d.signature &&
		a.signature !== b.signature
	);
}

function performanceNow() {
	const globalObject = getGlobal();
	if (globalObject?.performance?.now instanceof Function) {
		return globalObject.performance.now();
	}

	return Date.now();
}
