"use strict";
/*jslint vars:true, plusplus:true, forin:true*/
/*global Window, Script, Controller, MyAvatar, AvatarList, Entities, Messages, Audio, SoundCache, Account, UserActivityLogger, Vec3, Quat, XMLHttpRequest, location, print*/
//
//  makeUserConnection.js
//  scripts/system
//
//  Created by David Kelly on 3/7/2017.
//  Copyright 2017 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
//

(function () { // BEGIN LOCAL_SCOPE

    var request = Script.require('request').request;

    var WANT_DEBUG = Settings.getValue('MAKE_USER_CONNECTION_DEBUG', false);
    var LABEL = "makeUserConnection";
    var MAX_AVATAR_DISTANCE = 0.2; // m
    var GRIP_MIN = 0.75; // goes from 0-1, so 75% pressed is pressed
    var MESSAGE_CHANNEL = "io.highfidelity.makeUserConnection";
    var STATES = {
        INACTIVE: 0,
        WAITING: 1,
        CONNECTING: 2,
        MAKING_CONNECTION: 3
    };
    var STATE_STRINGS = ["inactive", "waiting", "connecting", "makingConnection"];
    var HAND_STRING_PROPERTY = 'hand'; // Used in our message protocol. IWBNI we changed it to handString, but that would break compatability.
    var WAITING_INTERVAL = 100; // ms
    var CONNECTING_INTERVAL = 100; // ms
    var MAKING_CONNECTION_TIMEOUT = 800; // ms
    var CONNECTING_TIME = 1600; // ms
    var PARTICLE_RADIUS = 0.15; // m
    var PARTICLE_ANGLE_INCREMENT = 360 / 45; // 1hz
    var HANDSHAKE_SOUND_URL = "https://s3-us-west-1.amazonaws.com/hifi-content/davidkelly/production/audio/4beat_sweep.wav";
    var SUCCESSFUL_HANDSHAKE_SOUND_URL = "https://s3-us-west-1.amazonaws.com/hifi-content/davidkelly/production/audio/3rdbeat_success_bell.wav";
    var PREFERRER_HAND_JOINT_POSTFIX_ORDER = ['Middle1', 'Index1', ''];
    var HAPTIC_DATA = {
        initial: { duration: 20, strength: 0.6 }, // duration is in ms
        background: { duration: 100, strength: 0.3 }, // duration is in ms
        success: { duration: 60, strength: 1.0 } // duration is in ms
    };
    var PARTICLE_EFFECT_PROPS = {
        "alpha": 0.8,
        "azimuthFinish": Math.PI,
        "azimuthStart": -1 * Math.PI,
        "emitRate": 500,
        "emitSpeed": 0.0,
        "emitterShouldTrail": 1,
        "isEmitting": 1,
        "lifespan": 3,
        "maxParticles": 1000,
        "particleRadius": 0.003,
        "polarStart": 1,
        "polarFinish": 1,
        "radiusFinish": 0.008,
        "radiusStart": 0.0025,
        "speedSpread": 0.025,
        "textures": "http://hifi-content.s3.amazonaws.com/alan/dev/Particles/Bokeh-Particle.png",
        "color": {"red": 255, "green": 255, "blue": 255},
        "colorFinish": {"red": 0, "green": 164, "blue": 255},
        "colorStart": {"red": 255, "green": 255, "blue": 255},
        "emitOrientation": {"w": -0.71, "x": 0.0, "y": 0.0, "z": 0.71},
        "emitAcceleration": {"x": 0.0, "y": 0.0, "z": 0.0},
        "accelerationSpread": {"x": 0.0, "y": 0.0, "z": 0.0},
        "dimensions": {"x": 0.05, "y": 0.05, "z": 0.05},
        "type": "ParticleEffect"
    };
    var MAKING_CONNECTION_PARTICLE_PROPS = {
        "alpha": 0.07,
        "alphaStart": 0.011,
        "alphaSpread": 0,
        "alphaFinish": 0,
        "azimuthFinish": Math.PI,
        "azimuthStart": -1 * Math.PI,
        "emitRate": 2000,
        "emitSpeed": 0.0,
        "emitterShouldTrail": 1,
        "isEmitting": 1,
        "lifespan": 3.6,
        "maxParticles": 4000,
        "particleRadius": 0.048,
        "polarStart": 0,
        "polarFinish": 1,
        "radiusFinish": 0.3,
        "radiusStart": 0.04,
        "speedSpread": 0.01,
        "radiusSpread": 0.9,
        "textures": "http://hifi-content.s3.amazonaws.com/alan/dev/Particles/Bokeh-Particle.png",
        "color": {"red": 200, "green": 170, "blue": 255},
        "colorFinish": {"red": 0, "green": 134, "blue": 255},
        "colorStart": {"red": 185, "green": 222, "blue": 255},
        "emitOrientation": {"w": -0.71, "x": 0.0, "y": 0.0, "z": 0.71},
        "emitAcceleration": {"x": 0.0, "y": 0.0, "z": 0.0},
        "accelerationSpread": {"x": 0.0, "y": 0.0, "z": 0.0},
        "dimensions": {"x": 0.05, "y": 0.05, "z": 0.05},
        "type": "ParticleEffect"
    };

    var currentHand;
    var currentHandJointIndex = -1;
    var state = STATES.INACTIVE;
    var connectingInterval;
    var waitingInterval;
    var makingConnectionTimeout;
    var animHandlerId;
    var connectingId;
    var connectingHandJointIndex = -1;
    var waitingList = {};
    var particleEffect;
    var particleRotationAngle = 0.0;
    var makingConnectionParticleEffect;
    var makingConnectionEmitRate = 2000;
    var particleEmitRate = 500;
    var handshakeInjector;
    var successfulHandshakeInjector;
    var handshakeSound;
    var successfulHandshakeSound;

    function debug() {
        if (!WANT_DEBUG) {
            return;
        }
        var stateString = "<" + STATE_STRINGS[state] + ">";
        var connecting = "[" + connectingId + "/" + connectingHandJointIndex + "]";
        var current = "[" + currentHand + "/" + currentHandJointIndex + "]"
        print.apply(null, [].concat.apply([LABEL, stateString, current, JSON.stringify(waitingList), connecting],
            [].map.call(arguments, JSON.stringify)));
    }

    function cleanId(guidWithCurlyBraces) {
        return guidWithCurlyBraces.slice(1, -1);
    }

    function handToString(hand) {
        if (hand === Controller.Standard.RightHand) {
            return "RightHand";
        }
        if (hand === Controller.Standard.LeftHand) {
            return "LeftHand";
        }
        debug("handToString called without valid hand! value: ", hand);
        return "";
    }

    function handToHaptic(hand) {
        if (hand === Controller.Standard.RightHand) {
            return 1;
        }
        if (hand === Controller.Standard.LeftHand) {
            return 0;
        }
        debug("handToHaptic called without a valid hand!");
        return -1;
    }

    function stopWaiting() {
        if (waitingInterval) {
            waitingInterval = Script.clearInterval(waitingInterval);
        }
    }

    function stopConnecting() {
        if (connectingInterval) {
            connectingInterval = Script.clearInterval(connectingInterval);
        }
    }

    function stopMakingConnection() {
        if (makingConnectionTimeout) {
            makingConnectionTimeout = Script.clearTimeout(makingConnectionTimeout);
        }
    }

    // This returns the ideal hand joint index for the avatar.
    // [handString]middle1 -> [handString]index1 -> [handString]
    function getIdealHandJointIndex(avatar, handString) {
        debug("get hand " + handString + " for avatar " + (avatar && avatar.sessionUUID));
        var suffixIndex, jointName, jointIndex;
        for (suffixIndex = 0; suffixIndex < (avatar ? PREFERRER_HAND_JOINT_POSTFIX_ORDER.length : 0); suffixIndex++) {
            jointName = handString + PREFERRER_HAND_JOINT_POSTFIX_ORDER[suffixIndex];
            jointIndex = avatar.getJointIndex(jointName);
            if (jointIndex !== -1) {
                debug('found joint ' + jointName + ' (' + jointIndex + ')');
                return jointIndex;
            }
        }
        debug('no hand joint found.');
        return -1;
    }

    // This returns the preferred hand position.
    function getHandPosition(avatar, handJointIndex) {
        if (handJointIndex === -1) {
            debug("calling getHandPosition with no hand joint index! (returning avatar position but this is a BUG)");
            return avatar.position;
        }
        return avatar.getJointPosition(handJointIndex);
    }

    var animationData = {};
    function updateAnimationData(verticalOffset) {
        // all we are doing here is moving the right hand to a spot
        // that is in front of and a bit above the hips.  Basing how
        // far in front as scaling with the avatar's height (say hips
        // to head distance)
        var headIndex = MyAvatar.getJointIndex("Head");
        var offset = 0.5; // default distance of hand in front of you
        if (headIndex) {
            offset = 0.8 * MyAvatar.getAbsoluteJointTranslationInObjectFrame(headIndex).y;
        }
        animationData.rightHandPosition = Vec3.multiply(offset, {x: -0.25, y: 0.8, z: 1.3});
        if (verticalOffset) {
            animationData.rightHandPosition.y += verticalOffset;
        }
        animationData.rightHandRotation = Quat.fromPitchYawRollDegrees(90, 0, 90);
    }
    function shakeHandsAnimation() {
        return animationData;
    }
    function endHandshakeAnimation() {
        if (animHandlerId) {
            debug("removing animation");
            animHandlerId = MyAvatar.removeAnimationStateHandler(animHandlerId);
        }
    }
    function startHandshakeAnimation() {
        endHandshakeAnimation(); // just in case order of press/unpress is broken
        debug("adding animation");
        updateAnimationData();
        animHandlerId = MyAvatar.addAnimationStateHandler(shakeHandsAnimation, []);
    }

    function positionFractionallyTowards(posA, posB, frac) {
        return Vec3.sum(posA, Vec3.multiply(frac, Vec3.subtract(posB, posA)));
    }

    function deleteParticleEffect() {
        if (particleEffect) {
            particleEffect = Entities.deleteEntity(particleEffect);
        }
    }

    function deleteMakeConnectionParticleEffect() {
        if (makingConnectionParticleEffect) {
            makingConnectionParticleEffect = Entities.deleteEntity(makingConnectionParticleEffect);
        }
    }

    function stopHandshakeSound() {
        if (handshakeInjector) {
            handshakeInjector.stop();
            handshakeInjector = null;
        }
    }

    function calcParticlePos(myHandPosition, otherHandPosition, otherOrientation, reset) {
        if (reset) {
            particleRotationAngle = 0.0;
        }
        var position = positionFractionallyTowards(myHandPosition, otherHandPosition, 0.5);
        particleRotationAngle += PARTICLE_ANGLE_INCREMENT; // about 0.5 hz
        var radius = Math.min(PARTICLE_RADIUS, PARTICLE_RADIUS * particleRotationAngle / 360);
        var axis = Vec3.mix(Quat.getFront(MyAvatar.orientation), Quat.inverse(Quat.getFront(otherOrientation)), 0.5);
        return Vec3.sum(position, Vec3.multiplyQbyV(Quat.angleAxis(particleRotationAngle, axis), {x: 0, y: radius, z: 0}));
    }

    // this is called frequently, but usually does nothing
    function updateVisualization() {
        if (state === STATES.INACTIVE) {
            deleteParticleEffect();
            deleteMakeConnectionParticleEffect();
            return;
        }

        var myHandPosition = getHandPosition(MyAvatar, currentHandJointIndex);
        var otherHandPosition;
        var otherOrientation;
        if (connectingId) {
            var other = AvatarList.getAvatar(connectingId);
            if (other) {
                otherOrientation = other.orientation;
                otherHandPosition = getHandPosition(other, connectingHandJointIndex);
            }
        }

        switch (state) {
        case STATES.WAITING:
            // no visualization while waiting
            deleteParticleEffect();
            deleteMakeConnectionParticleEffect();
            stopHandshakeSound();
            break;
        case STATES.CONNECTING:
            var particleProps = {};
            // put the position between the 2 hands, if we have a connectingId.  This
            // helps define the plane in which the particles move.
            positionFractionallyTowards(myHandPosition, otherHandPosition, 0.5);
            // now manage the rest of the entity
            if (!particleEffect) {
                particleRotationAngle = 0.0;
                particleEmitRate = 500;
                particleProps = PARTICLE_EFFECT_PROPS;
                particleProps.isEmitting = 0;
                particleProps.position = calcParticlePos(myHandPosition, otherHandPosition, otherOrientation);
                particleProps.parentID = MyAvatar.sessionUUID;
                particleEffect = Entities.addEntity(particleProps, true);
            } else {
                particleProps.position = calcParticlePos(myHandPosition, otherHandPosition, otherOrientation);
                particleProps.isEmitting = 1;
                Entities.editEntity(particleEffect, particleProps);
            }
            if (!makingConnectionParticleEffect) {
                var props = MAKING_CONNECTION_PARTICLE_PROPS;
                props.parentID = MyAvatar.sessionUUID;
                makingConnectionEmitRate = 2000;
                props.emitRate = makingConnectionEmitRate;
                props.position = myHandPosition;
                makingConnectionParticleEffect = Entities.addEntity(props, true);
            } else {
                makingConnectionEmitRate *= 0.5;
                Entities.editEntity(makingConnectionParticleEffect, {
                    emitRate: makingConnectionEmitRate,
                    position: myHandPosition,
                    isEmitting: true
                });
            }
            break;
        case STATES.MAKING_CONNECTION:
            particleEmitRate = Math.max(50, particleEmitRate * 0.5);
            Entities.editEntity(makingConnectionParticleEffect, {emitRate: 0, isEmitting: 0, position: myHandPosition});
            Entities.editEntity(particleEffect, {
                position: calcParticlePos(myHandPosition, otherHandPosition, otherOrientation),
                emitRate: particleEmitRate
            });
            break;
        default:
            debug("unexpected state", state);
            break;
        }
    }

    function isNearby() {
        if (currentHand) {
            var handPosition = getHandPosition(MyAvatar, currentHandJointIndex);
            var avatar = AvatarList.getAvatar(connectingId);
            if (avatar) {
                var distance = Vec3.distance(getHandPosition(avatar, connectingHandJointIndex), handPosition);
                return (distance < MAX_AVATAR_DISTANCE);
            }
        }
        return false;
    }
    function findNearestAvatar() {
        // We only look some max distance away (much larger than the handshake distance, but still...)
        var minDistance = MAX_AVATAR_DISTANCE * 20;
        var closestAvatar;
        AvatarList.getAvatarIdentifiers().forEach(function (id) {
            var avatar = AvatarList.getAvatar(id);
            if (avatar && avatar.sessionUUID != MyAvatar.sessionUUID) {
                var currentDistance = Vec3.distance(avatar.position, MyAvatar.position);
                if (minDistance > currentDistance) {
                    minDistance = currentDistance;
                    closestAvatar = avatar;
                }
            }
        });
        return closestAvatar;
    }
    function adjustAnimationHeight() {
        var avatar = findNearestAvatar();
        if (avatar) {
            var myHeadIndex = MyAvatar.getJointIndex("Head");
            var otherHeadIndex = avatar.getJointIndex("Head");
            var diff = (avatar.getJointPosition(otherHeadIndex).y - MyAvatar.getJointPosition(myHeadIndex).y) / 2;
            debug("head height difference: " + diff);
            updateAnimationData(diff);
        }
    }
    function findNearestWaitingAvatar() {
        var handPosition = getHandPosition(MyAvatar, currentHandJointIndex);
        var minDistance = MAX_AVATAR_DISTANCE;
        var nearestAvatar = {};
        Object.keys(waitingList).forEach(function (identifier) {
            var avatar = AvatarList.getAvatar(identifier);
            if (avatar) {
                var handJointIndex = waitingList[identifier];
                var distance = Vec3.distance(getHandPosition(avatar, handJointIndex), handPosition);
                if (distance < minDistance) {
                    minDistance = distance;
                    nearestAvatar = {avatarId: identifier, jointIndex: handJointIndex};
                }
            }
        });
        return nearestAvatar;
    }
    function messageSend(message) {
        // we always append whether or not we are logged in...
        message.isLoggedIn = Account.isLoggedIn();
        Messages.sendMessage(MESSAGE_CHANNEL, JSON.stringify(message));
    }
    function handStringMessageSend(message) {
        message[HAND_STRING_PROPERTY] = handToString(currentHand);
        messageSend(message);
    }
    function setupCandidate() { // find the closest in-range avatar, send connection request, and return true. (Otherwise falsey)
        var nearestAvatar = findNearestWaitingAvatar();
        if (nearestAvatar.avatarId) {
            connectingId = nearestAvatar.avatarId;
            connectingHandJointIndex = nearestAvatar.jointIndex;
            debug("sending connectionRequest to", connectingId);
            handStringMessageSend({
                key: "connectionRequest",
                id: connectingId
            });
            return true;
        }
    }
    function clearConnecting() {
        connectingId = undefined;
        connectingHandJointIndex = -1;
    }

    function lookForWaitingAvatar() {
        // we started with nobody close enough, but maybe I've moved
        // or they did.  Note that 2 people doing this race, so stop
        // as soon as you have a connectingId (which means you got their
        // message before noticing they were in range in this loop)

        // just in case we re-enter before stopping
        stopWaiting();
        debug("started looking for waiting avatars");
        waitingInterval = Script.setInterval(function () {
            if (state === STATES.WAITING && !connectingId) {
                setupCandidate();
            } else {
                // something happened, stop looking for avatars to connect
                stopWaiting();
                debug("stopped looking for waiting avatars");
            }
        }, WAITING_INTERVAL);
    }

    var pollCount = 0, requestUrl = Account.metaverseServerURL + '/api/v1/user/connection_request';
    // As currently implemented, we select the closest waiting avatar (if close enough) and send
    // them a connectionRequest.  If nobody is close enough we send a waiting message, and wait for a
    // connectionRequest.  If the 2 people who want to connect are both somewhat out of range when they
    // initiate the shake, they will race to see who sends the connectionRequest after noticing the
    // waiting message.  Either way, they will start connecting each other at that point.
    function startHandshake(fromKeyboard) {
        if (fromKeyboard) {
            startHandshakeAnimation();
        }
        debug("starting handshake for", currentHand);
        pollCount = 0;
        state = STATES.WAITING;
        clearConnecting();
        // just in case
        stopWaiting();
        stopConnecting();
        stopMakingConnection();
        if (!setupCandidate()) {
            // send waiting message
            debug("sending waiting message");
            handStringMessageSend({
                key: "waiting",
            });
            // potentially adjust height of handshake
            if (fromKeyboard) {
                adjustAnimationHeight();
            }
            lookForWaitingAvatar();
        }
    }

    function endHandshake() {
        debug("ending handshake for", currentHand);

        deleteParticleEffect();
        deleteMakeConnectionParticleEffect();
        currentHand = undefined;
        currentHandJointIndex = -1;
        // note that setting the state to inactive should really
        // only be done here, unless we change how the triggering works,
        // as we ignore the key release event when inactive.  See updateTriggers
        // below.
        state = STATES.INACTIVE;
        clearConnecting();
        stopWaiting();
        stopConnecting();
        stopMakingConnection();
        stopHandshakeSound();
        // send done to let connection know you are not making connections now
        messageSend({
            key: "done"
        });

        endHandshakeAnimation();
        // No-op if we were successful, but this way we ensure that failures and abandoned handshakes don't leave us
        // in a weird state.
        if (Account.isLoggedIn()) {
            request({ uri: requestUrl, method: 'DELETE' }, debug);
        }
    }

    function updateTriggers(value, fromKeyboard, hand) {
        if (currentHand && hand !== currentHand) {
            debug("currentHand", currentHand, "ignoring messages from", hand); // this can be a lot of spam on Touch. Should guard that someday.
            return;
        }
        // ok now, we are either initiating or quitting...
        var isGripping = value > GRIP_MIN;
        if (isGripping) {
            debug("updateTriggers called - gripping", handToString(hand));
            if (state !== STATES.INACTIVE) {
                return;
            }
            currentHand = hand;
            currentHandJointIndex = getIdealHandJointIndex(MyAvatar, handToString(currentHand)); // Always, in case of changed skeleton.
            startHandshake(fromKeyboard);
        } else {
            // TODO: should we end handshake even when inactive?  Ponder
            debug("updateTriggers called -- no longer gripping", handToString(hand));
            if (state !== STATES.INACTIVE) {
                endHandshake();
            } else {
                return;
            }
        }
    }

    /* There is a mini-state machine after entering STATES.makingConnection.
       We make a request (which might immediately succeed, fail, or neither.
       If we immediately fail, we tell the user.
       Otherwise, we wait MAKING_CONNECTION_TIMEOUT. At that time, we poll until success or fail.
     */
    var result, requestBody;
    function connectionRequestCompleted() { // Final result is in. Do effects.
        if (result.status === 'success') { // set earlier
            if (!successfulHandshakeInjector) {
                successfulHandshakeInjector = Audio.playSound(successfulHandshakeSound, {
                    position: getHandPosition(MyAvatar, currentHandJointIndex),
                    volume: 0.5,
                    localOnly: true
                });
            } else {
                successfulHandshakeInjector.restart();
            }
            Controller.triggerHapticPulse(HAPTIC_DATA.success.strength, HAPTIC_DATA.success.duration,
                handToHaptic(currentHand));
            // don't change state (so animation continues while gripped)
            // but do send a notification, by calling the slot that emits the signal for it
            Window.makeConnection(true,
                                  result.connection.new_connection ?
                                  "You and " + result.connection.username + " are now connected!" :
                                  result.connection.username);
            UserActivityLogger.makeUserConnection(connectingId,
                                                  true,
                                                  result.connection.new_connection ?
                                                  "new connection" :
                                                  "already connected");
            return;
        } // failed
        endHandshake();
        debug("failing with result data", result);
        // IWBNI we also did some fail sound/visual effect.
        Window.makeConnection(false, result.connection);
        if (Account.isLoggedIn()) { // Give extra failure info
            request(Account.metaverseServerURL + '/api/v1/users/' + Account.username + '/location', function (error, response) {
                var message = '';
                if (error || response.status !== 'success') {
                    message = 'Unable to get location.';
                } else if (!response.data || !response.data.location) {
                    message = "Unexpected location value: " + JSON.stringify(response);
                } else if (response.data.location.node_id !== cleanId(MyAvatar.sessionUUID)) {
                    message = 'Session identification does not match database. Maybe you are logged in on another machine? That would prevent handshakes.' + JSON.stringify(response) + MyAvatar.sessionUUID;
                }
                if (message) {
                    Window.makeConnection(false, message);
                }
                debug("account location:", message || 'ok');
            });
        }
        UserActivityLogger.makeUserConnection(connectingId, false, result.connection);
    }
    // This is a bit fragile - but to account for skew in when people actually create the
    // connection request, I've upped this to 2 seconds (plus the round-trip times)
    // TODO: keep track of when the person we are connecting with is done, and don't stop
    // until say 1 second after that.
    var POLL_INTERVAL_MS = 200, POLL_LIMIT = 10;
    function handleConnectionResponseAndMaybeRepeat(error, response) {
        // If response is 'pending', set a short timeout to try again.
        // If we fail other than pending, set result and immediately call connectionRequestCompleted.
        // If we succeed, set result and call connectionRequestCompleted immediately (if we've been polling),
        // and otherwise on a timeout.
        if (response && (response.connection === 'pending')) {
            debug(response, 'pollCount', pollCount);
            if (pollCount++ >= POLL_LIMIT) { // server will expire, but let's not wait that long.
                debug('POLL LIMIT REACHED; TIMEOUT: expired message generated by CLIENT');
                result = {status: 'error', connection: 'No logged-in partner found.'};
                connectionRequestCompleted();
            } else { // poll
                Script.setTimeout(function () {
                    request({
                        uri: requestUrl,
                        // N.B.: server gives bad request if we specify json content type, so don't do that.
                        body: requestBody
                    }, handleConnectionResponseAndMaybeRepeat);
                }, POLL_INTERVAL_MS);
            }
        } else if (error || (response.status !== 'success')) {
            debug('server fail', error, response.status);
            if (response && (response.statusCode === 401)) {
                error = "All participants must be logged in to connect.";
            }
            result = error ? {status: 'error', connection: error} : response;
            connectionRequestCompleted();
        } else {
            result = response;
            debug('server success', result);
            if (pollCount++) {
                connectionRequestCompleted();
            } else { // Wait for other guy, so that final success is at roughly the same time.
                Script.setTimeout(connectionRequestCompleted, MAKING_CONNECTION_TIMEOUT);
            }
        }
    }

    function makeConnection(id, isLoggedIn) {
        // send done to let the connection know you have made connection.
        messageSend({
            key: "done",
            connectionId: id
        });

        state = STATES.MAKING_CONNECTION;

        // continue the haptic background until the timeout fires.
        Controller.triggerHapticPulse(HAPTIC_DATA.background.strength, MAKING_CONNECTION_TIMEOUT, handToHaptic(currentHand));
        requestBody = {'node_id': cleanId(MyAvatar.sessionUUID), 'proposed_node_id': cleanId(id)}; // for use when repeating

        // It would be "simpler" to skip this and just look at the response, but:
        // 1. We don't want to bother the metaverse with request that we know will fail.
        // 2. We don't want our code here to be dependent on precisely how the metaverse responds (400, 401, etc.)
        // 3. We also don't want to connect to someone who is anonymous _now_, but was not earlier and still has
        //    the same node id.  Since the messaging doesn't say _who_ isn't logged in, fail the same as if we are
        //    not logged in.
        if (!Account.isLoggedIn() || isLoggedIn === false) {
            handleConnectionResponseAndMaybeRepeat("401:Unauthorized", {statusCode: 401});
            return;
        }

        // This will immediately set response if successful (e.g., the other guy got his request in first),
        // or immediate failure, and will otherwise poll (using the requestBody we just set).
        request({
            uri: requestUrl,
            method: 'POST',
            json: true,
            body: {'user_connection_request': requestBody}
        }, handleConnectionResponseAndMaybeRepeat);
    }
    function setupConnecting(id, jointIndex) {
        connectingId = id;
        connectingHandJointIndex = jointIndex;
    }

    // we change states, start the connectionInterval where we check
    // to be sure the hand is still close enough.  If not, we terminate
    // the interval, go back to the waiting state.  If we make it
    // the entire CONNECTING_TIME, we make the connection.  We pass in
    // whether or not the connecting id is actually logged in, as now we
    // will allow to start the connection process but have it stop with a
    // fail message before trying to call the backend if the other guy isn't
    // logged in.
    function startConnecting(id, jointIndex, isLoggedIn) {
        var count = 0;
        debug("connecting", id, "hand", jointIndex);
        // do we need to do this?
        setupConnecting(id, jointIndex);
        state = STATES.CONNECTING;

        // play sound
        if (!handshakeInjector) {
            handshakeInjector = Audio.playSound(handshakeSound, {
                position: getHandPosition(MyAvatar, currentHandJointIndex),
                volume: 0.5,
                localOnly: true
            });
        } else {
            handshakeInjector.restart();
        }

        // send message that we are connecting with them
        handStringMessageSend({
            key: "connecting",
            id: id
        });
        Controller.triggerHapticPulse(HAPTIC_DATA.initial.strength, HAPTIC_DATA.initial.duration, handToHaptic(currentHand));

        connectingInterval = Script.setInterval(function () {
            count += 1;
            Controller.triggerHapticPulse(HAPTIC_DATA.background.strength, HAPTIC_DATA.background.duration,
                handToHaptic(currentHand));
            if (state !== STATES.CONNECTING) {
                debug("stopping connecting interval, state changed");
                stopConnecting();
            } else if (!isNearby()) {
                // gotta go back to waiting
                debug(id, "moved, back to waiting");
                stopConnecting();
                messageSend({
                    key: "done"
                });
                startHandshake();
            } else if (count > CONNECTING_TIME / CONNECTING_INTERVAL) {
                debug("made connection with " + id);
                makeConnection(id, isLoggedIn);
                stopConnecting();
            }
        }, CONNECTING_INTERVAL);
    }
    /*
    A simple sequence diagram: NOTE that the ConnectionAck is somewhat
    vestigial, and probably should be removed shortly.

         Avatar A                       Avatar B
            |                               |
            |  <-----(waiting) ----- startHandshake
    startHandshake - (connectionRequest) -> |
            |                               |
            | <----(connectionAck) -------- |
            | <-----(connecting) -- startConnecting
     startConnecting ---(connecting) ---->  |
            |                               |
            |                            connected
       connected                            |
            | <---------  (done) ---------- |
            | ---------- (done) --------->  |
    */
    function messageHandler(channel, messageString, senderID) {
        var message = {};
        function exisitingOrSearchedJointIndex() { // If this is a new connectingId, we'll need to find the jointIndex
            return connectingId ? connectingHandJointIndex : getIdealHandJointIndex(AvatarList.getAvatar(senderID), message[HAND_STRING_PROPERTY]);
        }
        if (channel !== MESSAGE_CHANNEL) {
            return;
        }
        if (MyAvatar.sessionUUID === senderID) { // ignore my own
            return;
        }
        try {
            message = JSON.parse(messageString);
        } catch (e) {
            debug(e);
        }
        switch (message.key) {
        case "waiting":
            // add this guy to waiting object.  Any other message from this person will remove it from the list
            waitingList[senderID] = getIdealHandJointIndex(AvatarList.getAvatar(senderID), message[HAND_STRING_PROPERTY]);
            break;
        case "connectionRequest":
            delete waitingList[senderID];
            if (state === STATES.WAITING && message.id === MyAvatar.sessionUUID && (!connectingId || connectingId === senderID)) {
                // you were waiting for a connection request, so send the ack.  Or, you and the other
                // guy raced and both send connectionRequests.  Handle that too
                setupConnecting(senderID, exisitingOrSearchedJointIndex());
                handStringMessageSend({
                    key: "connectionAck",
                    id: senderID,
                });
            } else if (state === STATES.WAITING && connectingId === senderID) {
                // the person you are trying to connect sent a request to someone else.  See the
                // if statement above.  So, don't cry, just start the handshake over again
                startHandshake();
            }
            break;
        case "connectionAck":
            delete waitingList[senderID];
            if (state === STATES.WAITING && (!connectingId || connectingId === senderID)) {
                if (message.id === MyAvatar.sessionUUID) {
                    stopWaiting();
                    startConnecting(senderID, exisitingOrSearchedJointIndex(), message.isLoggedIn);
                } else if (connectingId) {
                    // this is for someone else (we lost race in connectionRequest),
                    // so lets start over
                    startHandshake();
                }
            }
            // TODO: check to see if we are waiting for this but the person we are connecting sent it to
            // someone else, and try again
            break;
        case "connecting":
            delete waitingList[senderID];
            if (state === STATES.WAITING && senderID === connectingId) {
                if (message.id !== MyAvatar.sessionUUID) {
                    // the person we were trying to connect is connecting to someone else
                    // so try again
                    startHandshake();
                    break;
                }
                startConnecting(senderID, connectingHandJointIndex, message.isLoggedIn);
            }
            break;
        case "done":
            delete waitingList[senderID];
            if (connectingId !== senderID) {
                break;
            }
            if (state === STATES.CONNECTING) {
                // if they are done, and didn't connect us, terminate our
                // connecting
                if (message.connectionId !== MyAvatar.sessionUUID) {
                    stopConnecting();
                    // now just call startHandshake.  Should be ok to do so without a
                    // value for isKeyboard, as we should not change the animation
                    // state anyways (if any)
                    startHandshake();
                } else {
                    // they just created a connection request to us, and we are connecting to
                    // them, so lets just stop connecting and make connection..
                    makeConnection(connectingId, message.isLoggedIn);
                    stopConnecting();
                }
            } else {
                if (state == STATES.MAKING_CONNECTION) {
                    // we are making connection, they just started, so lets reset the
                    // poll count just in case
                    pollCount = 0;
                } else {
                    // if waiting or inactive, lets clear the connecting id. If in makingConnection,
                    // do nothing
                    clearConnecting();
                    if (state !== STATES.INACTIVE) {
                        startHandshake();
                    }
                }
            }
            break;
        default:
            debug("unknown message", message);
            break;
        }
    }

    Messages.subscribe(MESSAGE_CHANNEL);
    Messages.messageReceived.connect(messageHandler);

    function makeGripHandler(hand, animate) {
        // determine if we are gripping or un-gripping
        if (animate) {
            return function (value) {
                updateTriggers(value, true, hand);
            };
        }
        return function (value) {
            updateTriggers(value, false, hand);
        };
    }

    function keyPressEvent(event) {
        if ((event.text === "x") && !event.isAutoRepeat && !event.isShifted && !event.isMeta && !event.isControl && !event.isAlt) {
            updateTriggers(1.0, true, Controller.Standard.RightHand);
        }
    }
    function keyReleaseEvent(event) {
        if ((event.text === "x") && !event.isAutoRepeat && !event.isShifted && !event.isMeta && !event.isControl && !event.isAlt) {
            updateTriggers(0.0, true, Controller.Standard.RightHand);
        }
    }
    // map controller actions
    var connectionMapping = Controller.newMapping(Script.resolvePath('') + '-grip');
    connectionMapping.from(Controller.Standard.LeftGrip).peek().to(makeGripHandler(Controller.Standard.LeftHand));
    connectionMapping.from(Controller.Standard.RightGrip).peek().to(makeGripHandler(Controller.Standard.RightHand));

    // setup keyboard initiation
    Controller.keyPressEvent.connect(keyPressEvent);
    Controller.keyReleaseEvent.connect(keyReleaseEvent);

    // Xbox controller because that is important
    connectionMapping.from(Controller.Standard.RB).peek().to(makeGripHandler(Controller.Standard.RightHand, true));

    // it is easy to forget this and waste a lot of time for nothing
    connectionMapping.enable();

    // connect updateVisualization to update frequently
    Script.update.connect(updateVisualization);

    // load the sounds when the script loads
    handshakeSound = SoundCache.getSound(HANDSHAKE_SOUND_URL);
    successfulHandshakeSound = SoundCache.getSound(SUCCESSFUL_HANDSHAKE_SOUND_URL);

    Script.scriptEnding.connect(function () {
        debug("removing controller mappings");
        connectionMapping.disable();
        debug("removing key mappings");
        Controller.keyPressEvent.disconnect(keyPressEvent);
        Controller.keyReleaseEvent.disconnect(keyReleaseEvent);
        debug("disconnecting updateVisualization");
        Script.update.disconnect(updateVisualization);
        deleteParticleEffect();
        deleteMakeConnectionParticleEffect();
    });

}()); // END LOCAL_SCOPE