import { equal } from "@wry/equality";
import { Trie } from "@wry/trie";
import { Kind } from "graphql";
import { addTypenameToDocument, argumentsObjectFromField, canonicalStringify, cloneDeep, getDefaultValues, getFragmentFromSelection, getOperationDefinition, getTypenameFromResult, isField, isNonEmptyArray, isReference, makeReference, resultKeyNameFromField, shouldInclude, } from "@apollo/client/utilities";
import { __DEV__ } from "@apollo/client/utilities/environment";
import { invariant, newInvariantError, } from "@apollo/client/utilities/invariant";
import { extractFragmentContext, fieldNameFromStoreName, isArray, makeProcessedFieldsMerger, storeValueIsStoreObject, } from "./helpers.js";
import { normalizeReadFieldOptions } from "./policies.js";
// Since there are only four possible combinations of context.clientOnly and
// context.deferred values, we should need at most four "flavors" of any given
// WriteContext. To avoid creating multiple copies of the same context, we cache
// the contexts in the context.flavors Map (shared by all flavors) according to
// their clientOnly and deferred values (always in that order).
function getContextFlavor(context, clientOnly, deferred) {
    const key = `${clientOnly}${deferred}`;
    let flavored = context.flavors.get(key);
    if (!flavored) {
        context.flavors.set(key, (flavored =
            context.clientOnly === clientOnly && context.deferred === deferred ?
                context
                : {
                    ...context,
                    clientOnly,
                    deferred,
                }));
    }
    return flavored;
}
export class StoreWriter {
    cache;
    reader;
    fragments;
    constructor(cache, reader, fragments) {
        this.cache = cache;
        this.reader = reader;
        this.fragments = fragments;
    }
    writeToStore(store, { query, result, dataId, variables, overwrite, }) {
        const operationDefinition = getOperationDefinition(query);
        const merger = makeProcessedFieldsMerger();
        variables = {
            ...getDefaultValues(operationDefinition),
            ...variables,
        };
        const context = {
            store,
            written: {},
            merge(existing, incoming) {
                return merger.merge(existing, incoming);
            },
            variables: variables,
            varString: canonicalStringify(variables),
            ...extractFragmentContext(query, this.fragments),
            overwrite: !!overwrite,
            incomingById: new Map(),
            clientOnly: false,
            deferred: false,
            flavors: new Map(),
        };
        const ref = this.processSelectionSet({
            result: result || {},
            dataId,
            selectionSet: operationDefinition.selectionSet,
            mergeTree: { map: new Map() },
            context,
        });
        if (!isReference(ref)) {
            throw newInvariantError(90, result);
        }
        // So far, the store has not been modified, so now it's time to process
        // context.incomingById and merge those incoming fields into context.store.
        context.incomingById.forEach(({ storeObject, mergeTree, fieldNodeSet }, dataId) => {
            const entityRef = makeReference(dataId);
            if (mergeTree && mergeTree.map.size) {
                const applied = this.applyMerges(mergeTree, entityRef, storeObject, context);
                if (isReference(applied)) {
                    // Assume References returned by applyMerges have already been merged
                    // into the store. See makeMergeObjectsFunction in policies.ts for an
                    // example of how this can happen.
                    return;
                }
                // Otherwise, applyMerges returned a StoreObject, whose fields we should
                // merge into the store (see store.merge statement below).
                storeObject = applied;
            }
            if (__DEV__ && !context.overwrite) {
                const fieldsWithSelectionSets = {};
                fieldNodeSet.forEach((field) => {
                    if (field.selectionSet) {
                        fieldsWithSelectionSets[field.name.value] = true;
                    }
                });
                const hasSelectionSet = (storeFieldName) => fieldsWithSelectionSets[fieldNameFromStoreName(storeFieldName)] ===
                    true;
                const hasMergeFunction = (storeFieldName) => {
                    const childTree = mergeTree && mergeTree.map.get(storeFieldName);
                    return Boolean(childTree && childTree.info && childTree.info.merge);
                };
                Object.keys(storeObject).forEach((storeFieldName) => {
                    // If a merge function was defined for this field, trust that it
                    // did the right thing about (not) clobbering data. If the field
                    // has no selection set, it's a scalar field, so it doesn't need
                    // a merge function (even if it's an object, like JSON data).
                    if (hasSelectionSet(storeFieldName) &&
                        !hasMergeFunction(storeFieldName)) {
                        warnAboutDataLoss(entityRef, storeObject, storeFieldName, context.store);
                    }
                });
            }
            store.merge(dataId, storeObject);
        });
        // Any IDs written explicitly to the cache will be retained as
        // reachable root IDs for garbage collection purposes. Although this
        // logic includes root IDs like ROOT_QUERY and ROOT_MUTATION, their
        // retainment counts are effectively ignored because cache.gc() always
        // includes them in its root ID set.
        store.retain(ref.__ref);
        return ref;
    }
    processSelectionSet({ dataId, result, selectionSet, context, 
    // This object allows processSelectionSet to report useful information
    // to its callers without explicitly returning that information.
    mergeTree, }) {
        const { policies } = this.cache;
        // This variable will be repeatedly updated using context.merge to
        // accumulate all fields that need to be written into the store.
        let incoming = {};
        // If typename was not passed in, infer it. Note that typename is
        // always passed in for tricky-to-infer cases such as "Query" for
        // ROOT_QUERY.
        const typename = (dataId && policies.rootTypenamesById[dataId]) ||
            getTypenameFromResult(result, selectionSet, context.fragmentMap) ||
            (dataId && context.store.get(dataId, "__typename"));
        if ("string" === typeof typename) {
            incoming.__typename = typename;
        }
        // This readField function will be passed as context.readField in the
        // KeyFieldsContext object created within policies.identify (called below).
        // In addition to reading from the existing context.store (thanks to the
        // policies.readField(options, context) line at the very bottom), this
        // version of readField can read from Reference objects that are currently
        // pending in context.incomingById, which is important whenever keyFields
        // need to be extracted from a child object that processSelectionSet has
        // turned into a Reference.
        const readField = (...args) => {
            const options = normalizeReadFieldOptions(args, incoming, context.variables);
            if (isReference(options.from)) {
                const info = context.incomingById.get(options.from.__ref);
                if (info) {
                    const result = policies.readField({
                        ...options,
                        from: info.storeObject,
                    }, context);
                    if (result !== void 0) {
                        return result;
                    }
                }
            }
            return policies.readField(options, context);
        };
        const fieldNodeSet = new Set();
        this.flattenFields(selectionSet, result, 
        // This WriteContext will be the default context value for fields returned
        // by the flattenFields method, but some fields may be assigned a modified
        // context, depending on the presence of @client and other directives.
        context, typename).forEach((context, field) => {
            const resultFieldKey = resultKeyNameFromField(field);
            const value = result[resultFieldKey];
            fieldNodeSet.add(field);
            if (value !== void 0) {
                const storeFieldName = policies.getStoreFieldName({
                    typename,
                    fieldName: field.name.value,
                    field,
                    variables: context.variables,
                });
                const childTree = getChildMergeTree(mergeTree, storeFieldName);
                let incomingValue = this.processFieldValue(value, field, 
                // Reset context.clientOnly and context.deferred to their default
                // values before processing nested selection sets.
                field.selectionSet ?
                    getContextFlavor(context, false, false)
                    : context, childTree);
                // To determine if this field holds a child object with a merge function
                // defined in its type policy (see PR #7070), we need to figure out the
                // child object's __typename.
                let childTypename;
                // The field's value can be an object that has a __typename only if the
                // field has a selection set. Otherwise incomingValue is scalar.
                if (field.selectionSet &&
                    (isReference(incomingValue) || storeValueIsStoreObject(incomingValue))) {
                    childTypename = readField("__typename", incomingValue);
                }
                const merge = policies.getMergeFunction(typename, field.name.value, childTypename);
                if (merge) {
                    childTree.info = {
                        // TODO Check compatibility against any existing childTree.field?
                        field,
                        typename,
                        merge,
                    };
                }
                else {
                    maybeRecycleChildMergeTree(mergeTree, storeFieldName);
                }
                incoming = context.merge(incoming, {
                    [storeFieldName]: incomingValue,
                });
            }
            else if (__DEV__ &&
                !context.clientOnly &&
                !context.deferred &&
                !addTypenameToDocument.added(field) &&
                // If the field has a read function, it may be a synthetic field or
                // provide a default value, so its absence from the written data should
                // not be cause for alarm.
                !policies.getReadFunction(typename, field.name.value)) {
                __DEV__ && invariant.error(91, resultKeyNameFromField(field), result);
            }
        });
        // Identify the result object, even if dataId was already provided,
        // since we always need keyObject below.
        try {
            const [id, keyObject] = policies.identify(result, {
                typename,
                selectionSet,
                fragmentMap: context.fragmentMap,
                storeObject: incoming,
                readField,
            });
            // If dataId was not provided, fall back to the id just generated by
            // policies.identify.
            dataId = dataId || id;
            // Write any key fields that were used during identification, even if
            // they were not mentioned in the original query.
            if (keyObject) {
                // TODO Reverse the order of the arguments?
                incoming = context.merge(incoming, keyObject);
            }
        }
        catch (e) {
            // If dataId was provided, tolerate failure of policies.identify.
            if (!dataId)
                throw e;
        }
        if ("string" === typeof dataId) {
            const dataRef = makeReference(dataId);
            // Avoid processing the same entity object using the same selection
            // set more than once. We use an array instead of a Set since most
            // entity IDs will be written using only one selection set, so the
            // size of this array is likely to be very small, meaning indexOf is
            // likely to be faster than Set.prototype.has.
            const sets = context.written[dataId] || (context.written[dataId] = []);
            if (sets.indexOf(selectionSet) >= 0)
                return dataRef;
            sets.push(selectionSet);
            // If we're about to write a result object into the store, but we
            // happen to know that the exact same (===) result object would be
            // returned if we were to reread the result with the same inputs,
            // then we can skip the rest of the processSelectionSet work for
            // this object, and immediately return a Reference to it.
            if (this.reader &&
                this.reader.isFresh(result, dataRef, selectionSet, context)) {
                return dataRef;
            }
            const previous = context.incomingById.get(dataId);
            if (previous) {
                previous.storeObject = context.merge(previous.storeObject, incoming);
                previous.mergeTree = mergeMergeTrees(previous.mergeTree, mergeTree);
                fieldNodeSet.forEach((field) => previous.fieldNodeSet.add(field));
            }
            else {
                context.incomingById.set(dataId, {
                    storeObject: incoming,
                    // Save a reference to mergeTree only if it is not empty, because
                    // empty MergeTrees may be recycled by maybeRecycleChildMergeTree and
                    // reused for entirely different parts of the result tree.
                    mergeTree: mergeTreeIsEmpty(mergeTree) ? void 0 : mergeTree,
                    fieldNodeSet,
                });
            }
            return dataRef;
        }
        return incoming;
    }
    processFieldValue(value, field, context, mergeTree) {
        if (!field.selectionSet || value === null) {
            // In development, we need to clone scalar values so that they can be
            // safely frozen with maybeDeepFreeze in readFromStore.ts. In production,
            // it's cheaper to store the scalar values directly in the cache.
            return __DEV__ ? cloneDeep(value) : value;
        }
        if (isArray(value)) {
            return value.map((item, i) => {
                const value = this.processFieldValue(item, field, context, getChildMergeTree(mergeTree, i));
                maybeRecycleChildMergeTree(mergeTree, i);
                return value;
            });
        }
        return this.processSelectionSet({
            result: value,
            selectionSet: field.selectionSet,
            context,
            mergeTree,
        });
    }
    // Implements https://spec.graphql.org/draft/#sec-Field-Collection, but with
    // some additions for tracking @client and @defer directives.
    flattenFields(selectionSet, result, context, typename = getTypenameFromResult(result, selectionSet, context.fragmentMap)) {
        const fieldMap = new Map();
        const { policies } = this.cache;
        const limitingTrie = new Trie(false); // No need for WeakMap, since limitingTrie does not escape.
        (function flatten(selectionSet, inheritedContext) {
            const visitedNode = limitingTrie.lookup(selectionSet, 
            // Because we take inheritedClientOnly and inheritedDeferred into
            // consideration here (in addition to selectionSet), it's possible for
            // the same selection set to be flattened more than once, if it appears
            // in the query with different @client and/or @directive configurations.
            inheritedContext.clientOnly, inheritedContext.deferred);
            if (visitedNode.visited)
                return;
            visitedNode.visited = true;
            selectionSet.selections.forEach((selection) => {
                if (!shouldInclude(selection, context.variables))
                    return;
                let { clientOnly, deferred } = inheritedContext;
                if (
                // Since the presence of @client or @defer on this field can only
                // cause clientOnly or deferred to become true, we can skip the
                // forEach loop if both clientOnly and deferred are already true.
                !(clientOnly && deferred) &&
                    isNonEmptyArray(selection.directives)) {
                    selection.directives.forEach((dir) => {
                        const name = dir.name.value;
                        if (name === "client")
                            clientOnly = true;
                        if (name === "defer") {
                            const args = argumentsObjectFromField(dir, context.variables);
                            // The @defer directive takes an optional args.if boolean
                            // argument, similar to @include(if: boolean). Note that
                            // @defer(if: false) does not make context.deferred false, but
                            // instead behaves as if there was no @defer directive.
                            if (!args || args.if !== false) {
                                deferred = true;
                            }
                            // TODO In the future, we may want to record args.label using
                            // context.deferred, if a label is specified.
                        }
                    });
                }
                if (isField(selection)) {
                    const existing = fieldMap.get(selection);
                    if (existing) {
                        // If this field has been visited along another recursive path
                        // before, the final context should have clientOnly or deferred set
                        // to true only if *all* paths have the directive (hence the &&).
                        clientOnly = clientOnly && existing.clientOnly;
                        deferred = deferred && existing.deferred;
                    }
                    fieldMap.set(selection, getContextFlavor(context, clientOnly, deferred));
                }
                else {
                    const fragment = getFragmentFromSelection(selection, context.lookupFragment);
                    if (!fragment && selection.kind === Kind.FRAGMENT_SPREAD) {
                        throw newInvariantError(92, selection.name.value);
                    }
                    if (fragment &&
                        policies.fragmentMatches(fragment, typename, result, context.variables)) {
                        flatten(fragment.selectionSet, getContextFlavor(context, clientOnly, deferred));
                    }
                }
            });
        })(selectionSet, context);
        return fieldMap;
    }
    applyMerges(mergeTree, existing, incoming, context, getStorageArgs) {
        if (mergeTree.map.size && !isReference(incoming)) {
            const e = 
            // Items in the same position in different arrays are not
            // necessarily related to each other, so when incoming is an array
            // we process its elements as if there was no existing data.
            (!isArray(incoming) &&
                // Likewise, existing must be either a Reference or a StoreObject
                // in order for its fields to be safe to merge with the fields of
                // the incoming object.
                (isReference(existing) || storeValueIsStoreObject(existing))) ?
                existing
                : void 0;
            // This narrowing is implied by mergeTree.map.size > 0 and
            // !isReference(incoming), though TypeScript understandably cannot
            // hope to infer this type.
            const i = incoming;
            // The options.storage objects provided to read and merge functions
            // are derived from the identity of the parent object plus a
            // sequence of storeFieldName strings/numbers identifying the nested
            // field name path of each field value to be merged.
            if (e && !getStorageArgs) {
                getStorageArgs = [isReference(e) ? e.__ref : e];
            }
            // It's possible that applying merge functions to this subtree will
            // not change the incoming data, so this variable tracks the fields
            // that did change, so we can create a new incoming object when (and
            // only when) at least one incoming field has changed. We use a Map
            // to preserve the type of numeric keys.
            let changedFields;
            const getValue = (from, name) => {
                return (isArray(from) ?
                    typeof name === "number" ?
                        from[name]
                        : void 0
                    : context.store.getFieldValue(from, String(name)));
            };
            mergeTree.map.forEach((childTree, storeFieldName) => {
                const eVal = getValue(e, storeFieldName);
                const iVal = getValue(i, storeFieldName);
                // If we have no incoming data, leave any existing data untouched.
                if (void 0 === iVal)
                    return;
                if (getStorageArgs) {
                    getStorageArgs.push(storeFieldName);
                }
                const aVal = this.applyMerges(childTree, eVal, iVal, context, getStorageArgs);
                if (aVal !== iVal) {
                    changedFields = changedFields || new Map();
                    changedFields.set(storeFieldName, aVal);
                }
                if (getStorageArgs) {
                    invariant(getStorageArgs.pop() === storeFieldName);
                }
            });
            if (changedFields) {
                // Shallow clone i so we can add changed fields to it.
                incoming = (isArray(i) ? i.slice(0) : { ...i });
                changedFields.forEach((value, name) => {
                    incoming[name] = value;
                });
            }
        }
        if (mergeTree.info) {
            return this.cache.policies.runMergeFunction(existing, incoming, mergeTree.info, context, getStorageArgs && context.store.getStorage(...getStorageArgs));
        }
        return incoming;
    }
}
const emptyMergeTreePool = [];
function getChildMergeTree({ map }, name) {
    if (!map.has(name)) {
        map.set(name, emptyMergeTreePool.pop() || { map: new Map() });
    }
    return map.get(name);
}
function mergeMergeTrees(left, right) {
    if (left === right || !right || mergeTreeIsEmpty(right))
        return left;
    if (!left || mergeTreeIsEmpty(left))
        return right;
    const info = left.info && right.info ?
        {
            ...left.info,
            ...right.info,
        }
        : left.info || right.info;
    const needToMergeMaps = left.map.size && right.map.size;
    const map = needToMergeMaps ? new Map()
        : left.map.size ? left.map
            : right.map;
    const merged = { info, map };
    if (needToMergeMaps) {
        const remainingRightKeys = new Set(right.map.keys());
        left.map.forEach((leftTree, key) => {
            merged.map.set(key, mergeMergeTrees(leftTree, right.map.get(key)));
            remainingRightKeys.delete(key);
        });
        remainingRightKeys.forEach((key) => {
            merged.map.set(key, mergeMergeTrees(right.map.get(key), left.map.get(key)));
        });
    }
    return merged;
}
function mergeTreeIsEmpty(tree) {
    return !tree || !(tree.info || tree.map.size);
}
function maybeRecycleChildMergeTree({ map }, name) {
    const childTree = map.get(name);
    if (childTree && mergeTreeIsEmpty(childTree)) {
        emptyMergeTreePool.push(childTree);
        map.delete(name);
    }
}
const warnings = new Set();
// Note that this function is unused in production, and thus should be
// pruned by any well-configured minifier.
function warnAboutDataLoss(existingRef, incomingObj, storeFieldName, store) {
    const getChild = (objOrRef) => {
        const child = store.getFieldValue(objOrRef, storeFieldName);
        return typeof child === "object" && child;
    };
    const existing = getChild(existingRef);
    if (!existing)
        return;
    const incoming = getChild(incomingObj);
    if (!incoming)
        return;
    // It's always safe to replace a reference, since it refers to data
    // safely stored elsewhere.
    if (isReference(existing))
        return;
    // If the values are structurally equivalent, we do not need to worry
    // about incoming replacing existing.
    if (equal(existing, incoming))
        return;
    // If we're replacing every key of the existing object, then the
    // existing data would be overwritten even if the objects were
    // normalized, so warning would not be helpful here.
    if (Object.keys(existing).every((key) => store.getFieldValue(incoming, key) !== void 0)) {
        return;
    }
    const parentType = store.getFieldValue(existingRef, "__typename") ||
        store.getFieldValue(incomingObj, "__typename");
    const fieldName = fieldNameFromStoreName(storeFieldName);
    const typeDotName = `${parentType}.${fieldName}`;
    // Avoid warning more than once for the same type and field name.
    if (warnings.has(typeDotName))
        return;
    warnings.add(typeDotName);
    const childTypenames = [];
    // Arrays do not have __typename fields, and always need a custom merge
    // function, even if their elements are normalized entities.
    if (!isArray(existing) && !isArray(incoming)) {
        [existing, incoming].forEach((child) => {
            const typename = store.getFieldValue(child, "__typename");
            if (typeof typename === "string" && !childTypenames.includes(typename)) {
                childTypenames.push(typename);
            }
        });
    }
    __DEV__ && invariant.warn(93, fieldName, parentType, childTypenames.length ?
        "either ensure all objects of type " +
            childTypenames.join(" and ") +
            " have an ID or a custom merge function, or "
        : "", typeDotName, { ...existing }, { ...incoming });
}
//# sourceMappingURL=writeToStore.js.map
