/**
 * Indicates where a change on a  {@link MergableObject} originates. Either remotely or locally.
 */
export var MergableObjectEventSource;
(function (MergableObjectEventSource) {
    /** Remote (backend) modification of {@link MergableObject} */
    MergableObjectEventSource[MergableObjectEventSource["Remote"] = 1] = "Remote";
    /** Local (browser) modification of {@link MergableObject} */
    MergableObjectEventSource[MergableObjectEventSource["Local"] = 2] = "Local";
})(MergableObjectEventSource || (MergableObjectEventSource = {}));
/**
 * Describes the states a mergable object may have. A mergable object is an object that has a local and a remote representation. Changes made to the local object
 * shall be synchronized to the remote object and vice versa. In same cases conflicts may arise because changes have been made to both the local and the remote
 * representation of the object.
 */
export var MergableObjectState;
(function (MergableObjectState) {
    /**
     * Object is not modified. Only possible if the object has had or have a remote counterpart.
     * */
    MergableObjectState[MergableObjectState["Unchanged"] = 0] = "Unchanged";
    /**
     * Object has been added to {@link MergableSet} via {@link MergableSet.add} but has not yet been saved (in which case it shall become {@link MergableObjectState.Unchanged}).
     * It is not possible to transition directly to {@link MergableObjectState.Modified} from this state as there is (probably) not yet a remote counterpart.
     * In the unlikely case there is a remote counterpart we will have to deal with a conflict. Somebody has added the exact same object remotely
     */
    MergableObjectState[MergableObjectState["Added"] = 1] = "Added";
    /**
     * Local object has been modified. Only possible if the local object has had or have a remote counterpart. A object can only get this state if
     * {@link MergableSet.update} is called and the object previously existed in {@link MergableSet}. Do note that if a object is updated because
     * its remote counterpart has changed then the object will retain its {@link MergableObjectState.Unchanged} state.
     */
    MergableObjectState[MergableObjectState["Modified"] = 2] = "Modified";
    /**
     * Object is marked for deletion. If the object has no remote counterpart it is removed immediately. Otherwise it will be removed on next save. (at which point its remote counterpart will disappear).
     */
    MergableObjectState[MergableObjectState["Deleted"] = 4] = "Deleted";
})(MergableObjectState || (MergableObjectState = {}));
/**
 * Reason for a conflict between a local and a remote object
 * Note: There is no state represented by 0.
 */
export var MergableObjectConflict;
(function (MergableObjectConflict) {
    /**
     * No conflict.
     */
    MergableObjectConflict[MergableObjectConflict["None"] = 0] = "None";
    /**
     * The local object differs from the remote object somehow
     */
    MergableObjectConflict[MergableObjectConflict["RemoteDiffers"] = 1] = "RemoteDiffers";
    /**
     * The remote object was deleted while the local object was modified.
     */
    MergableObjectConflict[MergableObjectConflict["RemoteDeleted"] = 2] = "RemoteDeleted";
})(MergableObjectConflict || (MergableObjectConflict = {}));
/**
 * Describes the relation between a local object and its remote counterpart. Also gives
 * the state for the local object and (if exists) a conflict reason between the local and the
 * remote object. A conflict typically arises when the local object has been modified and so has
 * the remote object.
 * Example: local object was modified but remote object was deleted. This conflict cannot be resolved automatically.
 */
export class MergableObjectWithState {
    local;
    _state;
    _conflictReason;
    remote;
    onStateChange;
    constructor(
    /**
     * local object.
     */
    local, _state, _conflictReason, 
    /**
     * Remote object (if there is one)
     */
    remote) {
        this.local = local;
        this._state = _state;
        this._conflictReason = _conflictReason;
        this.remote = remote;
    }
    /**
     * If defined then there is a conflict between the local and the remote object.
     */
    get conflictReason() {
        return this._conflictReason;
    }
    /**
     * Sets conflict reason (if any).
     */
    set conflictReason(v) {
        this._conflictReason = v;
        if (this.onStateChange) {
            this.onStateChange(this);
        }
    }
    /**
     * `true` if a conflict exists. Otherwise `false`
     */
    get hasConflict() {
        return this.conflictReason !== MergableObjectConflict.None;
    }
    /**
     * get current state of the local object.
     */
    get state() {
        return this._state;
    }
    /**
     * set current state of the local object.
     */
    set state(v) {
        this._state = v;
        if (this.onStateChange) {
            this.onStateChange(this);
        }
    }
}
/**
 * Represents a collection of object that has a local and a remote representation.
 * Changes are always made to the local representation of the object. However it is possible
 * to merge remote representations of the objects. When a merge occurs new local objects may be added, updated and deleted
 * In some cases conflicts may arise. Such as when a local object has been modified but the remote object has been deleted.
 */
export class MergableSet {
    _objectsById = new Map();
    _conflicts = new Map();
    _objectsByState = new Map();
    _onStateChangeBoundToThis = this.onStateChange.bind(this);
    /**
     * Get number of conflicts in the set.
     * @returns Number of conflicts.
     */
    get conflictCount() {
        return this._conflicts.size;
    }
    /**
     * Gets all local objects and their remote counterparts. Includes both state and conflict reasons (if any)
     * @returns All objects.
     */
    get objects() {
        return this._objectsById.values();
    }
    /**
     * Gets all conflicts.
     * @returns All conflicts.
     */
    get conflicts() {
        const conflicts = [];
        for (const uuid of this._conflicts.keys()) {
            const tmp = this._objectsById.get(uuid);
            if (tmp) {
                conflicts.push(tmp);
            }
        }
        return conflicts;
    }
    /**
     * Gets number of objects that are not marked as {@link MergableObjectState.Unchanged}.
     * @returns Number of changed objects.
     */
    get changeCount() {
        let changeCount = 0;
        for (const [state, ids] of this._objectsByState.entries()) {
            if (state !== MergableObjectState.Unchanged) {
                changeCount += ids.size;
            }
        }
        return changeCount;
    }
    /**
     * Checks if there are any changed objects in the set.
     * @returns `true` if there areobject not marked as {@link MergableObjectState.Unchanged}.
     */
    get hasChanges() {
        return this.changeCount > 0;
    }
    /**
     * Clear set
     */
    clear() {
        this._conflicts.clear();
        for (const o of this._objectsById.values()) {
            o.remote = undefined;
            o.conflictReason = MergableObjectConflict.None;
            o.local.onDelete({ eventSource: MergableObjectEventSource.Local, local: o.local, state: o.state });
        }
        this._objectsById.clear();
        this._objectsByState.clear();
    }
    /**
     * Get object with specified id.
     * @param id Id of object to get.
     */
    get(id) {
        return this._objectsById.get(id);
    }
    /**
     * Check if object with specified id exists.
     * @param id Id of matching object.
     * @returns `true` if object exists. Otherwise `false`.
     */
    has(id) {
        return this._objectsById.has(id);
    }
    /**
     * Deletes a object
     * @param local Id of object to delete
     * @returns `true` if object was deleted. Otherwise `false`
     */
    delete(local) {
        const uuid = typeof local === 'string' ? local : local.id;
        const existingState = this.get(uuid);
        if (!existingState || existingState.state === MergableObjectState.Deleted) {
            return false;
        }
        if (existingState.state === MergableObjectState.Added) {
            // Added items can only exist locally so remove it completely
            // from collection of objects.
            this._objectsById.delete(uuid);
            this._conflicts.delete(uuid);
            existingState.conflictReason = MergableObjectConflict.None;
            existingState.state = MergableObjectState.Deleted; // trigger item.onStateChange
            existingState.local.onDelete({
                eventSource: MergableObjectEventSource.Local,
                local: existingState.local,
                state: existingState.state
            });
            return true;
        }
        this._conflicts.delete(uuid);
        existingState.conflictReason = MergableObjectConflict.None;
        existingState.state = MergableObjectState.Deleted; // trigger item.onStateChange
        existingState.local.onDelete({
            eventSource: MergableObjectEventSource.Local,
            local: existingState.local,
            state: existingState.state
        });
        return true;
    }
    /**
     * Adds an object. If object already exists, it will not be added. An object exists for items where
     * {@link MergableObject.id} already exists.
     * @param o Object to add
     * @returns `true` if object was added, otherwise `false`.
     */
    add(o) {
        if (this.has(o.id)) {
            return false;
        }
        this._conflicts.delete(o.id);
        const item = new MergableObjectWithState(o, MergableObjectState.Added, MergableObjectConflict.None);
        item.onStateChange = this._onStateChangeBoundToThis;
        this._objectsById.set(o.id, item);
        item.state = MergableObjectState.Added; // trigger item.onStatechange
        o.onAdded({ eventSource: MergableObjectEventSource.Local, local: item.local, state: item.state });
        return true;
    }
    /**
     * Updates an object that already exists.
     * @param id Id of object to update
     * @param updateAction function called with {@link MergableObject} instance of specified id.
     * Caller should make any required modfifications to the object in this function.
     * @returns `true` if object did exist, otherwise `false`.
     */
    update(id, updateAction) {
        const existingState = this.get(id);
        if (!existingState) {
            return false;
        }
        // Updates the item with data.
        updateAction(existingState.local);
        this._conflicts.delete(id);
        existingState.conflictReason = MergableObjectConflict.None;
        if (existingState.state !== MergableObjectState.Added) {
            // item can only become modified after it has been saved at least
            // once (in which case it becomes unchanged and can then transition to modified)
            // therefore if it has state added all we do is resolve any conflicts and notify that
            // update has taken place. In other words a object that is Added cannot become
            // Modified.
            existingState.state = MergableObjectState.Modified;
        }
        else {
            existingState.state = MergableObjectState.Added; // Trigger onStatechange
        }
        existingState.local.onUpdate({
            eventSource: MergableObjectEventSource.Local,
            local: existingState.local,
            state: existingState.state
        });
        return true;
    }
    /**
     * Merges remote objects.
     * @param remoteObjects Remote objects to merge.
     * @returns Number of conflicts that occured during the merge. May not be
     * same as {@link conflictCount} (which gives all conflicts from previous merges as well.).
     */
    mergeRemotes(remoteObjects) {
        let conflictCount = 0;
        const notInRemote = new Map(this._objectsById);
        for (const newRemoteObject of remoteObjects) {
            notInRemote.delete(newRemoteObject.id);
            const item = this._objectsById.get(newRemoteObject.id);
            // remote objects can only set following states
            // * conflict
            // * unchanged
            // * deleted
            if (!item?.local) {
                // item does not exist locally. Notify that object has been added. However do note that the state
                // of both the local and the remote object is unchanged
                const tmp = new MergableObjectWithState(newRemoteObject, MergableObjectState.Unchanged, MergableObjectConflict.None, 
                // remote object must be a clone of the local object since modifications of
                // the local object should not be mirrored to the remote
                newRemoteObject.clone());
                tmp.onStateChange = this._onStateChangeBoundToThis;
                this._objectsById.set(newRemoteObject.id, tmp);
                tmp.state = MergableObjectState.Unchanged; // trigger tmp.onStateChange
                newRemoteObject.onAdded({
                    eventSource: MergableObjectEventSource.Remote,
                    remote: tmp.remote,
                    local: newRemoteObject,
                    state: tmp.state,
                    conflictReason: MergableObjectConflict.None
                });
                continue;
            }
            // When we get here we know that item.local !== undefined
            if (!item.remote) {
                // We have never fetched a remote item before but we do have a local match for the newly fetched remote.
                // Hence if the local object differs from the remote object then we must have a conflict.
                // Can for example happens if we have added a local object with id=X, When we load remote objects
                // for the first time we discover that there is also a remote object with id=X. if
                // both the new remote object and the local object differs, then we have a conflict (regardless of the state on the local object)
                // May also happen if another user deletes the item remotely. (on reload we set item.remote = undefined).
                // then another user readds the same object (remotely) and then we do a new reload.
                item.remote = newRemoteObject;
                if (!newRemoteObject.isEqual(item.local)) {
                    item.conflictReason = MergableObjectConflict.RemoteDiffers;
                    this._conflicts.set(item.local.id, item.conflictReason);
                    item.local.onUpdate({
                        eventSource: MergableObjectEventSource.Remote,
                        remote: item.remote,
                        local: item.local,
                        conflictReason: item.conflictReason,
                        state: item.state
                    });
                    conflictCount++;
                }
                continue;
            }
            // Ok we know that item.local !== undefined and item.remote !== undefined
            const areRemoteObjectsEqual = item.remote.isEqual(newRemoteObject);
            if (areRemoteObjectsEqual) {
                // If both remotes are equal then that means that the remote object has
                // already been fetched once and any events for it has already been raised
                // hence we should NOT update the local object, set states or raise events
                continue;
            }
            // If we get here then newRemote and current remote object are NOT equal. If the local
            // object is equal to the newRemote object then there is no reason to raise events or
            // change a state etc.
            item.remote = newRemoteObject;
            if (newRemoteObject.isEqual(item.local)) {
                continue;
            }
            // Remember if we get here then
            // item.local !== undefined && item.remote != newRemoteObject && newRemoteObject !== item.local
            if (item.state === MergableObjectState.Unchanged) {
                // unchanged objects kan simply be updated even if they differ because by definition nobody has changed
                // the object locally. Hence no conflict is possible.
                item.local.onUpdate({
                    eventSource: MergableObjectEventSource.Remote,
                    remote: item.remote,
                    local: item.local,
                    state: item.state,
                    conflictReason: MergableObjectConflict.None
                });
                continue;
            }
            // Ok the local object is NOT the same as the remote object. Also they local object
            // has a state that indicates that it has been changed somehow. Hence we have a conflict which
            // must be resolved.
            // When conflict is resolved the remote object is not changed. only (possibly) the local object
            // and the state is set to modified, added or whatever. This means that on next reload
            // the newRemoteObject and item.remote will be equal and we will not end up here.
            item.conflictReason = MergableObjectConflict.RemoteDiffers;
            this._conflicts.set(item.local.id, item.conflictReason);
            item.local.onUpdate({
                eventSource: MergableObjectEventSource.Remote,
                remote: item.remote,
                local: item.local,
                conflictReason: item.conflictReason,
                state: item.state
            });
            conflictCount++;
        }
        // Client objects not available in remote objects are counted as deleted if they are not
        // marked as changed in client.
        for (const nIr of notInRemote.values()) {
            nIr.remote = undefined;
            if (nIr.state === MergableObjectState.Deleted) {
                continue;
            }
            if (nIr.state === MergableObjectState.Added) {
                // A locally added object will obviously NOT be
                // in the remote object collection hence and it should
                // not be deleted because of that
                continue;
            }
            if (nIr.state === MergableObjectState.Unchanged) {
                nIr.remote = undefined;
                nIr.state = MergableObjectState.Deleted;
                nIr.local.onDelete({
                    eventSource: MergableObjectEventSource.Remote,
                    state: nIr.state,
                    local: nIr.local,
                    conflictReason: MergableObjectConflict.None
                });
                continue;
            }
            // Object is locally modified and remotely deleted. We have a conflict
            nIr.conflictReason = MergableObjectConflict.RemoteDeleted;
            this._conflicts.set(nIr.local.id, nIr.conflictReason);
            nIr.remote = undefined;
            nIr.local.onDelete({
                eventSource: MergableObjectEventSource.Remote,
                conflictReason: nIr.conflictReason,
                local: nIr.local,
                state: nIr.state
            });
            conflictCount++;
        }
        return conflictCount;
    }
    /**
     * Accepts changes in the set. Is only successful if no conflicts exist.
     * @returns `true` if no conflicts existed so changes can be accepted. Otherwise, `false`.
     */
    acceptChanges() {
        if (this.conflictCount) {
            return false;
        }
        if (!this.hasChanges) {
            return true;
        }
        for (const o of this._objectsById.values()) {
            if (o.state === MergableObjectState.Deleted) {
                this._objectsById.delete(o.local.id);
            }
            o.state = MergableObjectState.Unchanged;
            o.remote = o.local.clone();
            o.conflictReason = MergableObjectConflict.None;
        }
        return true;
    }
    onStateChange(p) {
        let ids = this._objectsByState.get(p.state);
        if (!ids) {
            ids = new Set();
            this._objectsByState.set(p.state, ids);
        }
        // A object can only have one state so we first clear all
        // (state) sets of the object so we can add it below
        for (const _ids of this._objectsByState.values()) {
            _ids.delete(p.local.id);
        }
        // add object to specified state set.
        ids.add(p.local.id);
        if (p.conflictReason !== MergableObjectConflict.None) {
            this._conflicts.set(p.local.id, p.conflictReason);
        }
        else {
            this._conflicts.delete(p.local.id);
        }
    }
}
//# sourceMappingURL=MergableSet.js.map