// Adopted from Vector 2022
/** @module SectionObserver */

/**
 * @callback OnIntersection
 * @param {HTMLElement} element The section that triggered the new intersection change.
 */

/**
 * @typedef {Object} SectionObserverProps
 * @property {NodeList} elements A list of HTML elements to observe for
 * intersection changes. This list can be updated through the `elements` setter.
 * @property {OnIntersection} onIntersection Called when a new intersection is observed.
 * @property {number} [topMargin] The number of pixels to shrink the top of
 * the viewport's bounding box before calculating intersections. This is useful
 * for sticky elements (e.g. sticky headers). Defaults to 0 pixels.
 * @property {number} [throttleMs] The number of milliseconds that the scroll
 * handler should be throttled.
 */

/**
 * @callback initSectionObserver
 * @param {SectionObserverProps} props
 * @return {SectionObserver}
 */

/**
 * Observe intersection changes with the viewport for one or more elements. This
 * is intended to be used with the headings in the content so that the
 * corresponding section(s) in the table of contents can be "activated" (e.g.
 * bolded).
 *
 * When sectionObserver notices a new intersection change, the
 * `props.onIntersection` callback will be fired with the corresponding section
 * as a param.
 *
 * Because sectionObserver uses a scroll event listener (in combination with
 * IntersectionObserver), the changes are throttled to a default maximum rate of
 * 200ms so that the main thread is not excessively blocked.
 * IntersectionObserver is used to asynchronously calculate the positions of the
 * observed tags off the main thread and in a manner that does not cause
 * expensive forced synchronous layouts.
 *
 * @param {SectionObserverProps} props
 * @return {SectionObserver}
 */
module.exports = function sectionObserver( props ) {
	props = Object.assign( {
		topMargin: 0,
		throttleMs: 200,
		onIntersection: () => {}
	}, props );

	let /** @type {number | undefined} */ timeoutId;
	let /** @type {HTMLElement | undefined} */ current;

	// eslint-disable-next-line compat/compat
	const observer = new IntersectionObserver( ( entries ) => {
		let /** @type {IntersectionObserverEntry | undefined} */ closestNegativeEntry;
		let /** @type {IntersectionObserverEntry | undefined} */ closestPositiveEntry;
		const topMargin = /** @type {number} */ ( props.topMargin );

		entries.forEach( ( entry ) => {
			const top =
					entry.boundingClientRect.top - topMargin;
			if (
				top > 0 &&
				(
					closestPositiveEntry === undefined ||
					top < closestPositiveEntry.boundingClientRect.top - topMargin
				)
			) {
				closestPositiveEntry = entry;
			}

			if (
				top <= 0 &&
				(
					closestNegativeEntry === undefined ||
					top > closestNegativeEntry.boundingClientRect.top - topMargin
				)
			) {
				closestNegativeEntry = entry;
			}
		} );

		const closestTag =
			/** @type {HTMLElement} */ ( closestNegativeEntry ? closestNegativeEntry.target :
				/** @type {IntersectionObserverEntry} */ ( closestPositiveEntry ).target
			);

		// If the intersection is new, fire the `onIntersection` callback.
		if ( current !== closestTag ) {
			props.onIntersection( closestTag );
		}
		current = closestTag;

		// When finished finding the intersecting element, stop observing all
		// observed elements. The scroll event handler will be responsible for
		// throttling and reobserving the elements again. Because we don't have a
		// wrapper element around our content headings and their children, we can't
		// rely on IntersectionObserver (which is optimized to detect intersecting
		// elements *within* the viewport) to reliably fire this callback without
		// this manual step. Instead, we offload the work of calculating the
		// position of each element in an efficient manner to IntersectionObserver,
		// but do not use it to detect when a new element has entered the viewport.
		observer.disconnect();
	} );

	/**
	 * Calculate the intersection of each observed element.
	 */
	function calcIntersection() {
		// IntersectionObserver will asynchronously calculate the boundingClientRect
		// of each observed element off the main thread after `observe` is called.
		props.elements.forEach( ( element ) => {
			if ( !element.parentNode ) {
				mw.log.warn( 'Element being observed is not in DOM', element );
				return;
			}
			observer.observe( /** @type {HTMLElement} */ ( element ) );
		} );
	}

	function handleScroll() {
		// Throttle the scroll event handler to fire at a rate limited by `props.throttleMs`.
		if ( !timeoutId ) {
			timeoutId = window.setTimeout( () => {
				calcIntersection();
				timeoutId = undefined;
			}, props.throttleMs );
		}
	}

	function bindScrollListener() {
		window.addEventListener( 'scroll', handleScroll );
	}

	function unbindScrollListener() {
		window.removeEventListener( 'scroll', handleScroll );
	}

	/**
	 * Pauses intersection observation until `resume` is called.
	 */
	function pause() {
		unbindScrollListener();
		clearTimeout( timeoutId );
		timeoutId = undefined;
		// Assume current is no longer valid while paused.
		current = undefined;
	}

	/**
	 * Resumes intersection observation.
	 */
	function resume() {
		bindScrollListener();
	}

	/**
	 * Cleans up event listeners and intersection observer. Should be called when
	 * the observer is permanently no longer needed.
	 */
	function unmount() {
		unbindScrollListener();
		observer.disconnect();
	}

	/**
	 * Set a list of HTML elements to observe for intersection changes.
	 *
	 * @param {NodeList} list
	 */
	function setElements( list ) {
		props.elements = list;
	}

	bindScrollListener();

	/**
	 * @typedef {Object} SectionObserver
	 * @property {calcIntersection} calcIntersection
	 * @property {pause} pause
	 * @property {resume} resume
	 * @property {unmount} unmount
	 * @property {setElements} setElements
	 */
	return {
		calcIntersection,
		pause,
		resume,
		unmount,
		setElements
	};
};
