"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 internal_1 = require("@fluidframework/core-utils/internal");
const internal_2 = require("@fluidframework/telemetry-utils/internal");
const index_js_1 = require("../core/index.js");
const index_js_2 = require("../events/index.js");
const transactionStack_js_1 = require("./transactionStack.js");
/**
 * 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 index_js_2.EventEmitter {
    /**
     * Construct a new branch.
     * @param head - the head of the branch
     * @param changeFamily - determines the set of changes that this branch can commit
     * @param branchTrimmer - an optional event emitter that informs the branch it has been trimmed. If this is not supplied, then the branch must
     * never be trimmed. See {@link BranchTrimmingEvents} for details on trimming.
     */
    constructor(head, changeFamily, mintRevisionTag, branchTrimmer, telemetryEventBatcher) {
        super();
        this.head = head;
        this.changeFamily = changeFamily;
        this.mintRevisionTag = mintRevisionTag;
        this.branchTrimmer = branchTrimmer;
        this.telemetryEventBatcher = telemetryEventBatcher;
        this.transactions = new transactionStack_js_1.TransactionStack();
        /**
         * After pushing a starting revision to the transaction stack, this branch might be rebased
         * over commits which are children of that starting revision. When the transaction is committed,
         * those rebased-over commits should not be included in the transaction's squash commit, even though
         * they exist between the starting revision and the final commit within the transaction.
         *
         * Whenever `rebaseOnto` is called during a transaction, this map is augmented with an entry from the
         * original merge-base to the new merge-base.
         *
         * This state need only be retained for the lifetime of the transaction.
         *
         * TODO: This strategy might need to be revisited when adding better support for async transactions.
         * Since:
         *
         * 1. Transactionality is guaranteed primarily by squashing at commit time
         * 2. Branches may be rebased with an ongoing transaction
         *
         * a rebase operation might invalidate only a portion of a transaction's commits, thus defeating the
         * purpose of transactionality.
         *
         * AB#6483 and children items track this work.
         */
        this.initialTransactionRevToRebasedRev = new Map();
        this.disposed = false;
        this.editor = this.changeFamily.buildEditor((change) => this.apply(change, mintRevisionTag()));
        this.unsubscribeBranchTrimmer = branchTrimmer?.on("ancestryTrimmed", (commit) => {
            this.emit("ancestryTrimmed", commit);
        });
    }
    /**
     * Sets the head of this branch. Emits no change events.
     */
    setHead(head) {
        this.assertNotDisposed();
        (0, internal_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`
     * @param changeKind - the kind of change to apply
     * @returns the change that was applied and the new head commit of the branch
     */
    apply(change, revision, changeKind = index_js_1.CommitKind.Default) {
        this.assertNotDisposed();
        const changeWithRevision = this.changeFamily.rebaser.changeRevision(change, revision);
        const newHead = (0, index_js_1.mintCommit)(this.head, {
            revision,
            change: changeWithRevision,
        });
        const changeEvent = {
            type: "append",
            change: (0, index_js_1.tagChange)(changeWithRevision, revision),
            newCommits: [newHead],
        };
        this.emit("beforeChange", changeEvent);
        this.head = newHead;
        // If this is not part of a transaction, emit a commitApplied event
        if (!this.isTransacting()) {
            this.emit("commitApplied", { isLocal: true, kind: changeKind });
        }
        this.emit("afterChange", changeEvent);
        return [changeWithRevision, newHead];
    }
    /**
     * @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.
     */
    startTransaction() {
        this.assertNotDisposed();
        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();
        });
        this.editor.enterTransaction();
        this.emit("transactionStarted", this.transactions.size === 1);
    }
    /**
     * 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();
        this.emit("transactionCommitted", this.transactions.size === 0);
        if (commits.length === 0) {
            return undefined;
        }
        // Squash the changes and make the squash commit the new head of this branch
        const squashedChange = this.changeFamily.rebaser.compose(commits);
        const revision = this.mintRevisionTag();
        const newHead = (0, index_js_1.mintCommit)(startCommit, {
            revision,
            change: this.changeFamily.rebaser.changeRevision(squashedChange, revision),
        });
        const changeEvent = {
            type: "replace",
            change: undefined,
            removedCommits: commits,
            newCommits: [newHead],
        };
        this.emit("beforeChange", changeEvent);
        this.head = newHead;
        // If this transaction is not nested, emit a commitApplied event
        if (!this.isTransacting()) {
            this.emit("commitApplied", { isLocal: true, kind: index_js_1.CommitKind.Default });
        }
        this.emit("afterChange", changeEvent);
        return [commits, newHead];
    }
    /**
     * 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] = this.popTransaction();
        this.editor.exitTransaction();
        this.emit("transactionAborted", this.transactions.size === 0);
        if (commits.length === 0) {
            return [undefined, []];
        }
        const inverses = [];
        for (let i = commits.length - 1; i >= 0; i--) {
            const revision = this.mintRevisionTag();
            const inverse = this.changeFamily.rebaser.changeRevision(this.changeFamily.rebaser.invert(commits[i], false), revision, commits[i].revision);
            inverses.push((0, index_js_1.tagRollbackInverse)(inverse, revision, commits[i].revision));
        }
        const change = inverses.length > 0 ? this.changeFamily.rebaser.compose(inverses) : undefined;
        const changeEvent = {
            type: "remove",
            change: change === undefined ? undefined : (0, index_js_1.makeAnonChange)(change),
            removedCommits: commits,
        };
        this.emit("beforeChange", changeEvent);
        this.head = startCommit;
        this.emit("afterChange", changeEvent);
        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: startRevisionOriginal } = this.transactions.pop();
        let startRevision = startRevisionOriginal;
        while (this.initialTransactionRevToRebasedRev.has(startRevision)) {
            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
            startRevision = this.initialTransactionRevToRebasedRev.get(startRevision);
        }
        if (!this.isTransacting()) {
            this.initialTransactionRevToRebasedRev.clear();
        }
        const commits = [];
        const startCommit = (0, index_js_1.findAncestor)([this.head, commits], (c) => c.revision === startRevision);
        (0, internal_1.assert)(startCommit !== undefined, 0x593 /* Expected branch to be ahead of transaction start revision */);
        return [startCommit, commits];
    }
    /**
     * 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.
     *
     * @remarks Forks created during a transaction will be disposed when the transaction ends.
     */
    fork() {
        this.assertNotDisposed();
        const fork = new SharedTreeBranch(this.head, this.changeFamily, this.mintRevisionTag, this.branchTrimmer);
        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`.
     *
     * @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 result of 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 { newSourceHead, commits } = rebaseResult;
        const { deletedSourceCommits, targetCommits, sourceCommits } = commits;
        const newCommits = targetCommits.concat(sourceCommits);
        if (this.isTransacting()) {
            const src = targetCommits[0].parent?.revision;
            const dst = targetCommits[targetCommits.length - 1].revision;
            if (src !== undefined && dst !== undefined) {
                this.initialTransactionRevToRebasedRev.set(src, dst);
            }
        }
        const changeEvent = {
            type: "replace",
            get change() {
                const change = rebaseResult.sourceChange;
                return change === undefined ? undefined : (0, index_js_1.makeAnonChange)(change);
            },
            removedCommits: deletedSourceCommits,
            newCommits,
        };
        this.emit("beforeChange", changeEvent);
        this.head = newSourceHead;
        this.emit("afterChange", changeEvent);
        return rebaseResult;
    }
    /**
     * Apply all the divergent changes on the given branch to 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, internal_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 sourceCommits = rebaseResult.commits.sourceCommits;
        const change = this.changeFamily.rebaser.compose(sourceCommits);
        const taggedChange = (0, index_js_1.makeAnonChange)(change);
        const changeEvent = {
            type: "append",
            get change() {
                return taggedChange;
            },
            newCommits: sourceCommits,
        };
        this.emit("beforeChange", changeEvent);
        this.head = rebaseResult.newSourceHead;
        this.emit("afterChange", changeEvent);
        return [change, sourceCommits];
    }
    /** Rebase `branchHead` onto `onto`, but return undefined if nothing changed */
    rebaseBranch(branch, onto, upTo = onto.getHead()) {
        const { head } = branch;
        if (head === upTo) {
            return undefined;
        }
        const { duration, output } = (0, internal_2.measure)(() => (0, index_js_1.rebaseBranch)(this.mintRevisionTag, this.changeFamily.rebaser, head, upTo, onto.getHead()));
        this.telemetryEventBatcher?.accumulateAndLog({ duration, ...output.telemetryProperties });
        if (this.head === output.newSourceHead) {
            return undefined;
        }
        return output;
    }
    /**
     * 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.unsubscribeBranchTrimmer?.();
        this.disposed = true;
        this.emit("dispose");
    }
    assertNotDisposed() {
        (0, internal_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