// Tests that the $merge stage enforces that the "on" fields argument can be used to uniquely
// identify documents by checking that there is a supporting unique, non-partial,
// collator-compatible index in the index catalog. This is meant to test sharding-related
// configurations that are not covered by the aggregation passthrough suites.
import {
    assertMergeFailsWithoutUniqueIndex,
    assertMergeSucceedsWithExpectedUniqueIndex,
    withEachMergeMode,
} from "jstests/aggregation/extras/merge_helpers.js";
import {assertErrCodeAndErrMsgContains, assertErrorCode} from "jstests/aggregation/extras/utils.js";
import {ShardingTest} from "jstests/libs/shardingtest.js";

const st = new ShardingTest({shards: 2, rs: {nodes: 1}});

const mongosDB = st.s0.getDB("merge_requires_unique_index");
const foreignDB = st.s0.getDB("merge_requires_unique_index_foreign");

// Enable sharding on the test DB and ensure that shard0 is the primary.
assert.commandWorked(
    mongosDB.adminCommand({enableSharding: mongosDB.getName(), primaryShard: st.rs0.getURL()}));

// Enable sharding on the foreign DB, except ensure that shard1 is the primary shard.
assert.commandWorked(
    foreignDB.adminCommand({enableSharding: foreignDB.getName(), primaryShard: st.rs1.getURL()}));

const sourceColl = mongosDB.source;
let targetColl = mongosDB.target;

// Increase the log verbosity for sharding, in the hope of getting a clearer picture of the
// cluster writer as part of BF-11106. This should be removed once BF-11106 is fixed.
st.shard0.getDB("admin").setLogLevel(4, 'sharding');
st.shard1.getDB("admin").setLogLevel(4, 'sharding');

function resetTargetColl(shardKey, split) {
    targetColl.drop();
    // Shard the target collection, and set the unique flag to ensure that there's a unique
    // index on the shard key.
    assert.commandWorked(mongosDB.adminCommand(
        {shardCollection: targetColl.getFullName(), key: shardKey, unique: true}));
    assert.commandWorked(mongosDB.adminCommand({split: targetColl.getFullName(), middle: split}));
    assert.commandWorked(mongosDB.adminCommand(
        {moveChunk: targetColl.getFullName(), find: split, to: st.rs1.getURL()}));
}

function runOnFieldsTests(targetShardKey, targetSplit) {
    jsTestLog("Running unique key tests for target shard key " + tojson(targetShardKey));
    resetTargetColl(targetShardKey, targetSplit);

    // Not specifying "on" fields should always pass.
    assertMergeSucceedsWithExpectedUniqueIndex({source: sourceColl, target: targetColl});

    // Since the target collection is sharded with a unique shard key, specifying "on" fields
    // that is equal to the shard key should be valid.
    assertMergeSucceedsWithExpectedUniqueIndex(
        {source: sourceColl, target: targetColl, onFields: Object.keys(targetShardKey)});

    // Create a compound "on" fields consisting of the shard key and one additional field.
    let prefixPipeline = [{$addFields: {newField: 1}}];
    const indexSpec = Object.merge(targetShardKey, {newField: 1});

    // Expect the $merge to fail since we haven't created a unique index on the compound
    // "on" fields.
    assertMergeFailsWithoutUniqueIndex({
        source: sourceColl,
        onFields: Object.keys(indexSpec),
        target: targetColl,
        prevStages: prefixPipeline
    });

    // Create the unique index and verify that the "on" fields is now valid.
    assert.commandWorked(targetColl.createIndex(indexSpec, {unique: true}));
    assertMergeSucceedsWithExpectedUniqueIndex({
        source: sourceColl,
        target: targetColl,
        onFields: Object.keys(indexSpec),
        prevStages: prefixPipeline
    });

    // Create a non-unique index and make sure that doesn't work.
    assert.commandWorked(targetColl.dropIndex(indexSpec));
    assert.commandWorked(targetColl.createIndex(indexSpec));
    assertMergeFailsWithoutUniqueIndex({
        source: sourceColl,
        onFields: Object.keys(indexSpec),
        target: targetColl,
        prevStages: prefixPipeline
    });

    // Test that a unique, partial index on the "on" fields cannot be used to satisfy the
    // requirement.
    resetTargetColl(targetShardKey, targetSplit);
    assert.commandWorked(
        targetColl.createIndex(indexSpec, {unique: true, partialFilterExpression: {a: {$gte: 2}}}));
    assertMergeFailsWithoutUniqueIndex({
        source: sourceColl,
        onFields: Object.keys(indexSpec),
        target: targetColl,
        prevStages: prefixPipeline
    });

    // Test that a unique index on the "on" fields cannot be used to satisfy the requirement if
    // it has a different collation.
    resetTargetColl(targetShardKey, targetSplit);
    assert.commandWorked(
        targetColl.createIndex(indexSpec, {unique: true, collation: {locale: "en_US"}}));
    assertMergeFailsWithoutUniqueIndex({
        source: sourceColl,
        onFields: Object.keys(indexSpec),
        target: targetColl,
        prevStages: prefixPipeline
    });
    assertMergeFailsWithoutUniqueIndex({
        source: sourceColl,
        onFields: Object.keys(indexSpec),
        target: targetColl,
        options: {collation: {locale: "en"}},
        prevStages: prefixPipeline
    });
    assertMergeFailsWithoutUniqueIndex({
        source: sourceColl,
        onFields: Object.keys(indexSpec),
        target: targetColl,
        options: {collation: {locale: "simple"}},
        prevStages: prefixPipeline
    });
    assertMergeFailsWithoutUniqueIndex({
        source: sourceColl,
        onFields: Object.keys(indexSpec),
        target: targetColl,
        options: {collation: {locale: "en_US", strength: 1}},
        prevStages: prefixPipeline
    });
    assertMergeSucceedsWithExpectedUniqueIndex({
        source: sourceColl,
        target: targetColl,
        onFields: Object.keys(indexSpec),
        options: {collation: {locale: "en_US"}},
        prevStages: prefixPipeline
    });

    // Test that a unique index with dotted field names can be used.
    resetTargetColl(targetShardKey, targetSplit);
    const dottedPathIndexSpec = Object.merge(targetShardKey, {"newField.subField": 1});
    assert.commandWorked(targetColl.createIndex(dottedPathIndexSpec, {unique: true}));

    // No longer a supporting index on the original compound "on" fields.
    assertMergeFailsWithoutUniqueIndex({
        source: sourceColl,
        onFields: Object.keys(indexSpec),
        target: targetColl,
        prevStages: prefixPipeline
    });

    // Test that an embedded object matching the "on" fields is valid.
    prefixPipeline = [{$addFields: {"newField.subField": 5}}];
    assertMergeSucceedsWithExpectedUniqueIndex({
        source: sourceColl,
        target: targetColl,
        onFields: Object.keys(dottedPathIndexSpec),
        prevStages: prefixPipeline
    });

    // Test that we cannot use arrays with a dotted path within a $merge.
    resetTargetColl(targetShardKey, targetSplit);
    assert.commandWorked(targetColl.createIndex(dottedPathIndexSpec, {unique: true}));
    withEachMergeMode(({whenMatchedMode, whenNotMatchedMode}) => {
        assertErrorCode(sourceColl,
                        [
                            {
                                $replaceRoot: {
                                    newRoot:
                                        {$mergeObjects: ["$$ROOT", {newField: [{subField: 1}]}]}
                                }
                            },
                            {
                                $merge: {
                                    into: {
                                        db: targetColl.getDB().getName(),
                                        coll: targetColl.getName(),
                                    },
                                    whenMatched: whenMatchedMode,
                                    whenNotMatched: whenNotMatchedMode,
                                    on: Object.keys(dottedPathIndexSpec)
                                }
                            }
                        ],
                        [51132, 51185]);
    });

    // Test that a unique index that is multikey can still be used.
    resetTargetColl(targetShardKey, targetSplit);
    assert.commandWorked(targetColl.createIndex(dottedPathIndexSpec, {unique: true}));
    assert.commandWorked(targetColl.insert(
        Object.merge(targetShardKey, {newField: [{subField: "hi"}, {subField: "hello"}]})));
    assert.commandWorked(sourceColl.update(
        {}, {$set: {newField: {subField: "hi"}, proofOfUpdate: "PROOF"}}, {multi: true}));

    // If whenMatched is "replace" and whenNotMatched is "insert", expect the command to
    // fail if the "on" fields does not contain _id, since a replacement-style update will fail
    // if attempting to modify _id.
    if (dottedPathIndexSpec.hasOwnProperty("_id")) {
        assert.doesNotThrow(() => sourceColl.aggregate([{
            $merge: {
                into: {
                    db: targetColl.getDB().getName(),
                    coll: targetColl.getName(),
                },
                whenMatched: "replace",
                whenNotMatched: "insert",
                on: Object.keys(dottedPathIndexSpec)
            }
        }]));
        assert.docEq({newField: {subField: "hi"}, proofOfUpdate: "PROOF"},
                     targetColl.findOne({"newField.subField": "hi", proofOfUpdate: "PROOF"},
                                        {"newField.subField": 1, proofOfUpdate: 1, _id: 0}));
    } else {
        assertErrCodeAndErrMsgContains(sourceColl,
                                       [{
                                           $merge: {
                                               into: {
                                                   db: targetColl.getDB().getName(),
                                                   coll: targetColl.getName(),
                                               },
                                               whenMatched: "replace",
                                               whenNotMatched: "insert",
                                               on: Object.keys(dottedPathIndexSpec)
                                           }
                                       }],
                                       ErrorCodes.ImmutableField,
                                       "did you attempt to modify the _id or the shard key?");

        assert.doesNotThrow(() => sourceColl.aggregate([
            {$project: {_id: 0}},
            {
                $merge: {
                    into: {
                        db: targetColl.getDB().getName(),
                        coll: targetColl.getName(),
                    },
                    whenMatched: "replace",
                    whenNotMatched: "insert",
                    on: Object.keys(dottedPathIndexSpec)
                }
            }
        ]));
        assert.docEq({newField: {subField: "hi"}, proofOfUpdate: "PROOF"},
                     targetColl.findOne({"newField.subField": "hi", proofOfUpdate: "PROOF"},
                                        {"newField.subField": 1, proofOfUpdate: 1, _id: 0}));
    }
}

function testAgainstDB(targetDB) {
    targetColl = targetDB["target"];
    targetColl.drop();

    //
    // Test unsharded source and sharded target collections.
    //
    let targetShardKey = {_id: 1, a: 1, b: 1};
    let splitPoint = {_id: 0, a: 0, b: 0};
    sourceColl.drop();
    assert.commandWorked(sourceColl.insert([{a: 0, b: 0}, {a: 1, b: 1}]));
    runOnFieldsTests(targetShardKey, splitPoint);

    // Test with a shard key that does *not* include _id.
    targetShardKey = {a: 1, b: 1};
    splitPoint = {a: 0, b: 0};
    runOnFieldsTests(targetShardKey, splitPoint);

    //
    // Test both source and target collections as sharded.
    //
    targetShardKey = {_id: 1, a: 1, b: 1};
    splitPoint = {_id: 0, a: 0, b: 0};
    sourceColl.drop();
    st.shardColl(sourceColl.getName(), {a: 1}, {a: 0}, {a: 1}, mongosDB.getName());
    assert.commandWorked(sourceColl.insert([{a: 0, b: 0}, {a: 1, b: 1}]));
    runOnFieldsTests(targetShardKey, splitPoint);

    // Re-run the test with a shard key that does *not* include _id.
    targetShardKey = {a: 1, b: 1};
    splitPoint = {a: 0, b: 0};
    runOnFieldsTests(targetShardKey, splitPoint);
}

// First test $merge to the same database as the source.
testAgainstDB(mongosDB);

// Then test against a foreign database, with the same expected behavior.
testAgainstDB(foreignDB);

st.stop();
