(function() {
    'use strict';
    const debug = 0;

    let rst = new ReplSetTest({name: "applyOpsIdempotency", nodes: 1});
    rst.startSet();
    rst.initiate();

    /**
     * Returns true if this database contains any drop-pending collections.
     */
    function containsDropPendingCollection(mydb) {
        const res =
            assert.commandWorked(mydb.runCommand("listCollections", {includePendingDrops: true}));
        const collectionInfos = res.cursor.firstBatch;
        const collectionNames = collectionInfos.map(c => c.name);
        return Boolean(collectionNames.find(c => c.indexOf('system.drop.') == 0));
    }

    /**
     *  Apply ops on mydb, asserting success.
     */
    function assertApplyOpsWorks(testdbs, ops) {
        // Remaining operations in ops must still be applied
        while (ops.length) {
            let cmd = {applyOps: ops};
            let res = testdbs[0].adminCommand(cmd);
            if (debug) {
                printjson({applyOps: ops, res});
            }

            // Wait for any drop-pending collections to be removed by the reaper before proceeding.
            assert.soon(function() {
                return !testdbs.find(mydb => containsDropPendingCollection(mydb));
            });

            // If the entire operation succeeded, we're done.
            if (res.ok == 1)
                return res;

            // Skip any operations that succeeded.
            while (res.applied-- && res.results.shift())
                ops.shift();

            // These errors are expected when replaying operations and should be ignored.
            if (res.code == ErrorCodes.NamespaceNotFound || res.code == ErrorCodes.DuplicateKey) {
                ops.shift();
                continue;
            }

            // Generate the appropriate error message.
            assert.commandWorked(res, tojson(cmd));
        }
    }

    /**
     *  Run the dbHash command on mydb, assert it worked and return the md5.
     */
    function dbHash(mydb) {
        let cmd = {dbHash: 1};
        let res = mydb.runCommand(cmd);
        assert.commandWorked(res, tojson(cmd));
        return res.md5;
    }

    /**
     *  Gather collection info and dbHash results of each of the passed databases.
     */
    function dbInfo(dbs) {
        return dbs.map((db) => {
            return {name: db.getName(), info: db.getCollectionInfos(), md5: dbHash(db)};
        });
    }

    var getCollections = (mydb, prefixes) => prefixes.map((prefix) => mydb[prefix]);

    /**
     *  Test functions to run and test using replay of oplog.
     */
    var tests = {
        crud: (mydb) => {
            let [x, y, z] = getCollections(mydb, ['x', 'y', 'z']);
            assert.writeOK(x.insert({_id: 1}));
            assert.writeOK(x.update({_id: 1}, {$set: {x: 1}}));
            assert.writeOK(x.remove({_id: 1}));

            assert.writeOK(y.update({_id: 1}, {y: 1}));
            assert.writeOK(y.insert({_id: 2, y: false, z: false}));
            assert.writeOK(y.update({_id: 2}, {y: 2}));

            assert.writeOK(z.insert({_id: 1, z: 1}));
            assert.writeOK(z.remove({_id: 1}));
            assert.writeOK(z.insert({_id: 1}));
            assert.writeOK(z.insert({_id: 2, z: 2}));
        },
        renameCollectionWithinDatabase: (mydb) => {
            let [x, y, z] = getCollections(mydb, ['x', 'y', 'z']);
            assert.writeOK(x.insert({_id: 1, x: 1}));
            assert.writeOK(y.insert({_id: 1, y: 1}));

            assert.commandWorked(x.renameCollection(z.getName()));
            assert.writeOK(z.insert({_id: 2, x: 2}));
            assert.writeOK(x.insert({_id: 2, x: false}));
            assert.writeOK(y.insert({y: 2}));

            assert.commandWorked(y.renameCollection(x.getName(), true));
            assert.commandWorked(z.renameCollection(y.getName()));
        },
        renameCollectionAcrossDatabases: (mydb) => {
            let otherdb = mydb.getSiblingDB(mydb + '_');
            let [x, y] = getCollections(mydb, ['x', 'y']);
            let [z] = getCollections(otherdb, ['z']);
            assert.writeOK(x.insert({_id: 1, x: 1}));
            assert.writeOK(y.insert({_id: 1, y: 1}));

            assert.commandWorked(
                mydb.adminCommand({renameCollection: x.getFullName(), to: z.getFullName()}));
            assert.writeOK(z.insert({_id: 2, x: 2}));
            assert.writeOK(x.insert({_id: 2, x: false}));
            assert.writeOK(y.insert({y: 2}));

            assert.commandWorked(mydb.adminCommand(
                {renameCollection: y.getFullName(), to: x.getFullName(), dropTarget: true}));
            assert.commandWorked(
                mydb.adminCommand({renameCollection: z.getFullName(), to: y.getFullName()}));
            return [mydb, otherdb];
        },
        createIndex: (mydb) => {
            let [x, y] = getCollections(mydb, ['x', 'y']);
            assert.commandWorked(x.createIndex({x: 1}));
            assert.writeOK(x.insert({_id: 1, x: 1}));
            assert.writeOK(y.insert({_id: 1, y: 1}));
            assert.commandWorked(y.createIndex({y: 1}));
            assert.writeOK(y.insert({_id: 2, y: 2}));
        },
    };

    /**
     *  Create a new uniquely named database, execute testFun and compute the dbHash. Then replay
     *  all different suffixes of the oplog and check for the correct hash. If testFun creates
     *  additional databases, it should return an array with all databases to check.
     */
    function testIdempotency(primary, testFun, testName) {
        // Create a new database name, so it's easier to filter out our oplog records later.
        let dbname = (new Date()).toISOString().match(/[-0-9T]/g).join('');  // 2017-05-30T155055713
        let mydb = primary.getDB(dbname);

        // Allow testFun to return the array of databases to check (default is mydb).
        let testdbs = testFun(mydb) || [mydb];
        let expectedInfo = dbInfo(testdbs);

        let oplog = mydb.getSiblingDB('local').oplog.rs;
        let ops = oplog
                      .find({op: {$ne: 'n'}, ns: new RegExp('^' + mydb.getName())},
                            {ts: 0, t: 0, h: 0, v: 0})
                      .toArray();
        assert.gt(ops.length, 0, 'Could not find any matching ops in the oplog');
        testdbs.forEach((db) => assert.commandWorked(db.dropDatabase()));

        if (debug) {
            print(testName + ': replaying suffixes of ' + ops.length + ' operations');
            printjson(ops);
        }

        for (let j = 0; j < ops.length; j++) {
            let replayOps = ops.slice(j);
            assertApplyOpsWorks(testdbs, replayOps);
            let actualInfo = dbInfo(testdbs);
            assert.eq(actualInfo,
                      expectedInfo,
                      'unexpected differences between databases after replaying final ' +
                          replayOps.length + ' ops in test ' + testName + ": " + tojson(replayOps));
        }
    }

    for (let f in tests)
        testIdempotency(rst.getPrimary(), tests[f], f);
})();
