var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
import { jsx as _jsx } from "react/jsx-runtime";
import { mixed } from '@recoiljs/refine';
import { applyPatch } from 'fast-json-patch';
import isEqual from 'lodash/isEqual';
import mapValues from 'lodash/mapValues';
import * as React from 'react';
import { generatePath, useLocation, useMatches, useNavigate, useParams } from 'react-router';
import { DefaultValue, useRecoilCallback } from 'recoil';
import { RecoilSync, syncEffect, urlSyncEffect } from 'recoil-sync';
import { useLatestRef, validateResponse } from '@darajs/ui-utils';
import {} from '../../api';
import { RequestExtrasSerializable, request } from '../../api/http';
import { handleAuthErrors } from '../../auth/auth';
import { getSessionIdentifier } from '../../auth/session-state';
import { useRouterContext } from '../../router/context';
import {} from '../../router/fetching';
import { isEmbedded } from '../../shared/utils/iframe';
import { isDerivedVariable, } from '../../types';
import { createRouteMatches, } from '../../types/core';
import { WebSocketCtx } from '../context';
// eslint-disable-next-line import/no-cycle
import { getOrRegisterDerivedVariable } from './internal';
import { atomFamilyMembersRegistry, atomFamilyRegistry, atomRegistry } from './store';
/**
 * Global map to store the extras for each store uid
 */
const STORE_EXTRAS_MAP = new Map();
/**
 * Global map from store uid to set of variable uids that use that store
 */
const STORE_VARIABLE_MAP = new Map();
/**
 * Global map to track expected sequence numbers per store for patch validation
 */
const STORE_SEQUENCE_MAP = new Map();
/**
 * Global map to track the latest read value for each store, to prevent cyclic updates
 */
const STORE_LATEST_VALUE_MAP = new Map();
/**
 * Shared item key used for the route matches store
 */
const ROUTE_MATCHES_KEY = '__route_matches';
/**
 * RecoilSync implementation for BackendStore
 *
 * - read: GET from /api/core/store/:store_uid
 * - write: POST to /api/core/store
 * - listen: subscribed to `backendStoreMessages$` on WsClient
 */
function BackendStoreSync({ children }) {
    const { client } = React.useContext(WebSocketCtx);
    const getStoreValue = React.useCallback((itemKey) => __awaiter(this, void 0, void 0, function* () {
        var _a;
        const serializableExtras = STORE_EXTRAS_MAP.get(itemKey);
        const response = yield request(`/api/core/store/${itemKey}`, {}, (_a = serializableExtras === null || serializableExtras === void 0 ? void 0 : serializableExtras.extras) !== null && _a !== void 0 ? _a : {});
        yield handleAuthErrors(response, true);
        yield validateResponse(response, `Failed to fetch the store value for key: ${itemKey}`);
        const { value, sequence_number } = yield response.json();
        STORE_LATEST_VALUE_MAP.set(itemKey, value);
        STORE_SEQUENCE_MAP.set(itemKey, sequence_number);
        return value;
    }), []);
    const syncStoreValues = React.useCallback((_a) => __awaiter(this, [_a], void 0, function* ({ diff }) {
        // keep track of extras -> diff to send each set of extras as a separate request
        const extrasMap = new Map();
        Array.from(diff.entries())
            .filter(([itemKey, value]) => !STORE_LATEST_VALUE_MAP.has(itemKey) || STORE_LATEST_VALUE_MAP.get(itemKey) !== value)
            .forEach(([itemKey, value]) => {
            STORE_LATEST_VALUE_MAP.set(itemKey, value);
            const extras = STORE_EXTRAS_MAP.get(itemKey);
            if (!extrasMap.has(extras)) {
                extrasMap.set(extras, {});
            }
            // store the value in the extras map
            extrasMap.get(extras)[itemKey] = value;
        });
        function sendRequest(serializableExtras, storeDiff) {
            return __awaiter(this, void 0, void 0, function* () {
                const response = yield request(`/api/core/store`, {
                    body: JSON.stringify({
                        values: storeDiff,
                        ws_channel: yield client.getChannel(),
                    }),
                    method: 'POST',
                }, serializableExtras.extras);
                yield handleAuthErrors(response, true);
                yield validateResponse(response, `Failed to sync the store values`);
            });
        }
        // Send a request with each different set of extras
        yield Promise.allSettled(Array.from(extrasMap.entries()).map(([serializableExtras, storeDiff]) => sendRequest(serializableExtras, storeDiff)));
    }), [client]);
    const applyPatchesToAtoms = useRecoilCallback(({ snapshot, set }) => (storeUid, patches, sequenceNumber) => __awaiter(this, void 0, void 0, function* () {
        // Validate sequence number to ensure UI state is in sync with backend
        const expectedSequence = STORE_SEQUENCE_MAP.get(storeUid) || 0;
        if (sequenceNumber !== expectedSequence + 1) {
            // eslint-disable-next-line no-console
            console.warn(`Sequence number mismatch for store ${storeUid}. Expected: ${expectedSequence + 1}, Got: ${sequenceNumber}. Rejecting patch.`);
            // Could trigger a full state refresh here in the future
            return;
        }
        // Update expected sequence number
        STORE_SEQUENCE_MAP.set(storeUid, sequenceNumber);
        // Get all variable UIDs that use this store
        const variableUids = STORE_VARIABLE_MAP.get(storeUid);
        if (!variableUids) {
            return;
        }
        // Collect all atoms that need patching
        const atomsToUpdate = [];
        for (const variableUid of variableUids) {
            // Check if there's a direct atom
            const directAtom = atomRegistry.get(variableUid);
            if (directAtom) {
                atomsToUpdate.push({ atom: directAtom, variableUid });
                continue;
            }
            // Check if there's an atom family
            const atomFamily = atomFamilyRegistry.get(variableUid);
            if (atomFamily) {
                const familyMembers = atomFamilyMembersRegistry.get(atomFamily);
                if (familyMembers) {
                    // Add all instances of this atom family
                    for (const [, atomInstance] of familyMembers) {
                        atomsToUpdate.push({ atom: atomInstance, variableUid });
                    }
                }
            }
        }
        // Batch read all current values
        const currentValues = yield Promise.all(atomsToUpdate.map((_a) => __awaiter(this, [_a], void 0, function* ({ atom, variableUid }) {
            try {
                return {
                    atom,
                    variableUid,
                    currentValue: yield snapshot.getPromise(atom),
                    error: null,
                };
            }
            catch (error) {
                return {
                    atom,
                    variableUid,
                    currentValue: null,
                    error: error,
                };
            }
        })));
        const applyPatchToValue = (currentValue, variableUid) => {
            try {
                return applyPatch(currentValue, patches, false, false).newDocument;
            }
            catch (error) {
                // eslint-disable-next-line no-console
                console.warn(`Failed to apply patch to atom ${variableUid}:`, error);
                return currentValue; // Return unchanged value on error
            }
        };
        // Apply patches and set new values
        currentValues.forEach(({ atom, variableUid, currentValue, error }) => {
            if (error) {
                // eslint-disable-next-line no-console
                console.warn(`Failed to read current value for atom ${variableUid}:`, error);
                return;
            }
            const patchedValue = applyPatchToValue(currentValue, variableUid);
            set(atom, patchedValue);
            STORE_LATEST_VALUE_MAP.set(storeUid, patchedValue);
        });
    }), []);
    const listenToStoreChanges = React.useCallback(({ updateItem }) => {
        if (!client) {
            return;
        }
        // Subscribe to regular value updates
        const valueSub = client.backendStoreMessages$().subscribe((message) => {
            // Set sequence number when full value is received
            STORE_SEQUENCE_MAP.set(message.store_uid, message.sequence_number);
            updateItem(message.store_uid, message.value);
            STORE_LATEST_VALUE_MAP.set(message.store_uid, message.value);
        });
        // Subscribe to patch updates
        const patchSub = client.backendStorePatchMessages$().subscribe((message) => {
            // Apply patches directly to atoms instead of going through RecoilSync
            applyPatchesToAtoms(message.store_uid, message.patches, message.sequence_number);
        });
        return () => {
            valueSub.unsubscribe();
            patchSub.unsubscribe();
        };
    }, [client, applyPatchesToAtoms]);
    return (_jsx(RecoilSync, { listen: listenToStoreChanges, read: getStoreValue, storeKey: "BackendStore", write: syncStoreValues, children: children }));
}
/**
 * Create a syncEffect for BackendStore
 *
 * @param variable variable to create the atom effect for
 * @param requestExtras extras object to create the effect for; used to pass through correct extras to write requests
 */
function backendStoreEffect(variable, requestExtras) {
    // Assumption: the set of extras is unique to the store, i.e. the variable will not be used under different sets of extras
    STORE_EXTRAS_MAP.set(variable.store.uid, requestExtras);
    // Register this variable as using this store for patch handling
    if (!STORE_VARIABLE_MAP.has(variable.store.uid)) {
        STORE_VARIABLE_MAP.set(variable.store.uid, new Set());
    }
    STORE_VARIABLE_MAP.get(variable.store.uid).add(variable.uid);
    return syncEffect({
        /** Use store uid as the unique identifier */
        itemKey: variable.store.uid,
        refine: mixed(),
        storeKey: 'BackendStore',
        write({ write }, newValue) {
            // If store is read-only, do not write - this is a no-op
            if (variable.store.readonly) {
                return;
            }
            // skip writing default values
            if (newValue instanceof DefaultValue) {
                return;
            }
            write(variable.store.uid, newValue);
        },
    });
}
/**
 * Get the session key used to persist a variable value
 *
 * @param uid uid of the variable to persist
 */
export function getSessionKey(uid) {
    // If we're within an IFrame (Jupyter)
    if (isEmbedded()) {
        return `dara-session-${window.frameElement.dataset.daraPageId}-var-${uid}`;
    }
    return `dara-session-${getSessionIdentifier()}-var-${uid}`;
}
/**
 * RecoilSync implementation for BrowserStore
 *
 * localStorage keys are generated via @see getSessionKey
 *
 * - read: read from localStorage
 * - write: write to localStorage
 * - listen: subscribe to 'storage' event for cross-tab or cross-window syncing
 */
function BrowserStoreSync({ children }) {
    const getStoreValue = React.useCallback((itemKey) => {
        var _a;
        const key = getSessionKey(itemKey);
        return JSON.parse((_a = localStorage.getItem(key)) !== null && _a !== void 0 ? _a : 'null');
    }, []);
    const syncStoreValues = React.useCallback(({ diff }) => {
        for (const [itemKey, value] of diff.entries()) {
            const key = getSessionKey(itemKey);
            localStorage.setItem(key, JSON.stringify(value));
        }
    }, []);
    /**
     * Listen to storage events and update the store when a change is detected.
     *
     * This is useful to keep the store in sync across tabs.
     */
    const listenToStoreChanges = React.useCallback(({ updateItem }) => {
        const listener = (e) => {
            var _a;
            if (e.storageArea === localStorage) {
                if (e.key) {
                    // check if the key matches our dara-session key
                    const match = e.key.match(/^dara-session-(.*)-var-(.*)$/);
                    if (match) {
                        const [, sessionToken, uid] = match;
                        if (sessionToken === getSessionIdentifier()) {
                            updateItem(uid, JSON.parse((_a = e.newValue) !== null && _a !== void 0 ? _a : 'null'));
                        }
                    }
                }
            }
        };
        window.addEventListener('storage', listener);
        return () => {
            window.removeEventListener('storage', listener);
        };
    }, []);
    return (_jsx(RecoilSync, { listen: listenToStoreChanges, read: getStoreValue, storeKey: "BrowserStore", write: syncStoreValues, children: children }));
}
export function PathParamSync({ children }) {
    const navigate = useNavigate();
    const location = useLocation();
    const locationRef = useLatestRef(location);
    const matches = useMatches();
    const matchesRef = useLatestRef(matches);
    // Keep track of matches in a ref.
    // This lets us mutate it in the `write` part and have `read` return the latest value.
    const params = useParams();
    const paramsRef = React.useRef(params);
    const locationSubscribers = React.useRef([]);
    React.useLayoutEffect(() => {
        // skip running subs when the params are the same
        if (isEqual(paramsRef.current, params)) {
            return;
        }
        paramsRef.current = params;
        for (const subscriber of locationSubscribers.current) {
            subscriber(params);
        }
    }, [paramsRef, params]);
    const getStoreValue = React.useCallback((paramName) => {
        var _a;
        return (_a = paramsRef.current[paramName]) !== null && _a !== void 0 ? _a : null;
    }, [paramsRef]);
    const writeStoreValue = React.useCallback((_a) => __awaiter(this, [_a], void 0, function* ({ diff }) {
        const newParams = mapValues(Object.assign(Object.assign({}, paramsRef.current), Object.fromEntries(diff)), (v) => String(v));
        // use the last match to get the full path,
        // needed to get the full path with placeholders e.g.
        // /foo/blogs/:blogId/posts/:postId
        const loaderData = matchesRef.current.at(-1).loaderData;
        const data = loaderData.data instanceof Promise ? yield loaderData.data : loaderData.data;
        const fullPathname = data.route_definition.full_path;
        // navigate once, building the full pathname with updated params
        // NOTE: This assumes the path param names are unique across matches
        // which we currently validate in the router compilation step
        navigate({
            pathname: generatePath(fullPathname, newParams),
            // preserve current search
            search: locationRef.current.search,
        });
    }), [matchesRef, navigate, paramsRef, locationRef]);
    const listenToStoreChanges = React.useCallback(({ updateItems }) => {
        function handleUpdate(newParams) {
            // NOTE: using updateItems instead of updateAllKnownItems
            // We don't want to reset params that become null because that can happen while
            // navigating away from a page, which would trigger derived values to re-run
            // for no reason.
            updateItems(new Map(Object.entries(newParams)));
        }
        locationSubscribers.current.push(handleUpdate);
        return () => {
            locationSubscribers.current = locationSubscribers.current.filter((item) => item !== handleUpdate);
        };
    }, []);
    return (_jsx(RecoilSync, { listen: listenToStoreChanges, read: getStoreValue, write: writeStoreValue, storeKey: "_PathParamStore", children: children }));
}
export function RouteMatchSync({ children }) {
    const routerCtx = useRouterContext();
    const matches = useMatches();
    const matchesRef = React.useRef(matches);
    const matchesSubscribers = React.useRef([]);
    React.useLayoutEffect(() => {
        // skip running subs when the matches are the same
        if (isEqual(matchesRef.current, matches)) {
            return;
        }
        matchesRef.current = matches;
        for (const subscriber of matchesSubscribers.current) {
            subscriber(matches);
        }
    }, [matchesRef, matches]);
    const getStoreValue = React.useCallback(() => {
        return createRouteMatches(matchesRef.current, routerCtx.routeDefMap);
    }, [matchesRef, routerCtx.routeDefMap]);
    const listenToStoreChanges = React.useCallback(({ updateAllKnownItems }) => {
        function handleUpdate(newMatches) {
            const newRouteMatches = createRouteMatches(newMatches, routerCtx.routeDefMap);
            updateAllKnownItems(new Map([[ROUTE_MATCHES_KEY, newRouteMatches]]));
        }
        matchesSubscribers.current.push(handleUpdate);
        return () => {
            matchesSubscribers.current = matchesSubscribers.current.filter((item) => item !== handleUpdate);
        };
    }, [routerCtx.routeDefMap]);
    return (
    // NOTE: write is not defined as we don't support updating the route matches
    _jsx(RecoilSync, { listen: listenToStoreChanges, read: getStoreValue, storeKey: "_RouteMatchStore", children: children }));
}
/**
 * Create a syncEffect for BrowserStore
 *
 * @param variable variable to create effect for
 */
function localStorageEffect(variable, extrasSerializable, wsClient, taskContext) {
    let firstRun = false;
    return syncEffect({
        itemKey: variable.uid,
        read: ({ read }) => {
            const readValue = read(variable.uid);
            if (!firstRun) {
                firstRun = true;
                // during first run fall back to the default value if the read value is null
                // as local storage might not have been initialized yet
                if (!readValue) {
                    const isDefaultDerived = isDerivedVariable(variable.default);
                    return isDefaultDerived ?
                        getOrRegisterDerivedVariable(variable.default, wsClient, taskContext, extrasSerializable.extras)
                        : variable.default;
                }
            }
            return readValue;
        },
        refine: mixed(),
        storeKey: 'BrowserStore',
    });
}
function urlEffect(variable) {
    return urlSyncEffect({
        history: 'push',
        itemKey: variable.store.query,
        refine: mixed(),
    });
}
function pathParamEffect(variable) {
    const itemKey = variable.store.param_name;
    const effect = syncEffect({
        itemKey,
        refine: mixed(),
        storeKey: '_PathParamStore',
    });
    return (effectParams) => {
        const cleanup = effect(effectParams);
        // NOTE: this is a hack to immediately commit the value of the atom to the recoil snapshot
        // Without this, the first time we try to read it in e.g. an action, the value is null
        // I wasn't able to find why this happens, but this seems to work.
        // `valueOrThrow` should be safe here as we always initialize the atom in the syncEffect.
        effectParams.setSelf(effectParams.getLoadable(effectParams.node).valueOrThrow());
        return () => {
            cleanup === null || cleanup === void 0 ? void 0 : cleanup();
        };
    };
}
/**
 * Global effect that syncs variables connected to the current route matches in the router
 */
function routeMatchEffect() {
    const effect = syncEffect({
        itemKey: ROUTE_MATCHES_KEY,
        refine: mixed(),
        storeKey: '_RouteMatchStore',
    });
    return (effectParams) => {
        const cleanup = effect(effectParams);
        // NOTE: this is a hack to immediately commit the value of the atom to the recoil snapshot
        // Without this, the first time we try to read it in e.g. an action, the value is null
        // I wasn't able to find why this happens, but this seems to work.
        // `valueOrThrow` should be safe here as we always initialize the atom in the syncEffect.
        effectParams.setSelf(effectParams.getLoadable(effectParams.node).valueOrThrow());
        return () => {
            cleanup === null || cleanup === void 0 ? void 0 : cleanup();
        };
    };
}
export const STORES = {
    BackendStore: {
        effect: backendStoreEffect,
    },
    BrowserStore: {
        effect: localStorageEffect,
    },
    QueryParamStore: {
        effect: urlEffect,
    },
    _PathParamStore: {
        effect: pathParamEffect,
    },
    _RouteMatchStore: {
        effect: routeMatchEffect,
    },
};
/**
 * Get the effect creator function for a given variable
 *
 * Looks up correct definition based on variable.store type
 *
 * @param variable variable to get effect for
 */
export function getEffect(variable) {
    var _a, _b, _c;
    const storeName = (_a = variable.store) === null || _a === void 0 ? void 0 : _a.__typename;
    if (!storeName || !(storeName in STORES)) {
        return null;
    }
    // Type assertion is safe here because we've checked the key exists
    return (_c = (_b = STORES[storeName]) === null || _b === void 0 ? void 0 : _b.effect) !== null && _c !== void 0 ? _c : null;
}
/**
 * Wrapper for the store providers.
 *
 * Applies all STORES around the children.
 */
export function StoreProviders({ children }) {
    // this could be a loop if STORES is dynamically extensible but static for now
    // NOTE: the path/url sync is applied separately as it needs to be wrapped around the router
    return (_jsx(BackendStoreSync, { children: _jsx(BrowserStoreSync, { children: children }) }));
}
//# sourceMappingURL=persistence.js.map