/*!
 * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
 * Licensed under the MIT License.
 */
import { assert, oob } from "@fluidframework/core-utils/internal";
import { defineLazyCachedProperty, hasSome } from "../../util/index.js";
import { makeAnonChange, mapTaggedChange, tagChange, tagRollbackInverse, } from "./changeRebaser.js";
import { mintCommit } from "./types.js";
export function rebaseBranch(mintRevisionTag, changeRebaser, sourceHead, targetCommit, targetHead = targetCommit) {
    // Get both source and target as path arrays
    const sourcePath = [];
    const targetPath = [];
    const ancestor = findCommonAncestor([sourceHead, sourcePath], [targetHead, targetPath]);
    assert(ancestor !== undefined, 0x675 /* branches must be related */);
    const sourceBranchLength = sourcePath.length;
    // Find where `targetCommit` is in the target branch
    const targetCommitIndex = targetPath.indexOf(targetCommit);
    if (targetCommitIndex === -1) {
        // If the targetCommit is not in the target path, then it is either disjoint from `target` or it is behind/at
        // the commit where source and target diverge (ancestor), in which case there is nothing more to rebase
        // TODO: Ideally, this would be an "assertExpensive". It is commented out because it causes O(N²) behavior when
        // processing N inbound commits from the same client whose ref seq# is not advancing (which is a common case).
        // N can be large when the client is sending a burst of changes (potentially on reconnection).
        // assert(
        // 	findCommonAncestor(targetCommit, targetHead) !== undefined,
        // 	0x676 /* target commit is not in target branch */,
        // );
        return {
            newSourceHead: sourceHead,
            sourceChange: undefined,
            commits: { deletedSourceCommits: [], targetCommits: [], sourceCommits: sourcePath },
            telemetryProperties: {
                sourceBranchLength,
                rebaseDistance: targetCommitIndex + 1,
                countDropped: 0,
            },
        };
    }
    // Iterate through the target path and look for commits that are also present on the source branch (i.e. they
    // have matching tags). Each commit found in the target branch can be skipped when processing the source branch
    // because it has already been rebased onto the target. In the case that one or more of these commits are present
    // directly after `targetCommit`, then the new base can be advanced further without having to do any work.
    const sourceSet = new Set(sourcePath.map((r) => r.revision));
    let newBaseIndex = targetCommitIndex;
    for (const [i, { revision }] of targetPath.entries()) {
        if (sourceSet.has(revision)) {
            sourceSet.delete(revision);
            newBaseIndex = Math.max(newBaseIndex, i);
        }
        else if (i > targetCommitIndex) {
            break;
        }
    }
    /** The commit on the target branch that the new source branch branches off of (i.e. the new common ancestor) */
    const newBase = targetPath[newBaseIndex] ?? oob();
    // Figure out how much of the trunk to start rebasing over.
    const targetCommits = targetPath.slice(0, newBaseIndex + 1);
    const deletedSourceCommits = [...sourcePath];
    // If the source and target rebase path begin with a range that has all the same revisions, remove it; it is
    // equivalent on both branches and doesn't need to be rebased.
    const targetRebasePath = [...targetCommits];
    if (hasSome(sourcePath) && hasSome(targetRebasePath)) {
        const minLength = Math.min(sourcePath.length, targetRebasePath.length);
        for (let i = 0; i < minLength; i++) {
            const firstSourcePath = sourcePath[0];
            const firstTargetRebasePath = targetRebasePath[0];
            if (firstSourcePath.revision === firstTargetRebasePath.revision) {
                sourcePath.shift();
                targetRebasePath.shift();
            }
        }
    }
    const sourceCommits = [];
    // If all commits that are about to be rebased over on the target branch already comprise the start of the source branch,
    // are in the same order, and have no other commits interleaving them, then no rebasing needs to occur. Those commits can
    // simply be removed from the source branch, and the remaining commits on the source branch are reparented off of the new
    // base commit.
    if (!hasSome(targetRebasePath)) {
        for (const c of sourcePath) {
            sourceCommits.push(mintCommit(sourceCommits[sourceCommits.length - 1] ?? newBase, c));
        }
        return {
            newSourceHead: sourceCommits[sourceCommits.length - 1] ?? newBase,
            sourceChange: undefined,
            commits: {
                deletedSourceCommits,
                targetCommits,
                sourceCommits,
            },
            telemetryProperties: {
                sourceBranchLength,
                rebaseDistance: targetCommits.length,
                countDropped: sourceBranchLength - sourceSet.size,
            },
        };
    }
    // For each source commit, rebase backwards over the inverses of any commits already rebased, and then
    // rebase forwards over the rest of the commits up to the new base before advancing the new base.
    let newHead = newBase;
    const revInfos = getRevInfoFromTaggedChanges([...targetRebasePath, ...sourcePath]);
    // Note that the `revisionMetadata` gets updated as `revInfos` gets updated.
    const revisionMetadata = revisionMetadataSourceFromInfo(revInfos);
    let editsToCompose = [...targetRebasePath];
    for (const c of sourcePath) {
        const rollback = rollbackFromCommit(changeRebaser, c, mintRevisionTag, true /* cache */);
        if (sourceSet.has(c.revision)) {
            const currentComposedEdit = makeAnonChange(changeRebaser.compose(editsToCompose));
            editsToCompose = [currentComposedEdit];
            const change = changeRebaser.rebase(c, currentComposedEdit, revisionMetadata);
            newHead = {
                revision: c.revision,
                change,
                parent: newHead,
            };
            sourceCommits.push(newHead);
            editsToCompose.push(tagChange(change, c.revision));
        }
        revInfos.push({ revision: c.revision });
        editsToCompose.unshift(rollback);
        revInfos.unshift({ revision: rollback.revision, rollbackOf: rollback.rollbackOf });
    }
    return defineLazyCachedProperty({
        newSourceHead: newHead,
        commits: {
            deletedSourceCommits,
            targetCommits,
            sourceCommits,
        },
        telemetryProperties: {
            sourceBranchLength,
            rebaseDistance: targetCommits.length,
            countDropped: sourceBranchLength - sourceSet.size,
        },
    }, "sourceChange", () => changeRebaser.compose(editsToCompose));
}
/**
 * "Sandwich/Horseshoe Rebase" a change over the given source and target branches
 * @param changeRebaser - the change rebaser responsible for rebasing the change over the commits in each branch
 * @param change - the change to rebase
 * @param sourceHead - the head of the branch that `change` is based on
 * @param targetHead - the branch to rebase `change` onto
 * @returns the rebased change
 *
 * @remarks inverses will be cached.
 */
export function rebaseChange(changeRebaser, change, sourceHead, targetHead, mintRevisionTag, ignoreNoChangeViolation) {
    const sourcePath = [];
    const targetPath = [];
    assert(findCommonAncestor([sourceHead, sourcePath], [targetHead, targetPath]) !== undefined, 0x576 /* branch A and branch B must be related */);
    const inverses = sourcePath.map((commit) => rollbackFromCommit(changeRebaser, commit, mintRevisionTag, true));
    inverses.reverse();
    const telemetryProperties = {
        sourceBranchLength: 1,
        rebaseDistance: sourcePath.length + targetPath.length,
        countDropped: 0,
    };
    return {
        change: rebaseChangeOverChanges(changeRebaser, change, [...inverses, ...targetPath], ignoreNoChangeViolation),
        telemetryProperties,
    };
}
export function revisionMetadataSourceFromInfo(revInfos) {
    const getIndex = (revision) => {
        const index = revInfos.findIndex((revInfo) => revInfo.revision === revision);
        return index >= 0 ? index : undefined;
    };
    const tryGetInfo = (revision) => {
        if (revision === undefined) {
            return undefined;
        }
        const index = getIndex(revision);
        return index === undefined ? undefined : revInfos[index];
    };
    const hasRollback = (revision) => {
        return revInfos.some((info) => info.rollbackOf === revision);
    };
    return { getIndex, tryGetInfo, hasRollback };
}
export function rebaseChangeOverChanges(changeRebaser, changeToRebase, changesToRebaseOver, ignoreNoChangeViolation) {
    const revisionMetadata = revisionMetadataSourceFromInfo(getRevInfoFromTaggedChanges([...changesToRebaseOver, changeToRebase]));
    let result = changeToRebase;
    for (const b of changesToRebaseOver) {
        result = mapTaggedChange(changeToRebase, changeRebaser.rebase(result, b, revisionMetadata, ignoreNoChangeViolation));
    }
    return result.change;
}
// TODO: Deduplicate
function getRevInfoFromTaggedChanges(changes) {
    const revInfos = [];
    for (const taggedChange of changes) {
        revInfos.push(...revisionInfoFromTaggedChange(taggedChange));
    }
    return revInfos;
}
// TODO: Deduplicate
function revisionInfoFromTaggedChange(taggedChange) {
    const revInfos = [];
    if (taggedChange.revision !== undefined) {
        const info = { revision: taggedChange.revision };
        if (taggedChange.rollbackOf !== undefined) {
            info.rollbackOf = taggedChange.rollbackOf;
        }
        revInfos.push(info);
    }
    return revInfos;
}
function rollbackFromCommit(changeRebaser, commit, mintRevisionTag, cache) {
    const cachedRollback = Rollback.get(commit);
    if (cachedRollback !== undefined) {
        return cachedRollback;
    }
    const tag = mintRevisionTag();
    const rollback = tagRollbackInverse(changeRebaser.invert(commit, true, tag), tag, commit.revision);
    if (cache === true) {
        Rollback.set(commit, rollback);
    }
    return rollback;
}
export function findAncestor(descendant, predicate = (t) => t.parent === undefined) {
    let d;
    let path;
    if (Array.isArray(descendant)) {
        [d, path] = descendant;
    }
    else {
        d = descendant;
    }
    for (let cur = d; cur !== undefined; cur = cur.parent) {
        if (predicate(cur)) {
            path?.reverse();
            return cur;
        }
        path?.push(cur);
    }
    if (path !== undefined) {
        path.length = 0;
    }
    return undefined;
}
/**
 * Find a common ancestor between two descendants that are linked by parent pointers.
 * @param descendantA - a descendant. If an empty `path` array is included, it will be populated
 * with the chain of commits from the ancestor to `descendantA` (not including the ancestor).
 * @param descendantB - another descendant. If an empty `path` array is included, it will be populated
 * with the chain of commits from the ancestor to `descendantB` (not including the ancestor).
 * @returns the common ancestor of `descendantA` and `descendantB`, or `undefined` if no such ancestor exists.
 *
 * @example
 *
 * ```typescript
 * interface Parented {
 *   parent?: Parented;
 * }
 * const shared = {};
 * const a = { parent: shared };
 * const b1 = { parent: shared };
 * const b2 = { parent: b1 };
 * const pathB: Parented[] = []
 * const ancestor = findCommonAncestor<Parented>(a, [b2, pathB]);
 * // ancestor === shared
 * // pathB === [b1, b2]
 * ```
 */
export function findCommonAncestor(descendantA, descendantB) {
    let a;
    let b;
    let pathA;
    let pathB;
    if (Array.isArray(descendantA)) {
        [a, pathA] = descendantA;
        assert(pathA === undefined || pathA.length === 0, 0x578 /* Path A must be empty */);
    }
    else {
        a = descendantA;
    }
    if (Array.isArray(descendantB)) {
        [b, pathB] = descendantB;
        assert(pathB === undefined || pathB.length === 0, 0x579 /* Path B must be empty */);
    }
    else {
        b = descendantB;
    }
    if (a === b) {
        return a;
    }
    const reversePaths = () => {
        pathA?.reverse();
        pathB?.reverse();
    };
    const visited = new Set();
    while (a !== undefined || b !== undefined) {
        if (a !== undefined) {
            if (visited.has(a)) {
                if (pathB !== undefined) {
                    pathB.length = pathB.findIndex((r) => Object.is(r, a));
                }
                reversePaths();
                return a;
            }
            visited.add(a);
            pathA?.push(a);
            a = a.parent;
        }
        if (b !== undefined) {
            if (visited.has(b)) {
                if (pathA !== undefined) {
                    pathA.length = pathA.findIndex((r) => Object.is(r, b));
                }
                reversePaths();
                return b;
            }
            visited.add(b);
            pathB?.push(b);
            b = b.parent;
        }
    }
    if (pathA !== undefined) {
        pathA.length = 0;
    }
    if (pathB !== undefined) {
        pathB.length = 0;
    }
    return undefined;
}
export function replaceChange(commit, change) {
    const output = { ...commit, change };
    Rollback.set(output, undefined);
    return output;
}
/** Associates rollback data with commits */
var Rollback;
(function (Rollback) {
    const map = new WeakMap();
    function get(commit) {
        return map.get(commit);
    }
    Rollback.get = get;
    function set(commit, rollback) {
        if (rollback === undefined) {
            map.delete(commit);
        }
        else {
            map.set(commit, rollback);
        }
    }
    Rollback.set = set;
})(Rollback || (Rollback = {}));
/**
 * Checks if one node is an ancestor of another in a parent-linked tree structure.
 * @param ancestor - The potential ancestor node
 * @param descendant - The potential descendant node
 * @param allowEqual - If true, returns true when ancestor === descendant
 * @returns true if ancestor is an ancestor of descendant (or equal if allowEqual is true)
 */
export function isAncestor(ancestor, descendant, allowEqual) {
    if (allowEqual && ancestor === descendant) {
        return true;
    }
    let current = descendant.parent;
    while (current !== undefined) {
        if (current === ancestor) {
            return true;
        }
        current = current.parent;
    }
    return false;
}
/**
 * Returns a change that represents the different between two places in a commit graph.
 * Applying this change to the state at `sourceCommit` will yield the state at `targetCommit`.
 * @param changeRebaser - the change rebaser responsible for providing inverses to the changes in the commits.
 * @param sourceCommit - the commit representing the initial state.
 * @param targetCommit - the commit representing the final state.
 * @param mintRevisionTag - a function which can be called to create revision tags for any rollback changes that need to be generated.
 * @returns a change representing the difference between `sourceCommit` and `targetCommit`.
 */
export function diffHistories(changeRebaser, sourceCommit, targetCommit, mintRevisionTag) {
    const sourcePath = [];
    const targetPath = [];
    const ancestor = findCommonAncestor([sourceCommit, sourcePath], [targetCommit, targetPath]);
    assert(ancestor !== undefined, 0xc6f /* Branches must be related */);
    const inverses = sourcePath.map((commit) => rollbackFromCommit(changeRebaser, commit, mintRevisionTag));
    inverses.reverse();
    return changeRebaser.compose([...inverses, ...targetPath]);
}
//# sourceMappingURL=utils.js.map