import { createSyncularDatabase, } from '@syncular/client';
import { createContext, createElement, startTransition, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
export function createSyncularReact() {
    const Context = createContext(null);
    function SyncProvider(props) {
        const { children, client: providedClient, closeOnUnmount, onClient, onError, options, optionsKey, } = props;
        const optionsRef = useLatest(options);
        const onClientRef = useLatest(onClient);
        const onErrorRef = useLatest(onError);
        const lifecycleKey = optionsKey;
        const [value, setValue] = useState({
            client: providedClient ?? null,
            status: providedClient ? 'ready' : options ? 'loading' : 'idle',
            error: null,
        });
        useEffect(() => {
            void lifecycleKey;
            let cancelled = false;
            let ownedClient = null;
            if (providedClient) {
                setValue({ client: providedClient, status: 'ready', error: null });
                onClientRef.current?.(providedClient);
                return () => {
                    if (closeOnUnmount) {
                        void providedClient.close().catch(() => undefined);
                    }
                };
            }
            const currentOptions = optionsRef.current;
            if (!currentOptions) {
                setValue({ client: null, status: 'idle', error: null });
                return undefined;
            }
            setValue((previous) => ({
                client: previous.client,
                status: 'loading',
                error: null,
            }));
            void createSyncularDatabase(currentOptions)
                .then((client) => {
                if (cancelled) {
                    void client.close().catch(() => undefined);
                    return;
                }
                ownedClient = client;
                setValue({ client, status: 'ready', error: null });
                onClientRef.current?.(client);
            })
                .catch((error) => {
                if (cancelled)
                    return;
                setValue({ client: null, status: 'error', error });
                onErrorRef.current?.(error);
            });
            return () => {
                cancelled = true;
                if (ownedClient)
                    void ownedClient.close().catch(() => undefined);
            };
        }, [
            closeOnUnmount,
            lifecycleKey,
            onClientRef,
            onErrorRef,
            optionsRef,
            providedClient,
        ]);
        return createElement(Context.Provider, { value, children });
    }
    function useSyncContext() {
        const value = useContext(Context);
        if (!value)
            throw new Error('SyncProvider is required for Syncular hooks.');
        return { status: value.status, error: value.error };
    }
    function useClient() {
        const value = useContext(Context);
        if (!value)
            throw new Error('SyncProvider is required for Syncular hooks.');
        if (!value.client) {
            if (value.status === 'error')
                throw value.error;
            throw new Error('Syncular client is not ready yet.');
        }
        return value.client;
    }
    function useSyncStatus() {
        const client = useClient();
        const readStatus = useCallback(() => client.getStatus(), [client]);
        const getSnapshot = useStableSnapshot(readStatus);
        return useSyncExternalStore(useCallback((notify) => client.on('lifecycleChanged', () => notify()), [client]), getSnapshot, getSnapshot);
    }
    function useSyncConnection() {
        const client = useClient();
        const status = useSyncStatus();
        const state = status.connection;
        return {
            state,
            isConnected: status.isConnected,
            isReconnecting: state.realtime === 'connecting',
            reconnect: client.start,
            disconnect: client.stop,
        };
    }
    function useSyncEvent(event, listener, enabled = true) {
        const client = useClient();
        const listenerRef = useLatest(listener);
        useEffect(() => {
            if (!enabled)
                return undefined;
            return client.on(event, (payload) => listenerRef.current(payload));
        }, [client, enabled, event, listenerRef]);
    }
    function useRowsChanged(listener, options = {}) {
        const listenerRef = useLatest(listener);
        useSyncEvent('rowsChanged', (event) => {
            const tables = options.tables == null ? null : new Set(options.tables);
            if (tables && !event.changedTables.some((table) => tables.has(table))) {
                return;
            }
            listenerRef.current(event);
        }, options.enabled !== false);
    }
    function useSyncQuery(query, options = {}) {
        const client = useClient();
        const queryRef = useLatest(query);
        const optionsRef = useLatest(options);
        const enabled = options.enabled !== false;
        const deps = options.deps ?? [];
        const tables = options.watchTables ?? options.tables;
        const tablesRef = useLatest(tables);
        const tablesKey = stableStringListKey(tables);
        const watchedTables = useMemo(() => {
            void tablesKey;
            return tablesRef.current == null ? null : new Set(tablesRef.current);
        }, [tablesKey, tablesRef]);
        const [data, setData] = useState(undefined);
        const [isLoading, setIsLoading] = useState(enabled);
        const [error, setError] = useState(null);
        const [lastSyncAt, setLastSyncAt] = useState(null);
        const publishResult = useCallback((result) => {
            const publish = () => {
                setData((previous) => shareQueryResult(previous, result, optionsRef.current.keyField ?? 'id', optionsRef.current.structuralSharing !== false));
                setLastSyncAt(Date.now());
                setIsLoading(false);
            };
            if (optionsRef.current.transitionUpdates === false) {
                publish();
            }
            else {
                startTransition(publish);
            }
        }, [optionsRef]);
        const execute = useCallback(async () => {
            if (!enabled)
                return;
            if (optionsRef.current.loadingOnRefresh !== false)
                setIsLoading(true);
            setError(null);
            try {
                const result = await executeSyncQuery(client.db, queryRef.current);
                publishResult(result);
            }
            catch (caught) {
                setError(toError(caught));
                setIsLoading(false);
            }
        }, [client, enabled, optionsRef, publishResult, queryRef]);
        useEffect(() => {
            if (!enabled) {
                setIsLoading(false);
                return undefined;
            }
            void execute();
            if (!options.pollIntervalMs || options.pollIntervalMs <= 0) {
                return undefined;
            }
            const timer = setInterval(() => {
                void execute();
            }, options.pollIntervalMs);
            return () => clearInterval(timer);
        }, [enabled, execute, options.pollIntervalMs, ...deps]);
        useEffect(() => {
            if (!enabled || options.refreshOnDataChange === false)
                return undefined;
            let disposed = false;
            let fallbackUnsubscribe;
            let liveSubscription;
            const startFallback = () => {
                if (disposed || fallbackUnsubscribe)
                    return;
                fallbackUnsubscribe = client.on('rowsChanged', (event) => {
                    if (watchedTables &&
                        !event.changedTables.some((table) => watchedTables.has(table))) {
                        return;
                    }
                    void execute();
                });
            };
            const startLiveQuery = async () => {
                if (!hasLiveQueries(client)) {
                    startFallback();
                    return;
                }
                try {
                    const queryResult = await Promise.resolve(queryRef.current({
                        db: client.db,
                        selectFrom: client.db.selectFrom.bind(client.db),
                    }));
                    if (!isCompilableExecutableQuery(queryResult)) {
                        startFallback();
                        return;
                    }
                    const liveTables = tablesRef.current;
                    liveSubscription = await client.live(queryResult, {
                        ...(liveTables == null ? {} : { tables: liveTables }),
                        onChange: (rows) => {
                            if (!disposed)
                                publishResult(rows);
                        },
                    });
                }
                catch (caught) {
                    if (!disposed) {
                        setError(toError(caught));
                        setIsLoading(false);
                    }
                }
            };
            void startLiveQuery();
            return () => {
                disposed = true;
                fallbackUnsubscribe?.();
                liveSubscription?.unsubscribe();
            };
        }, [
            client,
            enabled,
            execute,
            options.refreshOnDataChange,
            publishResult,
            queryRef,
            tablesRef,
            watchedTables,
        ]);
        return {
            data,
            isLoading,
            error,
            isStale: options.staleAfterMs != null &&
                lastSyncAt != null &&
                Date.now() - lastSyncAt > options.staleAfterMs,
            lastSyncAt,
            refetch: execute,
        };
    }
    function useQuery(query, options = {}) {
        const result = useSyncQuery(query, {
            ...options,
            refreshOnDataChange: false,
            loadingOnRefresh: true,
        });
        return {
            data: result.data,
            isLoading: result.isLoading,
            error: result.error,
            refetch: result.refetch,
        };
    }
    function useMutation(options) {
        return useMutationFor('mutations', options);
    }
    function useLeasedMutation(options) {
        return useMutationFor('leasedMutations', options);
    }
    function useMutationFor(apiName, options) {
        const client = useClient();
        const optionsRef = useLatest(options);
        const [isPending, setIsPending] = useState(false);
        const [error, setError] = useState(null);
        const reset = useCallback(() => setError(null), []);
        const run = useCallback(async (operation) => {
            setIsPending(true);
            setError(null);
            try {
                const result = await operation();
                if (optionsRef.current.syncImmediately !== false) {
                    await client.sync();
                }
                optionsRef.current.onSuccess?.(result);
                return result;
            }
            catch (caught) {
                const nextError = toError(caught);
                setError(nextError);
                optionsRef.current.onError?.(nextError);
                throw nextError;
            }
            finally {
                setIsPending(false);
            }
        }, [client, optionsRef]);
        const mutate = useMemo(() => {
            const table = client[apiName].$table(options.table);
            return {
                update: (rowId, payload, mutationOptions) => run(() => table.update(rowId, payload, mutationOptions)),
                upsert: (rowId, payload, mutationOptions) => run(() => table.upsert(rowId, payload, mutationOptions)),
                delete: (rowId, mutationOptions) => run(() => table.delete(rowId, mutationOptions)),
            };
        }, [apiName, client, options.table, run]);
        const mutateMany = useCallback((inputs) => run(async () => {
            const { commit } = await client[apiName].$commit(async (tx) => {
                const table = tx[optionsRef.current.table];
                for (const input of inputs) {
                    if (input.op === 'delete') {
                        await table.delete(input.rowId, {
                            baseVersion: input.baseVersion,
                        });
                    }
                    else {
                        await table.upsert(input.rowId, input.payload ?? {}, {
                            baseVersion: input.baseVersion,
                        });
                    }
                }
            });
            return commit;
        }), [apiName, client, optionsRef, run]);
        return { mutate, mutateMany, isPending, error, reset };
    }
    function useMutations(options = {}) {
        return useMutationsFor('mutations', options);
    }
    function useLeasedMutations(options = {}) {
        return useMutationsFor('leasedMutations', options);
    }
    function useMutationsFor(apiName, options = {}) {
        const client = useClient();
        const optionsRef = useLatest(options);
        const [isPending, setIsPending] = useState(false);
        const [error, setError] = useState(null);
        const reset = useCallback(() => setError(null), []);
        const run = useCallback(async (operation, opCount, syncOverride) => {
            setIsPending(true);
            setError(null);
            try {
                const result = await operation();
                const syncMode = syncOverride ?? optionsRef.current.sync ?? 'background';
                if (syncMode === 'await') {
                    await client.sync();
                }
                else if (syncMode === 'background') {
                    void client.sync().catch(() => undefined);
                }
                optionsRef.current.onSuccess?.({ ...result, opCount });
                return result;
            }
            catch (caught) {
                const nextError = toError(caught);
                setError(nextError);
                optionsRef.current.onError?.(nextError);
                throw nextError;
            }
            finally {
                setIsPending(false);
            }
        }, [client, optionsRef]);
        return useMemo(() => new Proxy({}, {
            get(_target, prop) {
                if (prop === 'then')
                    return undefined;
                if (prop === '$isPending')
                    return isPending;
                if (prop === '$error')
                    return error;
                if (prop === '$reset')
                    return reset;
                if (prop === '$table') {
                    return (table) => wrapMutationsTable(client, apiName, table, run);
                }
                if (prop === '$commit') {
                    return async (fn, commitOptions) => {
                        let callbackResult;
                        const commit = await run(async () => {
                            const result = await client[apiName].$commit(fn);
                            callbackResult = result.result;
                            return result.commit;
                        }, 0, commitOptions?.sync);
                        return { result: callbackResult, commit };
                    };
                }
                if (typeof prop !== 'string')
                    return undefined;
                return wrapMutationsTable(client, apiName, prop, run);
            },
        }), [apiName, client, error, isPending, reset, run]);
    }
    function useOutboxStats() {
        const [stats, setStats] = useState(null);
        useSyncEvent('outboxChanged', setStats);
        return stats;
    }
    function useConflictStats() {
        const [stats, setStats] = useState(null);
        useSyncEvent('conflictsChanged', setStats);
        return stats;
    }
    function usePresence(scopeKey, options = {}) {
        const client = useClient();
        const enabled = options.enabled !== false;
        const [presence, setPresence] = useState([]);
        const [isLoading, setIsLoading] = useState(enabled);
        useEffect(() => {
            if (!enabled) {
                setPresence([]);
                setIsLoading(false);
                return undefined;
            }
            setPresence(client.presence.get(scopeKey));
            setIsLoading(false);
            return client.presence.onChange((event) => {
                if (event.scopeKey === scopeKey)
                    setPresence(event.presence);
            });
        }, [client, enabled, scopeKey]);
        return { presence, isLoading };
    }
    function usePresenceWithJoin(scopeKey, options = {}) {
        const client = useClient();
        const metadataRef = useLatest(options.metadata);
        const enabled = options.enabled !== false;
        const base = usePresence(scopeKey, { enabled });
        const [isJoined, setIsJoined] = useState(false);
        const join = useCallback((metadata) => {
            client.presence.join(scopeKey, metadata);
            setIsJoined(true);
        }, [client, scopeKey]);
        const leave = useCallback(() => {
            client.presence.leave(scopeKey);
            setIsJoined(false);
        }, [client, scopeKey]);
        const updateMetadata = useCallback((metadata) => {
            client.presence.updateMetadata(scopeKey, metadata);
        }, [client, scopeKey]);
        useEffect(() => {
            if (!enabled || options.autoJoin === false)
                return undefined;
            join(metadataRef.current);
            return () => leave();
        }, [enabled, join, leave, metadataRef, options.autoJoin]);
        return { ...base, updateMetadata, join, leave, isJoined };
    }
    function useBlobUploadQueue() {
        const client = useClient();
        const [stats, setStats] = useState(null);
        const refresh = useCallback(async () => {
            setStats(await client.blobs.getUploadQueueStats());
        }, [client]);
        const process = useCallback(async () => {
            const result = await client.blobs.processUploadQueue();
            await refresh();
            return result;
        }, [client, refresh]);
        useEffect(() => {
            void refresh().catch(() => undefined);
            return client.on('blobUploadCompleted', () => {
                void refresh().catch(() => undefined);
            });
        }, [client, refresh]);
        return { stats, refresh, process };
    }
    function useBlob(ref) {
        const client = useClient();
        const [data, setData] = useState(null);
        useEffect(() => {
            let disposed = false;
            if (!ref) {
                setData(null);
                return undefined;
            }
            void client.blobs.retrieve(ref).then((next) => {
                if (!disposed)
                    setData(next);
            });
            return () => {
                disposed = true;
            };
        }, [client, ref]);
        return data;
    }
    return {
        SyncProvider,
        useBlob,
        useBlobUploadQueue,
        useConflictStats,
        useLeasedMutation,
        useLeasedMutations,
        useMutation,
        useMutations,
        useOutboxStats,
        usePresence,
        usePresenceWithJoin,
        useQuery,
        useRowsChanged,
        useSyncConnection,
        useSyncContext,
        useSyncStatus,
        useSyncQuery,
    };
}
async function executeSyncQuery(db, query) {
    const ctx = {
        db,
        selectFrom: db.selectFrom.bind(db),
    };
    const result = await Promise.resolve(query(ctx));
    if (isExecutableQuery(result))
        return await result.execute();
    return result;
}
function isExecutableQuery(value) {
    return isRecord(value) && typeof value.execute === 'function';
}
function isCompilableExecutableQuery(value) {
    return (isExecutableQuery(value) && typeof value.compile === 'function');
}
function hasLiveQueries(client) {
    return typeof client.live === 'function';
}
function wrapMutationsTable(client, apiName, table, run) {
    const tableApi = client[apiName].$table(table);
    return {
        insert: (values) => run(() => tableApi.insert(values), 1),
        insertMany: (rows) => run(() => tableApi.insertMany(rows), rows.length),
        update: (id, patch, options) => run(() => tableApi.update(id, patch, options), 1),
        delete: (id, options) => run(() => tableApi.delete(id, options), 1),
        upsert: (id, patch, options) => run(() => tableApi.upsert(id, patch, options), 1),
    };
}
function useLatest(value) {
    const ref = useRef(value);
    ref.current = value;
    return ref;
}
function stableStringListKey(values) {
    return values == null ? '' : values.join('\u0001');
}
function useStableSnapshot(read) {
    const ref = useRef(null);
    return useCallback(() => {
        const value = read();
        const json = JSON.stringify(value);
        if (ref.current?.json === json)
            return ref.current.value;
        ref.current = { value, json };
        return value;
    }, [read]);
}
function isRecord(value) {
    return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function toError(error) {
    return error instanceof Error ? error : new Error(String(error));
}
function shareQueryResult(previous, next, keyField, enabled) {
    if (!enabled || previous === undefined)
        return next;
    if (Array.isArray(previous) && Array.isArray(next)) {
        return shareArrayResult(previous, next, keyField);
    }
    if (isRecord(previous) &&
        isRecord(next) &&
        shallowEqualRecords(previous, next)) {
        return previous;
    }
    return next;
}
function shareArrayResult(previous, next, keyField) {
    if (previous.length === 0 || next.length === 0)
        return next;
    const previousByKey = new Map();
    for (const item of previous) {
        const key = keyedValue(item, keyField);
        if (key == null)
            return next;
        previousByKey.set(key, item);
    }
    let changed = previous.length !== next.length;
    const shared = next.map((item, index) => {
        const key = keyedValue(item, keyField);
        const previousItem = key == null ? undefined : previousByKey.get(key);
        if (previousItem !== undefined &&
            isRecord(previousItem) &&
            isRecord(item) &&
            shallowEqualRecords(previousItem, item)) {
            if (!Object.is(previousItem, previous[index]))
                changed = true;
            return previousItem;
        }
        if (!Object.is(item, previous[index]))
            changed = true;
        return item;
    });
    return changed ? shared : previous;
}
function keyedValue(value, keyField) {
    if (!isRecord(value))
        return null;
    const key = value[keyField];
    return key == null ? null : String(key);
}
function shallowEqualRecords(left, right) {
    if (left === right)
        return true;
    const leftKeys = Object.keys(left);
    const rightKeys = Object.keys(right);
    if (leftKeys.length !== rightKeys.length)
        return false;
    for (const key of leftKeys) {
        if (!(key in right) || !Object.is(left[key], right[key]))
            return false;
    }
    return true;
}
export function changedRowsForTable(event, table) {
    return event.changedRows.filter((row) => row.table === table);
}
//# sourceMappingURL=index.js.map