/**
 * Indicates where a change on a  {@link MergableObject} originates. Either remotely or locally.
 */
export enum MergableObjectEventSource {
    /** Remote (backend) modification of {@link MergableObject} */
    Remote = 1,
    /** Local (browser) modification of {@link MergableObject} */
    Local = 2
}

/**
 * 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 enum MergableObjectState {
    /**
     * Object is not modified. Only possible if the object has had or have a remote counterpart.
     * */
    Unchanged = 0,
    /**
     * 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
     */
    Added = 1 << 0,
    /**
     * 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.
     */
    Modified = 1 << 1,
    /**
     * 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).
     */
    Deleted = 1 << 2
}

/**
 * Reason for a conflict between a local and a remote object
 * Note: There is no state represented by 0.
 */
export enum MergableObjectConflict {
    /**
     * No conflict.
     */
    None = 0,

    /**
     * The local object differs from the remote object somehow
     */
    RemoteDiffers = 1,

    /**
     * The remote object was deleted while the local object was modified.
     */
    RemoteDeleted = 2
}

/**
 * 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<P> {
    public onStateChange: (p: MergableObjectWithState<P>) => void;

    public constructor(
        /**
         * local object.
         */
        public local: P,

        private _state: MergableObjectState,

        private _conflictReason: MergableObjectConflict,
        /**
         * Remote object (if there is one)
         */
        public remote?: P
    ) {}

    /**
     * If defined then there is a conflict between the local and the remote object.
     */
    public get conflictReason(): MergableObjectConflict {
        return this._conflictReason;
    }

    /**
     * Sets conflict reason (if any).
     */
    public set conflictReason(v: MergableObjectConflict) {
        this._conflictReason = v;
        if (this.onStateChange) {
            this.onStateChange(this);
        }
    }

    /**
     * `true` if a conflict exists. Otherwise `false`
     */
    public get hasConflict(): boolean {
        return this.conflictReason !== MergableObjectConflict.None;
    }

    /**
     * get current state of the local object.
     */
    public get state(): MergableObjectState {
        return this._state;
    }
    /**
     * set current state of the local object.
     */
    public set state(v: MergableObjectState) {
        this._state = v;
        if (this.onStateChange) {
            this.onStateChange(this);
        }
    }
}

export type ImmutableMergableObjectWithState<P> = Readonly<MergableObjectWithState<P>>;

/**
 * Represents event for change in local object.
 */
export interface MergableObjectLocalEventArgs<T> {
    /**
     * Determines if the event was raised due to a change in the local or a remote object.
     * Use in discriminant union checks to get type guard.
     */
    readonly eventSource: MergableObjectEventSource.Local;

    /**
     * Local representation of the object.
     */
    readonly local: T;

    /**
     * State of the local object.
     */
    readonly state: MergableObjectState;
}

/**
 * Represents event for change in remote object.
 */
export interface MergableObjectRemoteEventArgs<T> {
    /**
     * Determines if the event was raised due to a change in the local or a remote object.
     * Use in discriminant union checks to get type guard.
     */
    readonly eventSource: MergableObjectEventSource.Remote;
    /**
     * Remote representation of object. If remote has been deleted
     * then it will be undefined.
     */
    readonly remote?: T;

    /**
     * Local representation of the object.
     */
    readonly local: T;

    /**
     * Describes conflict reason (if any).
     */
    readonly conflictReason: MergableObjectConflict;

    /**
     * State of the local object.
     */
    readonly state: MergableObjectState;
}

/**
 * Represents event for change in local or remote object. Use the {@link MergableObjectEventArgs<T>.eventSource} property to determine if
 * the event is local or remote.
 */
export type MergableObjectEventArgs<T> = MergableObjectLocalEventArgs<T> | MergableObjectRemoteEventArgs<T>;

/**
 * Properties that a MergableObject must implement.
 */
export interface MergableObjectProps {
    /** Unique id of the object. A uuid is recommended. */
    readonly id: string;
}

/**
 * Operations that a MergableObject must implement
 */
export interface MergableObjectOperations<T> {
    /**
     * Called when the object is first added to to {@link MergableSet<T>} by calling {@link MergableSet<T>.addOrUpdate} and if the object
     * does not already exist in {@link MergableSet}. In that case the local object will get {@link MergableObjectState.Added}.
     * Also called if a remote object, for which there is no corresponding local or remote object in {@link MergableSet}, is detected during
     * calls to {@link MergableSet<T>.saveAndMerge} or {@link MergableSet<T>.loadAndMerge}. In that case the local object get the state {@link MergableObjectState.Unchanged}.
     * @param o Gives both local and (optional) remote representation of object. Also gives (rare but possible) conflict reason
     * if it exists. A conflict may occur if a local object with same {@link MergableObject.id} has been added by calling  {@link MergableSet<T>.addOrUpdate} when no remote counterpart existed. Once
     * {@link MergableSet<T>.saveAndMerge} or {@link MergableSet<T>.loadAndMerge} is called a remote counter part (same id) is detected and their content differs. This should only happen if non unique id's are
     * assigned to objects.
     */
    onAdded(o: MergableObjectEventArgs<T>): void;

    /**
     * This event is called when local object already exists in {@link MergableSet<T>} and does not have state {@link MergableObjectState.Added} and one of the following holds true
     * 1.  If state is {@link MergableObjectState.Unchanged} and a call to {@link MergableSet<T>.loadAndMerge} or {@link MergableSet<T>.saveAndMerge} results in a detection that the remote object has changed and it differs from the local object
     *
     * 2. Calling {@link MergableSet<T>.addOrUpdate}
     * @param o Gives both local and (optional) remote representation of object. Also gives (rare but possible) conflict reason
     * if it exists. A conflict may occur if a local object with same {@link MergableObject.id} has been added by calling  {@link MergableSet<T>.addOrUpdate} when no remote counterpart existed. Once
     * {@link MergableSet<T>.saveAndMerge} or {@link MergableSet<T>.loadAndMerge} is called a remote counter part (same id) is detected and their content differs. This should only happen if non unique id's are
     * assigned to objects.
     */
    onUpdate(o: MergableObjectEventArgs<T>): void;

    /** Called when
     *  * {@link Layer<T>.delete} is called.
     *  * {@link Layer<T>.save} or {@link Layer<T>.load} is called and item exists in client (and is unchanged)
     *  but no longer in backend. That means that somebody has removed it and store that in the backend. Hence it must be removed
     * @param o Gives both local and (optional) remote representation of object. Also gives (rare but possible) conflict reason
     * if it exists. A conflict may occur if a local object with same {@link MergableObject.id} has been added by calling  {@link MergableSet<T>.addOrUpdate} when no remote counterpart existed. Once
     * {@link MergableSet<T>.saveAndMerge} or {@link MergableSet<T>.loadAndMerge} is called a remote counter part (same id) is detected and their content differs. This should only happen if non unique id's are
     * assigned to objects.
     * @returns `true` if there is no conflict or conflict could be resolved.
     * */
    onDelete(o: MergableObjectEventArgs<T>): void;

    /**
     * Returns `true` if objects are considered to be equal. Otherwise `false`
     * @param o Object to compare with.
     */
    isEqual(o: T): boolean;

    /**
     * Deep clone the object.
     */
    clone(): T;
}

/**
 * Makes it possible to work with local objects to which there are (possibly) remote counterparts. Will automatically resolve most conflicts
 * except those that occur when both the local and remote object have been modified in some way that will result in a conflict.
 * Example: Local object is modified (which means it must have existed remotely) but the remote no longer exists.
 */
export type MergableObject<T extends MergableObjectProps> = MergableObjectProps & MergableObjectOperations<T>;

/**
 * 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<P extends MergableObject<P>> {
    private readonly _objectsById = new Map<string, MergableObjectWithState<P>>();
    private readonly _conflicts = new Map<string, MergableObjectConflict>();
    private readonly _objectsByState = new Map<MergableObjectState, Set<string>>();
    private readonly _onStateChangeBoundToThis = this.onStateChange.bind(this);

    /**
     * Get number of conflicts in the set.
     * @returns Number of conflicts.
     */
    public get conflictCount(): number {
        return this._conflicts.size;
    }

    /**
     * Gets all local objects and their remote counterparts. Includes both state and conflict reasons (if any)
     * @returns All objects.
     */
    public get objects(): IterableIterator<ImmutableMergableObjectWithState<P>> {
        return this._objectsById.values();
    }

    /**
     * Gets all conflicts.
     * @returns All conflicts.
     */
    public get conflicts(): ImmutableMergableObjectWithState<P>[] {
        const conflicts: MergableObjectWithState<P>[] = [];
        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.
     */
    public get changeCount(): number {
        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}.
     */
    public get hasChanges(): boolean {
        return this.changeCount > 0;
    }

    /**
     * Clear set
     */
    public clear(): void {
        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.
     */
    public get(id: string): MergableObjectWithState<P> | undefined {
        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`.
     */
    public has(id: string): boolean {
        return this._objectsById.has(id);
    }

    /**
     * Deletes a object
     * @param local Id of object to delete
     * @returns `true` if object was deleted. Otherwise `false`
     */
    public delete(local: Pick<P, 'id'> | string): boolean {
        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`.
     */
    public add(o: P): boolean {
        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`.
     */
    public update(id: string, updateAction: (existingItem: P) => void): boolean {
        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.).
     */
    public mergeRemotes(remoteObjects: P[]): number {
        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`.
     */
    public acceptChanges(): boolean {
        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;
    }

    private onStateChange(p: MergableObjectWithState<P>): void {
        let ids = this._objectsByState.get(p.state);
        if (!ids) {
            ids = new Set<string>();
            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);
        }
    }
}
