"use strict";
/*jslint nomen: true, plusplus: true, vars: true */
/*global Messages, Script, MyAvatar, AvatarList, Entities, print */

//  Created by Howard Stearns
//  Copyright 2016 High Fidelity, Inc.
//  
//  Distributed under the Apache License, Version 2.0.
//  See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
//
//  Allows cooperating scripts to pass a "virtual baton" between them,
//  which is useful when part of a script should only be executed by
//  the one participant that is holding this particular baton.
// 
//  A virtual baton is simply any string agreed upon by the scripts
//  that use it. Only one script at a time can hold the baton, and it
//  holds it until that script releases it, or the other scripts
//  determine that the holding script is not responding. The script
//  automatically determines who among claimants has the baton, if anyone,
//  and holds an "election" if necessary.
//
//  See entityScript/tribble.js as an example, and the functions
//  virtualBaton(), claim(), release().
//

// Answers a new virtualBaton for the given parameters, of which 'key'
// is required.
function virtualBatonf(options) {
    // Answer averages (number +/- variability). Avoids having everyone act in lockstep.
    function randomize(number, variability) {
        var allowedDeviation = number * variability; // one side of the deviation range
        var allowedDeviationRange = allowedDeviation * 2; // total range for +/- deviation
        var randomDeviation = Math.random() * allowedDeviationRange;
        var result = number - allowedDeviation + randomDeviation;
        return result;
    }
    // Allow testing outside in a harness outside of High Fidelity.
    // See sourceCodeSandbox/tests/mocha/test/testVirtualBaton.js
    var globals = options.globals || {},
        messages = globals.Messages || Messages,
        myAvatar = globals.MyAvatar || MyAvatar,
        avatarList = globals.AvatarList || AvatarList,
        entities = globals.Entities || Entities,
        timers = globals.Script || Script,
        log = globals.print || print;

    var batonName = options.batonName,  // The identify of the baton.
        // instanceId is the identify of this particular copy of the script among all copies using the same batonName
        // in the domain. For example, if you wanted only one entity among multiple entity scripts to hold the baton,
        // you could specify virtualBaton({batonName: 'someBatonName', instanceId: MyAvatar.sessionUUID + entityID}).
        instanceId = options.instanceId || myAvatar.sessionUUID,
        // virtualBaton() returns the exports object with properties. You can pass in an object to be side-effected.
        exports = options.exports || {},
        // Handy to set false if we believe the optimizations are wrong, or to use both values in a test harness.
        useOptimizations = (options.useOptimizations === undefined) ? true : options.useOptimizations,
        electionTimeout = options.electionTimeout || randomize(500, 0.2), // ms. If no winner in this time, hold a new election.
        recheckInterval = options.recheckInterval || randomize(500, 0.2), // ms. Check that winners remain connected.
        // If you supply your own instanceId, you might also supply a connectionTest that answers
        // truthy iff the given id is still valid and connected, and is run at recheckInterval. You
        // can use exports.validId (see below), and the default answers truthy if id is valid or a
        // concatenation of two valid ids. (This handles the most common cases of instanceId being
        // either (the default) MyAvatar.sessionUUID, an entityID, or the concatenation (in either
        // order) of both.)
        connectionTest = options.connectionTest || function connectionTest(id) {
            var idLength = 38;
            if (id.length === idLength) {
                return exports.validId(id);
            }
            return (id.length === 2 * idLength) && exports.validId(id.slice(0, idLength)) && exports.validId(id.slice(idLength));
        };

    if (!batonName) {
        throw new Error("A virtualBaton must specify a batonName.");
    }
    // Truthy if id exists as either a connected avatar or valid entity.
    exports.validId = function validId(id) {
        var avatar = avatarList.getAvatar(id);
        if (avatar && (avatar.sessionUUID === id)) {
            return true;
        }
        var properties = entities.getEntityProperties(id, ['type']);
        return properties && properties.type;
    };

    // Various logging, controllable through options.
    function debug() { // Display the arguments not just [Object object].
        log.apply(null, [].map.call(arguments, JSON.stringify));
    }
    function debugFlow() {
        if (options.debugFlow) {
            debug.apply(null, arguments);
        }
    }
    function debugSend(destination, operation, data) {
        if (options.debugSend) {
            debug('baton:', batonName, instanceId, 's=>', destination, operation, data);
        }
    }
    function debugReceive(senderID, operation, data) { // senderID is client sessionUUID -- not necessarily instanceID!
        if (options.debugReceive) {
            debug('baton:', batonName, senderID, '=>r', instanceId, operation, data);
        }
    }

    // Messages: Just synactic sugar for hooking things up to Messages system.
    // We create separate subchannel strings for each operation within our general channelKey, instead of using
    // a switch in the receiver.
    var channelKey = "io.highfidelity.virtualBaton:" + batonName,
        subchannelHandlers = {}, // Message channel string => {receiver, op}
        subchannelKeys = {};     // operation => Message channel string
    function subchannelKey(operation) {
        return channelKey + ':' + operation;
    }
    function receive(operation, handler) { // Record a handler for an operation on our channelKey
        var subKey = subchannelKey(operation);
        subchannelHandlers[subKey] = {receiver: handler, op: operation};
        subchannelKeys[operation] = subKey;
        messages.subscribe(subKey);
    }
    function sendHelper(subchannel, data) {
        var message = JSON.stringify(data);
        messages.sendMessage(subchannel, message);
    }
    function send1(operation, destination, data) { // Send data for an operation to just one destination on our channelKey.
        debugSend(destination, operation, data);
        sendHelper(subchannelKey(operation) + destination, data);
    }
    function send(operation, data) {  // Send data for an operation on our channelKey.
        debugSend('-', operation, data);
        sendHelper(subchannelKeys[operation], data);
    }
    function messageHandler(channel, messageString, senderID) {
        var handler = subchannelHandlers[channel];
        if (!handler) {
            return;
        }
        var data = JSON.parse(messageString);
        debugReceive(senderID, handler.op, data);
        handler.receiver(data);
    }
    messages.messageReceived.connect(messageHandler);

    var nPromises = 0, nAccepted = 0, electionWatchdog;

    // It would be great if we had a way to know how many subscribers our channel has. Failing that...
    var nNack = 0, previousNSubscribers = 0, lastGathering = 0, thisTimeout = electionTimeout;
    function nSubscribers() { // Answer the number of subscribers.
        // To find nQuorum, we need to know how many scripts are being run using this batonName, which isn't
        // the same as the number of clients!
        //
        // If we overestimate by too much, we may fail to reach consensus, which triggers a new
        // election proposal, so we take the number of acceptors to be the max(nPromises, nAccepted)
        // + nNack reported in the previous round.
        //
        // If we understimate by too much, there can be different pockets on the Internet that each
        // believe they have agreement on different holders of the baton, which is precisely what
        // the virtualBaton is supposed to avoid. Therefore we need to allow 'nack' to gather stragglers.

        var now = Date.now(), elapsed = now - lastGathering;
        if (elapsed >= thisTimeout) {
            previousNSubscribers = Math.max(nPromises, nAccepted) + nNack;
            lastGathering = now;
        } // ...otherwise we use the previous value unchanged.

        // On startup, we do one proposal that we cannot possibly close, so that we'll
        // lock things up for the full electionTimeout to gather responses.
        if (!previousNSubscribers) {
            var LARGE_INTEGER = Number.MAX_SAFE_INTEGER || (-1 >>> 1); // QT doesn't define the ECMA constant. Max int will do for our purposes.
            previousNSubscribers = LARGE_INTEGER;
        }
        return previousNSubscribers;
    }

    // MAIN ALGORITHM
    //
    // Internally, this uses the Paxos algorith to hold elections.
    // Alternatively, we could have the message server pick and maintain a winner, but it would
    // still have to deal with the same issues of verification in the presence of lost/delayed/reordered messages.
    // Paxos is known to be optimal under these circumstances, except that its best to have a dedicated proposer
    // (such as the server).
    function betterNumber(number, best) {
        return (number.number || 0) > best.number;
    }
    // Paxos Proposer behavior
    var proposalNumber = 0,
        nQuorum = 0,
        bestPromise = {number: 0},
        claimCallback,
        releaseCallback;
    function propose() {  // Make a new proposal, so that we learn/update the proposalNumber and winner.
        // Even though we send back a 'nack' if the proposal is obsolete, with network errors
        // there's no way to know for certain that we've failed. The electionWatchdog will try a new
        // proposal if we have not been accepted by a quorum after election Timeout.
        if (electionWatchdog) {
            // If we had a means of determining nSubscribers other than by counting, we could just
            // timers.clearTimeout(electionWatchdog) and not return.
            return;
        }
        thisTimeout = randomize(electionTimeout, 0.5); // Note use in nSubcribers.
        electionWatchdog = timers.setTimeout(function () {
            electionWatchdog = null;
            propose();
        }, thisTimeout);
        var nAcceptors = nSubscribers();
        nQuorum = Math.floor(nAcceptors / 2) + 1;

        proposalNumber = Math.max(proposalNumber, bestPromise.number) + 1;
        debugFlow('baton:', batonName, instanceId, 'propose', proposalNumber,
                  'claim:', !!claimCallback, 'nAcceptors:', nAcceptors, nPromises, nAccepted, nNack);
        nPromises = nAccepted = nNack = 0;
        send('prepare!', {number: proposalNumber, proposerId: instanceId});
    }
    // We create a distinguished promise subchannel for our id, because promises need only be sent to the proposer.
    receive('promise' + instanceId, function (data) {
        if (betterNumber(data, bestPromise)) {
            bestPromise = data;
        }
        if ((data.proposalNumber === proposalNumber) && (++nPromises >= nQuorum)) { // Note check for not being a previous round
            var answer = {number: data.proposalNumber, proposerId: data.proposerId, winner: bestPromise.winner}; // Not data.number.
            if (!answer.winner || (answer.winner === instanceId)) { // We get to pick.
                answer.winner = claimCallback ? instanceId : null;
            }
            send('accept!', answer);
        }
    });
    receive('nack' + instanceId, function (data) { // An acceptor reports more recent data...
        if (data.proposalNumber === proposalNumber) {
            nNack++;  // For updating nQuorum.
            // IWBNI if we started our next proposal right now/here, but we need a decent nNack count.
            // Lets save that optimization for another day...
        }
    });
    // Paxos Acceptor behavior
    var bestProposal = {number: 0}, accepted = {};
    function acceptedId() {
        return accepted && accepted.winner;
    }
    receive('prepare!', function (data) {
        var response = {proposalNumber: data.number, proposerId: data.proposerId};
        if (betterNumber(data, bestProposal)) {
            bestProposal = data;
            if (accepted.winner && connectionTest(accepted.winner)) {
                response.number = accepted.number;
                response.winner = accepted.winner;
            }
            send1('promise', data.proposerId, response);
        } else {
            send1('nack', data.proposerId, response);
        }
    });
    receive('accept!', function (data) {
        if (!betterNumber(bestProposal, data)) {
            bestProposal = accepted = data; // Update both with current data. Might have missed the proposal earlier.
            if (useOptimizations) {
                // The Paxos literature describes every acceptor sending 'accepted' to
                // every proposer and learner. In our case, these are the same nodes that received
                // the 'accept!' message, so we can send to just the originating proposer and invoke
                // our own accepted handler directly.
                // Note that this optimization cannot be used with Byzantine Paxos (which needs another
                // multi-broadcast to detect lying and collusion).
                debugSend('/', 'accepted', data);
                debugReceive(instanceId, 'accepted', data); // direct on next line, which doesn't get logging.
                subchannelHandlers[subchannelKey('accepted') + instanceId].receiver(data);
                if (data.proposerId !== instanceId) { // i.e., we didn't already do it directly on the line above.
                    send1('accepted', data.proposerId, data);
                }
            } else {
                send('accepted', data);
            }
        } else {
            send1('nack', data.proposerId, {proposalNumber: data.number});
        }
    });
    // Paxos Learner behavior.
    function localRelease() {
        var callback = releaseCallback;
        debugFlow('baton:', batonName, 'localRelease', 'callback:', !!releaseCallback);
        if (!releaseCallback) {
            return;
        } // Already released, but we might still receive a stale message. That's ok.
        releaseCallback = undefined;
        callback(batonName);  // Pass batonName so that clients may use the same handler for different batons.
    }
    receive('accepted' + (useOptimizations ? instanceId : ''), function (data) { // See note in 'accept!' regarding use of instanceId here.
        if (betterNumber(accepted, data)) { // Especially when !useOptimizations, we can receive other acceptances late.
            return;
        }
        var oldAccepted = accepted;
        debugFlow('baton:', batonName, instanceId, 'accepted', data.number, data.winner);
        accepted = data;
        // If we are proposer, make sure we get a quorum of acceptances.
        if ((data.proposerId === instanceId) && (data.number === proposalNumber) && (++nAccepted >= nQuorum)) {
            if (electionWatchdog) {
                timers.clearTimeout(electionWatchdog);
                electionWatchdog = null;
            }
        }
        // If we are the winner -- regardless of whether we were the proposer.
        if (acceptedId() === instanceId) {
            if (claimCallback) {
                var callback = claimCallback;
                claimCallback = undefined;
                callback(batonName);
            } else if (!releaseCallback) { // We won, but have been released and are no longer interested.
                // Propose that someone else take the job.
                timers.setTimeout(propose, 0); // Asynchronous to queue message handling if some are synchronous and others not.
            }
        } else if (releaseCallback && (oldAccepted.winner === instanceId)) { // We've been released by someone else!
            localRelease(); // This can happen if enough people thought we'd disconnected.
        }
    });

    // Public Interface
    //
    // Registers an intent to hold the baton:
    // Calls onElection(batonName) once, if you are elected by the scripts
    //    to be the unique holder of the baton, which may be never.
    // Calls onRelease(batonName) once, if the baton held by you is released,
    //    whether this is by you calling release(), or by losing
    //    an election when you become disconnected.
    // You may claim again at any time after the start of onRelease
    // being called.
    exports.claim = function claim(onElection, onRelease) {
        debugFlow('baton:', batonName, instanceId, 'claim');
        if (claimCallback) {
            log("Ignoring attempt to claim virtualBaton " + batonName + ", which is already waiting for claim.");
            return;
        }
        if (releaseCallback) {
            log("Ignoring attempt to claim virtualBaton " + batonName + ", which is somehow incorrect released, and that should not happen.");
            return;
        }
        claimCallback = onElection;
        releaseCallback = onRelease;
        propose();
        return exports; // Allows chaining. e.g., var baton = virtualBaton({batonName: 'foo'}.claim(onClaim, onRelease);
    };
    // Release the baton you hold, or just log that you are not holding it.
    exports.release = function release(optionalReplacementOnRelease) {
        debugFlow('baton:', batonName, instanceId, 'release');
        if (optionalReplacementOnRelease) { // If you want to change.
            releaseCallback = optionalReplacementOnRelease;
        }
        if (acceptedId() !== instanceId) {
            log("Ignoring attempt to release virtualBaton " + batonName + ", which is not being held.");
            return;
        }
        localRelease();
        if (!claimCallback) { // No claim set in release callback.
            propose();
        }
        return exports;
    };
    exports.recheckWatchdog = timers.setInterval(function recheck() {
        var holder = acceptedId();  // If we're waiting and we notice the holder is gone, ...
        if (holder && claimCallback && !electionWatchdog && !connectionTest(holder)) {
            bestPromise.winner = null; // used if the quorum agrees that old winner is not there
            propose();  // ... propose an election.
        }
    }, recheckInterval);
    exports.unload = function unload() { // Disconnect from everything.
        messages.messageReceived.disconnect(messageHandler);
        timers.clearInterval(exports.recheckWatchdog);
        if (electionWatchdog) {
            timers.clearTimeout(electionWatchdog);
        }
        electionWatchdog = claimCallback = releaseCallback = null;
        Object.keys(subchannelHandlers).forEach(messages.unsubscribe);
        debugFlow('baton:', batonName, instanceId, 'unload');
        return exports;
    };

    // Gather nAcceptors by making two proposals with some gathering time, even without a claim.
    propose();
    return exports;
}
if (typeof module !== 'undefined') { // Allow testing in nodejs.
    module.exports = virtualBatonf;
} else {
    virtualBaton = virtualBatonf;
}