/*!
 * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
 * Licensed under the MIT License.
 */
import { strict as assert } from "assert";
import { describeStress } from "@fluid-private/stochastic-test-utils";
import { makeAnonChange, tagChange, tagRollbackInverse, } from "../../../core/index.js";
// TODO: Throughout this file, we use TestChange as the child change type.
// This is the same approach used in sequenceChangeRebaser.spec.ts, but it requires casting in this file
// since OptionalChangeset is not generic over the child changeset type.
// Search this file for "as any" and "as NodeChangeset"
import { TestChange } from "../../testChange.js";
import { deepFreeze, defaultRevInfosFromChanges, defaultRevisionMetadataFromChanges, isDeltaVisible, } from "../../utils.js";
import { brand, idAllocatorFromMaxId } from "../../../util/index.js";
import { optionalChangeRebaser, optionalFieldEditor, optionalFieldIntoDelta,
// eslint-disable-next-line import/no-internal-modules
 } from "../../../feature-libraries/optional-field/index.js";
import { getSequentialEdits, generatePossibleSequenceOfEdits, getSequentialStates, } from "../../exhaustiveRebaserUtils.js";
import { runExhaustiveComposeRebaseSuite } from "../../rebaserAxiomaticTests.js";
import { rebaseRevisionMetadataFromInfo,
// eslint-disable-next-line import/no-internal-modules
 } from "../../../feature-libraries/modular-schema/index.js";
import { Change, assertTaggedEqual, verifyContextChain } from "./optionalFieldUtils.js";
function makeRevisionTagMinter(prefix = "rev") {
    // Rather than use UUIDs, allocate these sequentially for an easier time debugging tests.
    let currentRevision = 0;
    return () => `${prefix}${currentRevision++}`;
}
const type = brand("Node");
const mintRevisionTag = makeRevisionTagMinter();
const tag1 = mintRevisionTag();
const OptionalChange = {
    set(value, wasEmpty, ids) {
        return optionalFieldEditor.set(wasEmpty, ids);
    },
    clear(wasEmpty, id) {
        return optionalFieldEditor.clear(wasEmpty, id);
    },
    buildChildChange(childChange) {
        return optionalFieldEditor.buildChildChange(0, childChange);
    },
};
const failCrossFieldManager = {
    get: () => assert.fail("Should not query CrossFieldManager"),
    set: () => assert.fail("Should not modify CrossFieldManager"),
};
function toDelta(change, revision) {
    return optionalFieldIntoDelta(tagChange(change, revision), (childChange) => TestChange.toDelta(tagChange(childChange, revision)));
}
function getMaxId(...changes) {
    let max;
    const ingest = (candidate) => {
        if (max === undefined || (candidate !== undefined && candidate > max)) {
            max = candidate;
        }
    };
    for (const change of changes) {
        for (const [src, dst] of change.moves) {
            ingest(src.localId);
            ingest(dst.localId);
        }
        for (const [id] of change.childChanges) {
            if (id !== "self") {
                // Child changes do not need to be ingested for this test file, as TestChange (which is used as a child)
                // doesn't have any `ChangesetLocalId`s.
                ingest(id.localId);
            }
        }
        if (change.valueReplace !== undefined) {
            ingest(change.valueReplace.dst.localId);
            if (change.valueReplace.src !== undefined && change.valueReplace.src !== "self") {
                ingest(change.valueReplace.src.localId);
            }
        }
    }
    return max;
}
function invert(change, isRollback) {
    const inverted = optionalChangeRebaser.invert(change, TestChange.invert, isRollback, idAllocatorFromMaxId(), failCrossFieldManager, defaultRevisionMetadataFromChanges([change]));
    verifyContextChain(change, makeAnonChange(inverted));
    return inverted;
}
function rebase(change, base, metadataArg) {
    deepFreeze(change);
    deepFreeze(base);
    const metadata = metadataArg ??
        rebaseRevisionMetadataFromInfo(defaultRevInfosFromChanges([base, makeAnonChange(change)]), [
            base.revision,
        ]);
    const moveEffects = failCrossFieldManager;
    const idAllocator = idAllocatorFromMaxId(getMaxId(change, base.change));
    const rebased = optionalChangeRebaser.rebase(change, base, TestChange.rebase, idAllocator, moveEffects, metadata, undefined);
    verifyContextChain(base, makeAnonChange(rebased));
    return rebased;
}
function rebaseTagged(change, ...baseChanges) {
    let currChange = change;
    for (const base of baseChanges) {
        currChange = tagChange(rebase(currChange.change, base), currChange.revision);
        verifyContextChain(base, currChange);
    }
    return currChange;
}
function rebaseComposed(metadata, change, ...baseChanges) {
    baseChanges.forEach((base) => deepFreeze(base));
    deepFreeze(change);
    const composed = composeList(baseChanges, metadata);
    const moveEffects = failCrossFieldManager;
    const idAllocator = idAllocatorFromMaxId(getMaxId(composed));
    const rebased = optionalChangeRebaser.rebase(change, makeAnonChange(composed), TestChange.rebase, idAllocator, moveEffects, metadata, undefined);
    const lastBase = baseChanges.at(-1);
    if (lastBase !== undefined) {
        verifyContextChain(lastBase, makeAnonChange(rebased));
    }
    return rebased;
}
function composeList(changes, metadata) {
    const moveEffects = failCrossFieldManager;
    const idAllocator = idAllocatorFromMaxId(getMaxId(...changes.map((c) => c.change)));
    let composed = Change.empty();
    const metadataOrDefault = metadata ?? defaultRevisionMetadataFromChanges(changes);
    for (const change of changes) {
        verifyContextChain(makeAnonChange(composed), change);
        composed = optionalChangeRebaser.compose(makeAnonChange(composed), change, TestChange.compose, idAllocator, moveEffects, metadataOrDefault);
    }
    return composed;
}
function compose(change1, change2, metadata) {
    verifyContextChain(change1, change2);
    const moveEffects = failCrossFieldManager;
    const idAllocator = idAllocatorFromMaxId(getMaxId(change1.change, change2.change));
    return optionalChangeRebaser.compose(change1, change2, TestChange.compose, idAllocator, moveEffects, metadata ?? defaultRevisionMetadataFromChanges([change1, change2]));
}
function computeChildChangeInputContext(inputState) {
    // This is effectively a filter of the intentions from all edits such that it only includes
    // intentions for edits which modify the same child as the final one in the changeset.
    // Note: this takes a dependency on the fact that `generateChildStates` doesn't set matching string
    // content for what are meant to represent different nodes.
    const states = getSequentialStates(inputState);
    const finalContent = states.at(-1)?.content;
    assert(finalContent !== undefined, "Child change input context should only be computed when the optional field has content.");
    const intentions = [];
    let currentContent;
    for (const state of states) {
        if (state.mostRecentEdit !== undefined &&
            currentContent === finalContent &&
            state.mostRecentEdit.changeset.change.childChanges.length > 0) {
            if (state.mostRecentEdit.changeset.change.childChanges !== undefined) {
                intentions.push(state.mostRecentEdit.intention);
            }
        }
        currentContent = state.content;
    }
    return intentions;
}
/**
 * See {@link ChildStateGenerator}
 */
const generateChildStates = function* (state, tagFromIntention, mintIntention) {
    const mintId = mintIntention;
    const edits = getSequentialEdits(state);
    if (state.content !== undefined) {
        const changeChildIntention = mintIntention();
        yield {
            content: state.content,
            mostRecentEdit: {
                changeset: tagChange(OptionalChange.buildChildChange(TestChange.mint(computeChildChangeInputContext(state), changeChildIntention)), tagFromIntention(changeChildIntention)),
                intention: changeChildIntention,
                description: `ChildChange${changeChildIntention}`,
            },
            parent: state,
        };
        const setUndefinedIntention = mintIntention();
        yield {
            content: undefined,
            mostRecentEdit: {
                changeset: tagChange(OptionalChange.clear(false, mintId()), tagFromIntention(setUndefinedIntention)),
                intention: setUndefinedIntention,
                description: "Remove",
            },
            parent: state,
        };
    }
    else {
        // Even if there is no content, optional field supports an explicit clear operation with LWW semantics,
        // as a concurrent set operation may populate the field.
        const setUndefinedIntention = mintIntention();
        yield {
            content: undefined,
            mostRecentEdit: {
                changeset: tagChange(OptionalChange.clear(true, mintId()), tagFromIntention(setUndefinedIntention)),
                intention: setUndefinedIntention,
                description: "Remove",
            },
            parent: state,
        };
    }
    for (const value of ["A", "B"]) {
        const setIntention = mintIntention();
        const [fill, detach] = [mintId(), mintId()];
        // Using length of the input context guarantees set operations generated at different times also have different
        // values, which should tend to be easier to debug.
        // This also makes the logic to determine intentions simpler.
        const newContents = `${value},${edits.length}`;
        yield {
            content: newContents,
            mostRecentEdit: {
                changeset: tagChange(OptionalChange.set(newContents, state.content === undefined, {
                    fill,
                    detach,
                }), tagFromIntention(setIntention)),
                intention: setIntention,
                description: `Set${newContents}`,
            },
            parent: state,
        };
    }
    if (state.mostRecentEdit !== undefined) {
        const undoIntention = mintIntention();
        // We don't use the `invert` helper here, as `TestChange.invert` has logic to negate the intention
        // of the most recent edit. Instead, we want to mint a new intention. Having correct composition for
        // the 'negate' operation is already tested via sandwich rebasing.
        const invertTestChangeViaNewIntention = (change) => {
            if ("inputContext" in change) {
                return {
                    inputContext: change.outputContext,
                    outputContext: [...change.outputContext, undoIntention],
                    intentions: [undoIntention],
                };
            }
            return TestChange.emptyChange;
        };
        const inverseChangeset = optionalChangeRebaser.invert(state.mostRecentEdit.changeset, invertTestChangeViaNewIntention, false, idAllocatorFromMaxId(), failCrossFieldManager, defaultRevisionMetadataFromChanges([state.mostRecentEdit.changeset]));
        yield {
            content: state.parent?.content,
            mostRecentEdit: {
                changeset: tagChange(inverseChangeset, tagFromIntention(undoIntention)),
                intention: undoIntention,
                description: `Undo:${state.mostRecentEdit.description}`,
            },
            parent: state,
        };
    }
};
/**
 * Runs a suite of axiomatic tests which use combinations of single edits that are valid to apply from an initial state.
 */
function runSingleEditRebaseAxiomSuite(initialState) {
    const singleTestChanges = (prefix) => generatePossibleSequenceOfEdits(initialState, generateChildStates, 1, prefix);
    /**
     * This test simulates rebasing over an do-inverse pair.
     */
    describe("A ↷ [B, B⁻¹] === A", () => {
        for (const [{ description: name1, changeset: change1 }] of singleTestChanges("A")) {
            for (const [{ description: name2, changeset: change2 }] of singleTestChanges("B")) {
                const title = `(${name1} ↷ ${name2}) ↷ ${name2}⁻¹ => ${name1}`;
                it(title, () => {
                    const inv = tagRollbackInverse(invert(change2, true), tag1, change2.revision);
                    const r1 = rebaseTagged(change1, change2);
                    const r2 = rebaseTagged(r1, inv);
                    assert.deepEqual(r2.change, change1.change);
                });
            }
        }
    });
    /**
     * This test simulates rebasing over an do-undo pair.
     * It is different from the above in two ways:
     * - The undo(B) changeset bears a different RevisionTag than B
     * - The inverse produced by undo(B) is not a rollback
     */
    describe("A ↷ [B, undo(B)] => A", () => {
        for (const [{ description: name1, changeset: change1 }] of singleTestChanges("A")) {
            for (const [{ description: name2, changeset: change2 }] of singleTestChanges("B")) {
                const title = `${name1} ↷ [${name2}, undo(${name2})] => ${name1}`;
                it(title, () => {
                    const inv = tagChange(invert(change2, false), tag1);
                    const r1 = rebaseTagged(change1, change2);
                    const r2 = rebaseTagged(r1, inv);
                    assert.deepEqual(r2.change, change1.change);
                });
            }
        }
    });
    /**
     * This test simulates sandwich rebasing:
     * a change is first rebased over the inverse of a change it took for granted
     * then rebased over the updated version of that change (the same as the original in our case).
     *
     * The first rebase (A ↷ B) is purely for the purpose of manufacturing a change to which we can
     * apply the inverse of some change.
     */
    describe("(A ↷ B) ↷ [B⁻¹, B] === A ↷ B", () => {
        for (const [{ description: name1, changeset: change1 }] of singleTestChanges("A")) {
            for (const [{ description: name2, changeset: change2 }] of singleTestChanges("B")) {
                const title = `${name1} ↷ [${name2}, ${name2}⁻¹, ${name2}] => ${name1} ↷ ${name2}`;
                it(title, () => {
                    const inverse2 = tagRollbackInverse(invert(change2, true), tag1, change2.revision);
                    const r1 = rebaseTagged(change1, change2);
                    const r2 = rebaseTagged(r1, inverse2);
                    const r3 = rebaseTagged(r2, change2);
                    assert.deepEqual(r3.change, r1.change);
                });
            }
        }
    });
    describe("A ○ A⁻¹ === ε", () => {
        for (const [{ description: name, changeset: change }] of singleTestChanges("A")) {
            it(`${name} ○ ${name}⁻¹ === ε`, () => {
                const inv = invert(change, true);
                const actual = compose(change, tagRollbackInverse(inv, tag1, change.revision));
                const delta = toDelta(actual);
                assert.equal(isDeltaVisible(delta), false);
            });
        }
    });
    describe("A⁻¹ ○ A === ε", () => {
        for (const [{ description: name, changeset: change }] of singleTestChanges("A")) {
            it(`${name}⁻¹ ○ ${name} === ε`, () => {
                const inv = tagRollbackInverse(invert(change, true), tag1, change.revision);
                const actual = compose(inv, change);
                const delta = toDelta(actual);
                assert.equal(isDeltaVisible(delta), false);
            });
        }
    });
}
export function testRebaserAxioms() {
    describe("Rebaser Axioms", () => {
        describe("Using valid edits from an undefined field", () => {
            runSingleEditRebaseAxiomSuite({ content: undefined });
        });
        describe("Using valid edits from a field with content", () => {
            runSingleEditRebaseAxiomSuite({ content: "A" });
        });
        describeStress("Exhaustive", ({ isStress }) => {
            runExhaustiveComposeRebaseSuite([{ content: undefined }, { content: "A" }], generateChildStates, {
                rebase,
                rebaseComposed,
                compose,
                invert,
                assertEqual: assertTaggedEqual,
                createEmpty: Change.empty,
            }, {
                numberOfEditsToRebase: 3,
                numberOfEditsToRebaseOver: isStress ? 5 : 3,
                numberOfEditsToVerifyAssociativity: isStress ? 6 : 3,
            });
        });
    });
}
//# sourceMappingURL=optionalChangeRebaser.test.js.map