/*!
 * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
 * Licensed under the MIT License.
 */
import BTree from "sorted-btree";
import { assert } from "@fluidframework/common-utils";
import { brand, fail, getOrCreate, mapIterable } from "../util";
import { assertIsRevisionTag, findAncestor, findCommonAncestor, mintCommit, rebaseChange, SimpleDependee, UndoRedoManager, } from "../core";
import { createEmitter } from "../events";
import { getChangeReplaceType, onForkTransitive, SharedTreeBranch } from "./branch";
export const minimumPossibleSequenceNumber = brand(Number.MIN_SAFE_INTEGER);
/**
 * Represents a local branch of a document and interprets the effect on the document of adding sequenced changes,
 * which were based on a given session's branch, to the document history
 */
// TODO: Try to reduce this to a single type parameter
export class EditManager extends SimpleDependee {
    /**
     * @param changeFamily - the change family of changes on the trunk and local branch
     * @param localSessionId - the id of the local session that will be used for local commits
     * @param repairDataStoreProvider - used for undoing/redoing the local branch
     * @param anchors - an optional set of anchors to be rebased by the local branch when it changes
     */
    constructor(changeFamily, 
    // TODO: Change this type to be the Session ID type provided by the IdCompressor when available.
    localSessionId, repairDataStoreProvider, anchors) {
        super("EditManager");
        this.changeFamily = changeFamily;
        this.localSessionId = localSessionId;
        /**
         * Records extra data associated with commits in the {@link trunk}.
         * This does not include an entry for the {@link trunkBase}.
         */
        this.trunkMetadata = new Map();
        /**
         * A map from a sequence number to the commit in the {@link trunk} which has that sequence number.
         * This also includes an entry for the {@link trunkBase} which always has the lowest key in the map.
         */
        this.sequenceMap = new BTree();
        /**
         * Branches are maintained to represent the local change list that the issuing client had
         * at the time of submitting the latest known edit on the branch.
         * This means the head commit of each branch is always in its original (non-rebased) form.
         */
        // TODO:#4593: Add test to ensure that peer branches are never initialized with a repairDataStoreProvider
        this.peerLocalBranches = new Map();
        /**
         * Tracks where on the trunk all registered branches are based. Each key is the sequence number of a commit on
         * the trunk, and the value is the set of all branches who have that commit as their common ancestor with the trunk.
         */
        this.trunkBranches = new BTree();
        /**
         * The sequence number of the newest commit on the trunk that has been received by all peers.
         * Defaults to {@link minimumPossibleSequenceNumber} if no commits have been received.
         */
        this.minimumSequenceNumber = minimumPossibleSequenceNumber;
        this.events = createEmitter();
        this.trunkBase = {
            revision: assertIsRevisionTag("00000000-0000-4000-8000-000000000000"),
            change: changeFamily.rebaser.compose([]),
        };
        this.sequenceMap.set(minimumPossibleSequenceNumber, this.trunkBase);
        this.localBranchUndoRedoManager = UndoRedoManager.create(changeFamily);
        this.trunkUndoRedoManager = this.localBranchUndoRedoManager.clone();
        this.trunk = new SharedTreeBranch(this.trunkBase, changeFamily, repairDataStoreProvider.clone(), this.trunkUndoRedoManager);
        this.localBranch = new SharedTreeBranch(this.trunk.getHead(), changeFamily, repairDataStoreProvider, this.localBranchUndoRedoManager, anchors);
        // Track all forks of the local branch for purposes of trunk eviction. Unlike the local branch, they have
        // an unknown lifetime and rebase frequency, so we can not make any assumptions about which trunk commits
        // they require and therefore we monitor them explicitly.
        onForkTransitive(this.localBranch, (fork) => this.registerBranch(fork));
    }
    on(eventName, listener) {
        return this.events.on(eventName, listener);
    }
    /**
     * Make the given branch known to the `EditManager`. The `EditManager` will ensure that all registered
     * branches remain usable even as the minimum sequence number advances.
     */
    registerBranch(branch) {
        const trackBranch = (b) => {
            const trunkCommit = findCommonAncestor(this.trunk.getHead(), b.getHead()) ??
                fail("Expected branch to be related to trunk");
            const sequenceNumber = this.trunkMetadata.get(trunkCommit.revision)?.sequenceNumber ??
                minimumPossibleSequenceNumber;
            const branches = getOrCreate(this.trunkBranches, sequenceNumber, () => new Set());
            assert(!branches.has(b), 0x670 /* Branch was registered more than once */);
            branches.add(b);
            return sequenceNumber;
        };
        const untrackBranch = (b, sequenceNumber) => {
            const branches = this.trunkBranches.get(sequenceNumber) ?? fail("Expected branch to be tracked");
            assert(branches.delete(b), 0x671 /* Expected branch to be tracked */);
            if (branches.size === 0) {
                this.trunkBranches.delete(sequenceNumber);
            }
        };
        // Record the sequence number of the branch's base commit on the trunk
        const trunkBase = { sequenceNumber: trackBranch(branch) };
        // Whenever the branch is rebased, update our record of its base trunk commit
        const offRebase = branch.on("change", (args) => {
            if (args.type === "replace" && getChangeReplaceType(args) === "rebase") {
                untrackBranch(branch, trunkBase.sequenceNumber);
                trunkBase.sequenceNumber = trackBranch(branch);
                this.trimTrunk();
            }
        });
        // When the branch is disposed, update our branch set and trim the trunk
        const offDispose = branch.on("dispose", () => {
            untrackBranch(branch, trunkBase.sequenceNumber);
            this.trimTrunk();
            offRebase();
            offDispose();
        });
    }
    /**
     * Advances the minimum sequence number, and removes all commits from the trunk which lie outside the collaboration window.
     * @param minimumSequenceNumber - the sequence number of the newest commit that all peers (including this one) have received and applied to their trunks
     */
    advanceMinimumSequenceNumber(minimumSequenceNumber) {
        if (minimumSequenceNumber === this.minimumSequenceNumber) {
            return;
        }
        assert(minimumSequenceNumber > this.minimumSequenceNumber, 0x476 /* number must be larger or equal to current minimumSequenceNumber. */);
        this.minimumSequenceNumber = minimumSequenceNumber;
        this.trimTrunk();
    }
    /**
     * Examines the latest known minimum sequence number and the trunk bases of any registered branches to determine
     * if any commits on the trunk are unreferenced and unneeded for future computation; those found are evicted from the trunk.
     * @returns the number of commits that were removed from the trunk
     */
    trimTrunk() {
        /** The sequence number of the oldest commit on the trunk that will be retained */
        let trunkTailSearchKey = this.minimumSequenceNumber;
        // If there are any outstanding registered branches, get the one that is the oldest (has the "most behind" trunk base)
        const minimumBranchBaseSequenceNumber = this.trunkBranches.minKey();
        if (minimumBranchBaseSequenceNumber !== undefined) {
            // If that branch is behind the minimum sequence number, we only want to evict commits older than it,
            // even if those commits are behind the minimum sequence number
            trunkTailSearchKey = brand(Math.min(trunkTailSearchKey, minimumBranchBaseSequenceNumber));
        }
        const [sequenceNumber, latestEvicted] = this.getClosestTrunkCommit(trunkTailSearchKey);
        // Don't do any work if the commit found by the search is already the tail of the trunk
        if (latestEvicted !== this.trunkBase) {
            // The minimum sequence number informs us that all peer branches are at least caught up to the tail commit,
            // so rebase them accordingly. This is necessary to prevent peer branches from referencing any evicted commits.
            for (const [, branch] of this.peerLocalBranches) {
                branch.rebaseOnto(this.trunk, latestEvicted);
            }
            // This mutation is a performance hack. If commits are truly immutable, then changing the trunk's tail requires
            // regenerating the entire commit graph. Instead, we can simply chop off the tail like this if we're certain
            // that there are no outstanding references to any of the commits being removed (other than the references via
            // the trunk). The peer branches have been rebased to the head of the trunk, the local branch is already rebased
            // to the head of the trunk, and all other branches are tracked by `trunkBranches` and known to be ahead of or at
            // `newTrunkBase`. Therefore, no branches should have unique references to any of the commits being evicted here.
            // We mutate the most recent of the evicted commits to become the new trunk base. That way, any other commits that
            // have parent pointers to the latest evicted commit will stay linked, even though that it is no longer part of the trunk.
            const newTrunkBase = latestEvicted;
            // Copying the revision of the old trunk base into the new trunk base means we don't need to write out the original
            // revision to summaries. All clients agree that the trunk base always has the same hardcoded revision.
            newTrunkBase.revision = this.trunkBase.revision;
            // Overwriting the change is not strictly necessary, but done here for consistency (so all trunk bases are deeply equal).
            newTrunkBase.change = this.trunkBase.change;
            // Dropping the parent field removes (transitively) all references to the evicted commits so they can be garbage collected.
            delete newTrunkBase.parent;
            this.trunkBase = newTrunkBase;
            // Update any state that is derived from trunk commits
            this.sequenceMap.editRange(minimumPossibleSequenceNumber, sequenceNumber, true, (s, { revision }) => {
                // Cleanup look-aside data for each evicted commit
                this.trunkMetadata.delete(revision);
                this.localBranchUndoRedoManager.untrackCommitType(revision);
                // Delete all evicted commits from `sequenceMap` except for the latest one, which is the new `trunkBase`
                if (s === sequenceNumber) {
                    assert(revision === newTrunkBase.revision, 0x729 /* Expected last evicted commit to be new trunk base */);
                }
                else {
                    return { delete: true };
                }
            });
        }
    }
    isEmpty() {
        return (this.trunk.getHead() === this.trunkBase &&
            this.peerLocalBranches.size === 0 &&
            this.localBranch.getHead() === this.trunk.getHead() &&
            this.minimumSequenceNumber === minimumPossibleSequenceNumber);
    }
    getSummaryData() {
        // The assert below is acceptable at present because summarization only ever occurs on a client with no
        // local/in-flight changes.
        // In the future we may wish to relax this constraint. For that to work, the current implementation of
        // `EditManager` would have to be amended in one of two ways:
        // A) Changes made by the local session should be represented by a branch in `EditManager.branches`.
        // B) The contents of such a branch should be computed on demand based on the trunk.
        // Note that option (A) would be a simple change to `addSequencedChange` whereas (B) would likely require
        // rebasing trunk changes over the inverse of trunk changes.
        assert(this.localBranch.getHead() === this.trunk.getHead(), 0x428 /* Clients with local changes cannot be used to generate summaries */);
        const trunk = getPathFromBase(this.trunk.getHead(), this.trunkBase).map((c) => {
            const metadata = this.trunkMetadata.get(c.revision) ?? fail("Expected metadata for trunk commit");
            const commit = {
                change: c.change,
                revision: c.revision,
                sequenceNumber: metadata.sequenceNumber,
                sessionId: metadata.sessionId,
            };
            return commit;
        });
        const branches = new Map(mapIterable(this.peerLocalBranches.entries(), ([sessionId, branch]) => {
            const branchPath = [];
            const ancestor = findCommonAncestor([branch.getHead(), branchPath], this.trunk.getHead()) ??
                fail("Expected branch to be based on trunk");
            return [
                sessionId,
                {
                    base: ancestor.revision,
                    commits: branchPath.map((c) => {
                        const commit = {
                            change: c.change,
                            revision: c.revision,
                            sessionId,
                        };
                        return commit;
                    }),
                },
            ];
        }));
        return { trunk, branches };
    }
    loadSummaryData(data) {
        assert(this.isEmpty(), 0x68a /* Attempted to load from summary after edit manager was already mutated */);
        // Record the tags of each trunk commit as we generate the trunk so they can be looked up quickly
        // when hydrating the peer branches below
        const trunkRevisionCache = new Map();
        trunkRevisionCache.set(this.trunkBase.revision, this.trunkBase);
        this.trunk.setHead(data.trunk.reduce((base, c) => {
            const commit = mintCommit(base, c);
            this.sequenceMap.set(c.sequenceNumber, commit);
            this.trunkMetadata.set(c.revision, {
                sequenceNumber: c.sequenceNumber,
                sessionId: c.sessionId,
            });
            trunkRevisionCache.set(c.revision, commit);
            return commit;
        }, this.trunkBase));
        this.localBranch.setHead(this.trunk.getHead());
        for (const [sessionId, branch] of data.branches) {
            const commit = trunkRevisionCache.get(branch.base) ??
                fail("Expected summary branch to be based off of a revision in the trunk");
            this.peerLocalBranches.set(sessionId, new SharedTreeBranch(branch.commits.reduce(mintCommit, commit), this.changeFamily));
        }
    }
    getTrunkChanges() {
        return getPathFromBase(this.trunk.getHead(), this.trunkBase).map((c) => c.change);
    }
    getTrunkHead() {
        return this.trunk.getHead();
    }
    getLocalChanges() {
        return getPathFromBase(this.localBranch.getHead(), this.trunk.getHead()).map((c) => c.change);
    }
    /**
     * @returns The length of the longest branch maintained by this EditManager.
     * This may be the length of a peer branch or the local branch.
     * The length is counted from the lowest common ancestor with the trunk such that a fully sequenced branch would
     * have length zero.
     */
    getLongestBranchLength() {
        let max = 0;
        const trunkHead = this.trunk.getHead();
        for (const branch of this.peerLocalBranches.values()) {
            const branchPath = getPathFromBase(branch.getHead(), trunkHead);
            if (branchPath.length > max) {
                max = branchPath.length;
            }
        }
        const localPath = getPathFromBase(this.localBranch.getHead(), trunkHead);
        return Math.max(max, localPath.length);
    }
    /**
     * Needs to be called after a summary is loaded.
     * @remarks This is necessary to keep the trunk's repairDataStoreProvider up to date with the
     * local's after a summary load.
     */
    afterSummaryLoad() {
        assert(this.localBranch.repairDataStoreProvider !== undefined, 0x6cb /* Local branch must maintain repair data */);
        this.trunk.repairDataStoreProvider = this.localBranch.repairDataStoreProvider.clone();
    }
    addSequencedChange(newCommit, sequenceNumber, referenceSequenceNumber) {
        assert(sequenceNumber > this.minimumSequenceNumber, 0x713 /* Expected change sequence number to exceed the last known minimum sequence number */);
        if (newCommit.sessionId === this.localSessionId) {
            const [firstLocalCommit] = getPathFromBase(this.localBranch.getHead(), this.trunk.getHead());
            assert(firstLocalCommit !== undefined, 0x6b5 /* Received a sequenced change from the local session despite having no local changes */);
            // The first local branch commit is already rebased over the trunk, so we can push it directly to the trunk.
            this.pushToTrunk(sequenceNumber, { ...firstLocalCommit, sessionId: this.localSessionId }, true);
            this.localBranch.rebaseOnto(this.trunk);
            return;
        }
        // Get the revision that the remote change is based on
        const [, baseRevisionInTrunk] = this.getClosestTrunkCommit(referenceSequenceNumber);
        // Rebase that branch over the part of the trunk up to the base revision
        // This will be a no-op if the sending client has not advanced since the last time we received an edit from it
        const peerLocalBranch = getOrCreate(this.peerLocalBranches, newCommit.sessionId, () => new SharedTreeBranch(baseRevisionInTrunk, this.changeFamily));
        peerLocalBranch.rebaseOnto(this.trunk, baseRevisionInTrunk);
        if (peerLocalBranch.getHead() === this.trunk.getHead()) {
            // If the branch is fully caught up and empty after being rebased, then push to the trunk directly
            this.pushToTrunk(sequenceNumber, newCommit);
            peerLocalBranch.setHead(this.trunk.getHead());
        }
        else {
            // Otherwise, rebase the change over the trunk and append it, and append the original change to the peer branch.
            const newChangeFullyRebased = rebaseChange(this.changeFamily.rebaser, newCommit.change, peerLocalBranch.getHead(), this.trunk.getHead());
            peerLocalBranch.apply(newCommit.change, newCommit.revision);
            this.pushToTrunk(sequenceNumber, {
                ...newCommit,
                change: newChangeFullyRebased,
            });
        }
        this.localBranch.rebaseOnto(this.trunk);
    }
    findLocalCommit(revision) {
        const commits = [];
        const commit = findAncestor([this.localBranch.getHead(), commits], (c) => c.revision === revision);
        assert(commit !== undefined, 0x599 /* Expected local branch to contain revision */);
        return [commit, commits];
    }
    pushToTrunk(sequenceNumber, commit, local = false) {
        this.trunk.setHead(mintCommit(this.trunk.getHead(), commit));
        const trunkHead = this.trunk.getHead();
        if (local) {
            const type = this.localBranchUndoRedoManager.getCommitType(trunkHead.revision) ??
                fail("Local commit types must be tracked until they are sequenced.");
            this.trunkUndoRedoManager.trackCommit(trunkHead, type);
        }
        this.trunk.repairDataStoreProvider?.applyChange(commit.change);
        this.sequenceMap.set(sequenceNumber, trunkHead);
        this.trunkMetadata.set(trunkHead.revision, { sequenceNumber, sessionId: commit.sessionId });
        this.events.emit("newTrunkHead", trunkHead);
    }
    /**
     * Finds the most recent trunk commit that was sequenced at or before the given sequence number.
     * @param sequenceNumber - the sequence number to search for
     * @remarks Fails if there is no eligible commit.
     * @returns the closest commit and its sequence number
     */
    getClosestTrunkCommit(sequenceNumber) {
        const commit = this.sequenceMap.getPairOrNextLower(sequenceNumber);
        assert(commit !== undefined, 0x72a /* Sequence number has been evicted */);
        return commit;
    }
}
/**
 * @returns the path from the base of a branch to its head
 */
function getPathFromBase(branchHead, baseBranchHead) {
    const path = [];
    assert(findCommonAncestor([branchHead, path], baseBranchHead) !== undefined, 0x573 /* Expected branches to be related */);
    return path;
}
//# sourceMappingURL=editManager.js.map