/**
 * Tracks the first/last visible tile indices inside the attached scroll
 * container so the host can render only `[first - BUFFER, last + BUFFER]`
 * and leave the rest unmounted.
 *
 * Implementation: rAF-throttled scroll listener that scans the shell
 * elements (every photo renders a `[data-uid-shell]` div regardless of
 * the windowing window) and reports the first/last shell whose bounding
 * rect intersects the scroll root.
 *
 * Why not an IntersectionObserver? Two real-world breakages:
 *
 *   1. Late tile registration on remount. With cached photo data, the
 *      host's child shells mount in the same pass as the scroll root,
 *      racing the observer setup → registrations silently drop, observer
 *      sees nothing, the window never updates.
 *   2. Observer dead zone on fast scroll. The observer fires only when a
 *      sample tile crosses the root boundary. If the user flicks the
 *      scroll faster than the host's buffer can extend the mounted set,
 *      every sample tile leaves the viewport before the next one is
 *      mounted, the host's `onChange` stops firing, and the timeline
 *      goes blank.
 *
 * Querying shells directly sidesteps both: shells are always mounted,
 * the scan happens on every scroll tick, and the result is the true
 * first/last regardless of how fast the user dragged.
 *
 * Use it like:
 *
 *   <main use:visibleRange={{
 *     onChange: (f, l) => { range.first = f; range.last = l; }
 *   }}>
 *     {#each photos as p, i}
 *       <div data-uid-shell={p.UID}>
 *         {#if i >= range.first - BUFFER && i <= range.last + BUFFER}
 *           <Tile {p} />
 *         {/if}
 *       </div>
 *     {/each}
 *   </main>
 *
 * The `register`/`unregister` handle is kept for backwards compatibility
 * with the existing host but is now a no-op — shell-based scanning
 * doesn't need per-tile enrolment.
 */

export interface VisibleRangeParams {
	onChange: (first: number, last: number) => void;
	/** Retained for backwards compatibility — shell-scan ignores it. */
	sampleEvery?: number;
	/** CSS margin string for legacy callers; shell-scan ignores it. */
	rootMargin?: string;
}

export interface VisibleRangeHandle {
	register(el: HTMLElement, index: number): void;
	unregister(el: HTMLElement): void;
}

export function visibleRange(node: HTMLElement, params: VisibleRangeParams) {
	let current = params;
	let lastFirst = -1;
	let lastLast = -1;
	let rafId: number | null = null;

	// Between frames the visible band can shift by at most ~one viewport
	// of shells (any further and it's a programmatic jump, which falls
	// back to a full sweep below). 300 covers a fast-flick on the densest
	// thumbnail preset (XS) plus a buffer; tightening it further saves
	// little and risks missing the new band after a quick scroll-wheel
	// flick.
	const SCAN_MARGIN = 300;

	function scanFrom(
		shells: NodeListOf<HTMLElement>,
		rootRect: DOMRect,
		start: number
	): [number, number] {
		let first = -1;
		let last = -1;
		for (let i = start; i < shells.length; i++) {
			const r = shells[i].getBoundingClientRect();
			if (r.bottom < rootRect.top) continue;
			if (r.top > rootRect.bottom) break;
			if (first === -1) first = i;
			last = i;
		}
		return [first, last];
	}

	function compute() {
		rafId = null;
		const shells = node.querySelectorAll<HTMLElement>('[data-uid-shell]');
		if (shells.length === 0) return;
		const rootRect = node.getBoundingClientRect();
		// Anchor the sweep around the previous result so a deep timeline
		// doesn't pay `getBoundingClientRect()` × (every-shell-above-the-
		// viewport) on every scroll tick. Previous loop scanned from 0
		// each time → quadratic-feeling on long sessions with 1000+
		// loaded photos.
		const startHint = lastFirst >= 0 ? Math.max(0, lastFirst - SCAN_MARGIN) : 0;
		let [first, last] = scanFrom(shells, rootRect, startHint);
		// Bounded scan missed the band — user scrolled past the hint
		// margin (programmatic jump, filter-reset reflow, etc.). Fall
		// back to a single full sweep to re-anchor. Costs the same as
		// the old behavior on this one frame, then bounded scans take
		// over again.
		if (first === -1 && startHint > 0) {
			[first, last] = scanFrom(shells, rootRect, 0);
		}
		if (first === -1 || last === -1) return;
		if (first === lastFirst && last === lastLast) return;
		lastFirst = first;
		lastLast = last;
		current.onChange(first, last);
	}

	function schedule() {
		if (rafId !== null) return;
		rafId = requestAnimationFrame(compute);
	}

	// Initial measurement. Two rAFs because the first runs *during* the
	// current frame's mount cycle — shells may not have computed layout
	// yet, so `getBoundingClientRect` returns zeros. Bouncing once more
	// lets the browser finish layout before we measure.
	requestAnimationFrame(() => requestAnimationFrame(compute));

	node.addEventListener('scroll', schedule, { passive: true });
	// Resize / content changes (new pages loaded, sidebar toggled,
	// thumbnail size flipped) also shift the visible band — recompute.
	const ro = new ResizeObserver(schedule);
	ro.observe(node);

	// No-op handle preserved so host code (`tileRegister`) doesn't need
	// to change shape. Shell-scan reads geometry directly; per-tile
	// registration isn't needed.
	const handle: VisibleRangeHandle = {
		register() {},
		unregister() {}
	};
	(node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange = handle;

	return {
		update(next: VisibleRangeParams) {
			current = next;
		},
		destroy() {
			if (rafId !== null) cancelAnimationFrame(rafId);
			node.removeEventListener('scroll', schedule);
			ro.disconnect();
			delete (node as HTMLElement & { __visibleRange?: VisibleRangeHandle })
				.__visibleRange;
		}
	};
}

/** Read the handle the action stashed on the scroll-root node. Kept for
 *  callers that still want the (now-no-op) register/unregister surface;
 *  new callers can ignore this entirely. */
export function getVisibleRangeHandle(node: HTMLElement | undefined): VisibleRangeHandle | null {
	if (!node) return null;
	return (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange ?? null;
}
