import type { RealtimeChannel, SupabaseClient } from '@supabase/supabase-js';

/**
 * Subscribe to `postgres_changes` on a table + optional filter for a given
 * client, and return the channel plus a promise-based `waitFor` that resolves
 * on the next event matching the given predicate. Always call `unsubscribe()`
 * in a `finally` block — the WebSocket stays open otherwise and leaks across
 * tests.
 *
 * The `SUBSCRIBED` handshake is awaited before the function resolves, so
 * callers can start mutating immediately and trust that events will be caught.
 */
export async function subscribePostgresChanges<T = Record<string, unknown>>(
	client: SupabaseClient,
	params: {
		table: string;
		schema?: string;
		filter?: string;
		event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*';
		channelName?: string;
	}
): Promise<{
	channel: RealtimeChannel;
	events: Array<{ eventType: string; new: T; old: T | null }>;
	waitFor: (
		predicate: (evt: { eventType: string; new: T; old: T | null }) => boolean,
		timeoutMs?: number
	) => Promise<{ eventType: string; new: T; old: T | null }>;
	unsubscribe: () => Promise<void>;
}> {
	const events: Array<{ eventType: string; new: T; old: T | null }> = [];
	const waiters: Array<{
		predicate: (e: { eventType: string; new: T; old: T | null }) => boolean;
		resolve: (e: { eventType: string; new: T; old: T | null }) => void;
	}> = [];

	const channel = client.channel(params.channelName ?? `test:${params.table}:${Date.now()}`);

	channel.on(
		// @ts-expect-error — supabase-js types require a specific literal-union, filter
		//                   is a runtime string so the narrow overload complains
		'postgres_changes',
		{
			event: params.event ?? '*',
			schema: params.schema ?? 'public',
			table: params.table,
			...(params.filter ? { filter: params.filter } : {})
		},
		(payload: { eventType: string; new: T; old: T | null }) => {
			const evt = { eventType: payload.eventType, new: payload.new, old: payload.old };
			events.push(evt);
			for (let i = waiters.length - 1; i >= 0; i--) {
				if (waiters[i].predicate(evt)) {
					waiters[i].resolve(evt);
					waiters.splice(i, 1);
				}
			}
		}
	);

	await new Promise<void>((resolve, reject) => {
		const timeout = setTimeout(() => reject(new Error('Realtime SUBSCRIBED timeout')), 10_000);
		channel.subscribe((status) => {
			if (status === 'SUBSCRIBED') {
				clearTimeout(timeout);
				// The Phoenix socket reports SUBSCRIBED slightly before the backend has
				// fully propagated the filter to the WAL subscription. Without a small
				// settle delay, a mutation fired immediately after subscribe can miss
				// the filter and never broadcast. 250ms is empirically enough under
				// full `just test-all` load.
				setTimeout(resolve, 250);
			} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
				clearTimeout(timeout);
				reject(new Error(`Realtime subscribe failed: ${status}`));
			}
		});
	});

	function waitFor(
		predicate: (e: { eventType: string; new: T; old: T | null }) => boolean,
		// 10s default: under load (full `just test-all`), the Phoenix socket can take
		// a beat to propagate a fresh subscription's filter before the mutation fires.
		// Passing runs resolve immediately on event arrival — this only bounds the flake.
		timeoutMs = 10_000
	) {
		const existing = events.find(predicate);
		if (existing) return Promise.resolve(existing);
		return new Promise<{ eventType: string; new: T; old: T | null }>((resolve, reject) => {
			const t = setTimeout(() => reject(new Error('waitFor timeout')), timeoutMs);
			waiters.push({
				predicate,
				resolve: (e) => {
					clearTimeout(t);
					resolve(e);
				}
			});
		});
	}

	async function unsubscribe() {
		await channel.unsubscribe();
	}

	return { channel, events, waitFor, unsubscribe };
}
