"use strict";
/*!
 * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
 * Licensed under the MIT License.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.onForkTransitive = exports.SharedTreeBranch = exports.getChangeReplaceType = void 0;
const core_utils_1 = require("@fluidframework/core-utils");
const core_1 = require("../core");
const events_1 = require("../events");
const transactionStack_1 = require("./transactionStack");
/**
 * Returns the operation that caused the given {@link SharedTreeBranchChange}.
 */
function getChangeReplaceType(change) {
    // The "replace" variant of the change event is emitted by two operations: committing a transaction and doing a rebase.
    // Committing a transaction will always remove one or more commits (the commits that were squashed),
    // and will add exactly one new commit (the squash commit).
    if (change.removedCommits.length === 0 || change.newCommits.length !== 1) {
        return "rebase";
    }
    // There is only one case in which a rebase both removes commits and adds exactly one new commit.
    // This occurs when there is exactly one divergent, but equivalent, commit on each branch:
    //
    // A ─ B (branch X)	  -- rebase Y onto X -->   A ─ B (branch X)
    // └─ B' (branch Y)                                └─ (branch Y)
    //
    // B' is removed and replaced by B because both have the same revision.
    if (change.removedCommits.length === 1 &&
        change.removedCommits[0].revision === change.newCommits[0].revision) {
        return "rebase";
    }
    return "transactionCommit";
}
exports.getChangeReplaceType = getChangeReplaceType;
/**
 * A branch of changes that can be applied to a SharedTree.
 */
class SharedTreeBranch extends events_1.EventEmitter {
    /**
     * Construct a new branch.
     * @param head - the head of the branch
     * @param rebaser - the rebaser used for rebasing and merging commits across branches
     * @param changeFamily - determines the set of changes that this branch can commit
     * @param repairDataStoreProvider - an optional provider of {@link RepairDataStore}s to use when generating
     * repair data. This must be provided in order to use features that require repair data such as undo/redo or constraints.
     * @param undoRedoManager - an optional {@link UndoRedoManager} to manage the undo/redo operations of this
     * branch. This must be provided in order to use the `undo` and `redo` methods of this branch.
     */
    constructor(head, changeFamily, repairDataStoreProvider, undoRedoManager) {
        super();
        this.head = head;
        this.changeFamily = changeFamily;
        this.repairDataStoreProvider = repairDataStoreProvider;
        this.undoRedoManager = undoRedoManager;
        this.transactions = new transactionStack_1.TransactionStack();
        this.disposed = false;
        this.editor = this.changeFamily.buildEditor((change) => this.apply(change, (0, core_1.mintRevisionTag)()));
    }
    /**
     * Sets the head of this branch. Emits no change events.
     */
    setHead(head) {
        this.assertNotDisposed();
        (0, core_utils_1.assert)(!this.isTransacting(), 0x685 /* Cannot set head during a transaction */);
        this.head = head;
    }
    /**
     * Apply a change to this branch.
     * @param change - the change to apply
     * @param revision - the revision of the new head commit of the branch that contains `change`
     * @returns the change that was applied and the new head commit of the branch
     */
    apply(change, revision) {
        return this.applyChange(change, revision, core_1.LocalCommitSource.Default);
    }
    applyChange(change, revision, undoRedoType) {
        this.assertNotDisposed();
        // If this is not part of a transaction, capture the repair data
        let repairData;
        if (!this.isTransacting() && this.repairDataStoreProvider !== undefined) {
            repairData = this.repairDataStoreProvider.createRepairData();
            repairData.capture(change, revision);
        }
        this.head = (0, core_1.mintCommit)(this.head, {
            revision,
            change,
            repairData,
        });
        this.transactions.repairStore?.capture(change, this.head.revision);
        // If this is not part of a transaction, add it to the undo commit tree
        if (undoRedoType !== undefined && !this.isTransacting()) {
            this.undoRedoManager?.trackCommit(this.head, undoRedoType);
            this.emit("revertible", undoRedoType);
        }
        this.emit("change", { type: "append", change, newCommits: [this.head] });
        return [change, this.head];
    }
    /**
     * @returns the commit at the head of this branch.
     */
    getHead() {
        return this.head;
    }
    /**
     * Begin a transaction on this branch. If the transaction is committed via {@link commitTransaction},
     * all commits made since this call will be squashed into a single head commit.
     * @param repairStore - the repair store associated with this transaction
     */
    startTransaction(repairStore) {
        this.assertNotDisposed();
        if (!this.isTransacting() && this.repairDataStoreProvider !== undefined) {
            // If this is the start of a transaction stack, freeze the
            // repair data store provider so that repair data can be captured based on the
            // state of the branch at the start of the transaction.
            this.repairDataStoreProvider.freeze();
        }
        const forks = new Set();
        const onDisposeUnSubscribes = [];
        const onForkUnSubscribe = onForkTransitive(this, (fork) => {
            forks.add(fork);
            onDisposeUnSubscribes.push(fork.on("dispose", () => forks.delete(fork)));
        });
        this.transactions.push(this.head.revision, () => {
            forks.forEach((fork) => fork.dispose());
            onDisposeUnSubscribes.forEach((unsubscribe) => unsubscribe());
            onForkUnSubscribe();
        }, repairStore);
        this.editor.enterTransaction();
    }
    /**
     * Commit the current transaction. There must be a transaction in progress that was begun via {@link startTransaction}.
     * If there are commits in the current transaction, they will be squashed into a new single head commit.
     * @returns the commits that were squashed and the new squash commit if a squash occurred, otherwise `undefined`.
     * @remarks If the transaction had no changes applied during its lifetime, then no squash occurs (i.e. this method is a no-op).
     * Even if the transaction contained only one change, it will still be replaced with an (equivalent) squash change.
     */
    commitTransaction() {
        this.assertNotDisposed();
        const [startCommit, commits] = this.popTransaction();
        this.editor.exitTransaction();
        if (commits.length === 0) {
            return undefined;
        }
        // Anonymize the commits from this transaction by stripping their revision tags.
        // Otherwise, the change rebaser will record their tags and those tags no longer exist.
        const anonymousCommits = commits.map(({ change }) => ({ change, revision: undefined }));
        // Squash the changes and make the squash commit the new head of this branch
        const squashedChange = this.changeFamily.rebaser.compose(anonymousCommits);
        const revision = (0, core_1.mintRevisionTag)();
        let repairData;
        if (!this.isTransacting() && this.repairDataStoreProvider !== undefined) {
            repairData = this.repairDataStoreProvider.createRepairData();
            repairData?.capture(squashedChange, revision);
        }
        this.head = (0, core_1.mintCommit)(startCommit, {
            revision,
            change: squashedChange,
            repairData,
        });
        // If this transaction is not nested, add it to the undo commit tree and capture its repair data
        if (!this.isTransacting()) {
            if (this.undoRedoManager !== undefined) {
                this.undoRedoManager.trackCommit(this.head, core_1.LocalCommitSource.Default);
                this.emit("revertible", core_1.LocalCommitSource.Default);
            }
        }
        // If there is still an ongoing transaction (because this transaction was nested inside of an outer transaction)
        // then update the repair data store for that transaction
        this.transactions.repairStore?.capture(this.head.change, revision);
        this.emit("change", {
            type: "replace",
            change: undefined,
            removedCommits: commits,
            newCommits: [this.head],
        });
        return [commits, this.head];
    }
    /**
     * Cancel the current transaction. There must be a transaction in progress that was begun via
     * {@link startTransaction}. All commits made during the transaction will be removed.
     * @returns the change to this branch resulting in the removal of the commits, and a list of the
     * commits that were removed.
     */
    abortTransaction() {
        this.assertNotDisposed();
        const [startCommit, commits, repairStore] = this.popTransaction();
        this.editor.exitTransaction();
        this.head = startCommit;
        if (commits.length === 0) {
            return [undefined, []];
        }
        const inverses = [];
        for (let i = commits.length - 1; i >= 0; i--) {
            const inverse = this.changeFamily.rebaser.invert(commits[i], false, repairStore);
            inverses.push((0, core_1.tagChange)(inverse, (0, core_1.mintRevisionTag)()));
        }
        const change = inverses.length > 0 ? this.changeFamily.rebaser.compose(inverses) : undefined;
        this.emit("change", {
            type: "remove",
            change,
            removedCommits: commits,
        });
        return [change, commits];
    }
    /**
     * True iff this branch is in the middle of a transaction that was begin via {@link startTransaction}
     */
    isTransacting() {
        return this.transactions.size !== 0;
    }
    popTransaction() {
        const { startRevision, repairStore } = this.transactions.pop();
        const commits = [];
        const startCommit = (0, core_1.findAncestor)([this.head, commits], (c) => c.revision === startRevision);
        (0, core_utils_1.assert)(startCommit !== undefined, 0x593 /* Expected branch to be ahead of transaction start revision */);
        return [startCommit, commits, repairStore];
    }
    /**
     * Undoes the last change made by the client. If there is no change to undo then this method has no effect.
     * This method will error if no {@link UndoRedoManager} was provided when this branch was constructed.
     * It is invalid to call this method while a transaction is open (this will be supported in the future).
     * @returns the change to this branch and the new head commit, or undefined if there was nothing to undo
     */
    undo() {
        (0, core_utils_1.assert)(this.undoRedoManager !== undefined, 0x686 /* Must construct branch with an `UndoRedoManager` in order to undo. */);
        // TODO: allow this once it becomes possible to compose the changesets created by edits made
        // within transactions and edits that represent completed transactions.
        (0, core_utils_1.assert)(!this.isTransacting(), 0x66a /* Undo is not yet supported during transactions */);
        const undoChange = this.undoRedoManager?.undo(this.getHead());
        if (undoChange !== undefined) {
            return this.applyChange(undoChange, (0, core_1.mintRevisionTag)(), core_1.LocalCommitSource.Undo);
        }
        return undefined;
    }
    /**
     * Redoes the last change made by the client. If there is no change to redo then this method has no effect.
     * This method will error if no {@link UndoRedoManager} was provided when this branch was constructed.
     * It is invalid to call this method while a transaction is open (this will be supported in the future).
     * @returns the change to this branch and the new head commit, or undefined if there was nothing to redo
     */
    redo() {
        (0, core_utils_1.assert)(this.undoRedoManager !== undefined, 0x687 /* Must construct branch with an `UndoRedoManager` in order to redo. */);
        // TODO: allow this once it becomes possible to compose the changesets created by edits made
        // within transactions and edits that represent completed transactions.
        (0, core_utils_1.assert)(!this.isTransacting(), 0x67e /* Redo is not yet supported during transactions */);
        const redoChange = this.undoRedoManager?.redo(this.getHead());
        if (redoChange !== undefined) {
            return this.applyChange(redoChange, (0, core_1.mintRevisionTag)(), core_1.LocalCommitSource.Redo);
        }
        return undefined;
    }
    /**
     * Spawn a new branch that is based off of the current state of this branch.
     * Changes made to the new branch will not be applied to this branch until the new branch is merged back in.
     * @param repairDataStoreProvider - a {@link RepairDataStoreProvider} that reflects the state of the new branch. If one is not
     * provided, then it will be cloned from this branch.
     *
     * @remarks Forks created during a transaction will be disposed when the transaction ends.
     */
    fork(repairDataStoreProvider) {
        this.assertNotDisposed();
        const fork = new SharedTreeBranch(this.head, this.changeFamily, repairDataStoreProvider ?? this.repairDataStoreProvider?.clone(), this.undoRedoManager?.clone());
        this.emit("fork", fork);
        return fork;
    }
    /**
     * Rebase the changes that have been applied to this branch over divergent changes in the given branch.
     * After this operation completes, this branch will be based off of `branch`.
     *
     * @remarks
     * This operation can change the relative ordering between revertible commits therefore, the revertible event
     * is not emitted during this operation.
     *
     * @param branch - the branch to rebase onto
     * @param upTo - the furthest commit on `branch` over which to rebase (inclusive). Defaults to the head commit of `branch`.
     * @returns the net change to this branch and the commits that were removed and added to this branch by the rebase,
     * or undefined if nothing changed
     */
    rebaseOnto(branch, upTo = branch.getHead()) {
        this.assertNotDisposed();
        // Rebase this branch onto the given branch
        const rebaseResult = this.rebaseBranch(this, branch, upTo);
        if (rebaseResult === undefined) {
            return undefined;
        }
        // The net change to this branch is provided by the `rebaseBranch` API
        const [newHead, change, { deletedSourceCommits, targetCommits, sourceCommits }] = rebaseResult;
        if (this.undoRedoManager !== undefined) {
            // TODO: We probably can rebase a revertible branch onto a non-revertible branch.
            (0, core_utils_1.assert)(branch.undoRedoManager !== undefined, 0x688 /* Cannot rebase a revertible branch onto a non-revertible branch */);
            this.undoRedoManager.updateAfterRebase(sourceCommits, branch.undoRedoManager);
        }
        this.head = newHead;
        const newCommits = targetCommits.concat(sourceCommits);
        this.emit("change", {
            type: "replace",
            change,
            removedCommits: deletedSourceCommits,
            newCommits,
        });
        return [change, deletedSourceCommits, newCommits];
    }
    /**
     * Apply all the divergent changes on the given branch to this branch.
     *
     * @remarks
     * Revertible events are emitted for new local commits merged into this branch.
     *
     * @param branch - the branch to merge into this branch
     * @returns the net change to this branch and the commits that were added to this branch by the merge,
     * or undefined if nothing changed
     */
    merge(branch) {
        this.assertNotDisposed();
        branch.assertNotDisposed();
        (0, core_utils_1.assert)(!branch.isTransacting(), 0x597 /* Branch may not be merged while transaction is in progress */);
        // Rebase the given branch onto this branch
        const rebaseResult = this.rebaseBranch(branch, this);
        if (rebaseResult === undefined) {
            return undefined;
        }
        // Compute the net change to this branch
        const [newHead, _, { sourceCommits }] = rebaseResult;
        if (this.undoRedoManager !== undefined) {
            // TODO: We probably can merge a non-revertible branch into a revertible branch.
            (0, core_utils_1.assert)(branch.undoRedoManager !== undefined, 0x689 /* Cannot merge a non-revertible branch into a revertible branch */);
            this.undoRedoManager.updateAfterMerge(sourceCommits, branch.undoRedoManager);
            for (const commit of sourceCommits) {
                const type = this.undoRedoManager.getCommitType(commit.revision);
                if (type !== undefined) {
                    this.emit("revertible", type);
                }
            }
        }
        this.head = newHead;
        const change = this.changeFamily.rebaser.compose(sourceCommits);
        this.emit("change", {
            type: "append",
            change,
            newCommits: sourceCommits,
        });
        return [change, sourceCommits];
    }
    /** Rebase `branchHead` onto `onto`, but return undefined if nothing changed */
    rebaseBranch(branch, onto, upTo = onto.getHead()) {
        const { head, repairDataStoreProvider } = branch;
        if (head === upTo) {
            return undefined;
        }
        const rebaseResult = (0, core_1.rebaseBranch)(this.changeFamily.rebaser, repairDataStoreProvider, head, upTo, onto.getHead());
        const [rebasedHead] = rebaseResult;
        if (this.head === rebasedHead) {
            return undefined;
        }
        return rebaseResult;
    }
    /**
     * Dispose this branch, freezing its state.
     *
     * @remarks
     * Attempts to further mutate the branch will error.
     * Any transactions in progress will be aborted.
     * Calling dispose more than once has no effect.
     */
    dispose() {
        if (this.disposed) {
            return;
        }
        while (this.isTransacting()) {
            this.abortTransaction();
        }
        this.disposed = true;
        this.emit("dispose");
    }
    assertNotDisposed() {
        (0, core_utils_1.assert)(!this.disposed, 0x66e /* Branch is disposed */);
    }
}
exports.SharedTreeBranch = SharedTreeBranch;
/**
 * Registers an event listener that fires when the given forkable object forks.
 * The listener will also fire when any of those forks fork, and when those forks of forks fork, and so on.
 * @param forkable - an object that emits an event when it is forked
 * @param onFork - the fork event listener
 * @returns a function which when called will deregister all registrations (including transitive) created by this function.
 * The deregister function has undefined behavior if called more than once.
 */
function onForkTransitive(forkable, onFork) {
    const offs = [];
    offs.push(forkable.on("fork", (fork) => {
        offs.push(onForkTransitive(fork, onFork));
        onFork(fork);
    }));
    return () => offs.forEach((off) => off());
}
exports.onForkTransitive = onForkTransitive;
//# sourceMappingURL=branch.js.map