"use strict";

/** @typedef {(...args: unknown[]) => unknown} AnyFn */

module.exports = (globalTimeout = 2000, nameSuffix = "") => {
	const state = global.JEST_STATE_SYMBOL;
	/** @type {import("@jest/types").Circus.DescribeBlock} */
	let currentDescribeBlock;
	/** @type {import("@jest/types").Circus.TestEntry | null | undefined} */
	let currentlyRunningTest;
	let runTests = -1;
	/** @type {(() => void)[]} */
	const disposables = [];

	// this function allows to release memory in fn context
	// manually, usually after the suite has been run.
	/**
	 * @param {AnyFn | undefined} fn test or hook function
	 * @param {boolean=} isTest whether this wraps a test (vs a hook)
	 * @returns {((done: AnyFn) => void) | (() => unknown) | null} wrapped function
	 */
	const createDisposableFn = (fn, isTest) => {
		if (!fn) return null;
		/** @type {AnyFn | null} */
		let f = fn;
		const rfn =
			f.length >= 1
				? (/** @type {AnyFn} */ done) => {
						/** @type {AnyFn} */
						(f)((/** @type {unknown[]} */ ...args) => {
							if (isTest) runTests++;
							done(...args);
						});
					}
				: () => {
						const r = /** @type {AnyFn} */ (f)();
						if (isTest) runTests++;
						return r;
					};
		disposables.push(() => {
			f = null;
		});
		return rfn;
	};

	describe(
		nameSuffix ? `exported tests ${nameSuffix}` : "exported tests",
		() => {
			// this must have a child to be handled correctly
			it("should run the exported tests", () => {
				runTests++;
			});
			afterAll((done) => {
				for (const dispose of disposables) {
					dispose();
				}
				done();
			});
			currentDescribeBlock = state.currentDescribeBlock;
			currentlyRunningTest = state.currentlyRunningTest;
		}
	);
	let numberOfTests = 0;
	/**
	 * @param {() => void} fn function to run inside the exported suite scope
	 * @returns {void}
	 */
	const inSuite = (fn) => {
		const {
			currentDescribeBlock: oldCurrentDescribeBlock,
			currentlyRunningTest: oldCurrentlyRunningTest,
			hasStarted: oldHasStarted
		} = state;
		state.currentDescribeBlock = currentDescribeBlock;
		state.currentlyRunningTest = currentlyRunningTest;
		state.hasStarted = false;
		try {
			fn();
		} catch (err) {
			/* eslint-disable no-unused-expressions */
			// avoid leaking memory
			/** @type {Error} */
			(err).stack;
			/* eslint-enable no-unused-expressions */
			throw err;
		}
		state.currentDescribeBlock = oldCurrentDescribeBlock;
		state.currentlyRunningTest = oldCurrentlyRunningTest;
		state.hasStarted = oldHasStarted;
	};
	/**
	 * @param {import("@jest/types").Circus.TestEntry | import("@jest/types").Circus.Hook} block test or hook entry
	 * @returns {void}
	 */
	const fixAsyncError = (block) => {
		// By default jest leaks memory as it stores asyncError
		// for each "it" call to track the origin test suite
		// We want to evaluate this early here to avoid leaking memory
		block.asyncError = {
			stack: block.asyncError.stack
		};
	};
	return {
		/**
		 * @param {number} time time
		 */
		setDefaultTimeout(time) {
			globalTimeout = time;
		},
		getNumberOfTests() {
			return numberOfTests;
		},
		it(/** @type {unknown[]} */ ...args) {
			numberOfTests++;
			if (runTests >= numberOfTests) throw new Error("it called too late");
			args[1] = createDisposableFn(
				/** @type {AnyFn | undefined} */ (args[1]),
				true
			);
			args[2] = args[2] || globalTimeout;
			inSuite(() => {
				// @ts-expect-error expected
				it(...args);
				fixAsyncError(
					currentDescribeBlock.tests[currentDescribeBlock.tests.length - 1]
				);
			});
		},
		beforeEach(/** @type {unknown[]} */ ...args) {
			if (runTests >= numberOfTests) {
				throw new Error("beforeEach called too late");
			}
			args[0] = createDisposableFn(/** @type {AnyFn | undefined} */ (args[0]));
			inSuite(() => {
				// @ts-expect-error expected
				beforeEach(...args);
				fixAsyncError(
					currentDescribeBlock.hooks[currentDescribeBlock.hooks.length - 1]
				);
			});
		},
		afterEach(/** @type {unknown[]} */ ...args) {
			if (runTests >= numberOfTests) {
				throw new Error("afterEach called too late");
			}
			args[0] = createDisposableFn(/** @type {AnyFn | undefined} */ (args[0]));
			inSuite(() => {
				// @ts-expect-error expected
				afterEach(...args);
				fixAsyncError(
					currentDescribeBlock.hooks[currentDescribeBlock.hooks.length - 1]
				);
			});
		}
	};
};
