"use strict";
/*jslint nomen: true, plusplus: true, vars: true*/
/*global AvatarList, Entities, EntityViewer, Script, SoundCache, Audio, print, randFloat*/
//
//  ACAudioSearchAndInject.js
//  audio
//
//  Created by Eric Levin and Howard Stearns 2/1/2016
//  Copyright 2016 High Fidelity, Inc.
//
//  Keeps track of all sounds within QUERY_RADIUS of an avatar, where a "sound" is specified in entity userData.
//  Inject as many as practical into the audio mixer.
//  See acAudioSearchAndCompatibilityEntitySpawner.js.
//
//  This implementation takes some precautions to scale well:
//  - It doesn't hastle the entity server because it issues at most one octree query every UPDATE_TIME period, regardless of the number of avatars.
//  - It does not load itself because it only gathers entities once every UPDATE_TIME period, and only
//    checks entity properties for those small number of entities that are currently playing (plus a RECHECK_TIME period examination of all entities).
//  This implementation tries to use all the available injectors.
//
//  Distributed under the Apache License, Version 2.0.
//  See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html

var MSEC_PER_SEC = 1000;
var SOUND_DATA_KEY = "io.highfidelity.soundKey"; // Sound data is specified in userData under this key.
var old_sound_data_key = "soundKey"; // For backwards compatibility.
var QUERY_RADIUS = 50; // meters
var UPDATE_TIME = 100; // ms. We'll update just one thing on this period.
var EXPIRATION_TIME = 5 * MSEC_PER_SEC; // ms. Remove sounds that have been out of range for this time.
var RECHECK_TIME = 10 * MSEC_PER_SEC; // ms. Check for new userData properties this often when not currently playing.
// (By not checking most of the time when not playing, we can efficiently go through all entities without getEntityProperties.)
var UPDATES_PER_STATS_LOG = RECHECK_TIME / UPDATE_TIME; // (It's nice to smooth out the results by straddling a recheck.)

var DEFAULT_SOUND_DATA = {
    volume: 0.5, // userData cannot specify zero volume with our current method of defaulting.
    loop: false, // Default must be false with our current method of defaulting, else there's no way to get a false value.
    playbackGap: MSEC_PER_SEC, // in ms
    playbackGapRange: 0 // in ms
};

Script.include("../../libraries/utils.js");
Agent.isAvatar = true; // This puts a robot at 0,0,0, but is currently necessary in order to use AvatarList.
Avatar.skeletonModelURL = "http://hifi-content.s3.amazonaws.com/ozan/dev/avatars/invisible_avatar/invisible_avatar.fst";
function ignore() {}
function debug() { // Display the arguments not just [Object object].
    //print.apply(null, [].map.call(arguments, JSON.stringify));
}

EntityViewer.setCenterRadius(QUERY_RADIUS);

// ENTITY DATA CACHE
//
var entityCache = {}; // A dictionary of unexpired EntityData objects.
var examinationCount = 0;
function EntityDatum(entityIdentifier) { // Just the data of an entity that we need to know about.
    // This data is only use for our sound injection. There is no need to store such info in the replicated entity on everyone's computer.
    var that = this;
    that.lastUserDataUpdate = 0; // new entity is in need of rechecking user data
    // State Transitions:
    // no data => no data | sound data | expired
    //   expired => stop => remove
    //   sound data => downloading
    //     downloading => downloading | waiting
    //       waiting => playing | waiting (if too many already playing)
    //         playing => update position etc | no data
    that.stop = function stop() {
        if (!that.sound) {
            return;
        }
        print("stopping sound", entityIdentifier, that.url);
        delete that.sound;
        delete that.url;
        if (!that.injector) {
            return;
        }
        that.injector.stop();
        delete that.injector;
    };
    this.update = function stateTransitions(expirationCutoff, userDataCutoff, now) {
        if (that.timestamp < expirationCutoff) {                             // EXPIRED => STOP => REMOVE
            that.stop(); // Alternatively, we could fade out and then stop...
            delete entityCache[entityIdentifier];
            return;
        }
        var properties, soundData; // Latest data, pulled from local octree.
        // getEntityProperties locks the tree, which competes with the asynchronous processing of queryOctree results.
        // Most entity updates are fast and only a very few do getEntityProperties.
        function ensureSoundData() { // We only getEntityProperities when we need to.
            if (properties) {
                return;
            }
            properties = Entities.getEntityProperties(entityIdentifier, ['userData', 'position']);
            examinationCount++; // Collect statistics on how many getEntityProperties we do.
            debug("updating", that, properties);
            try {
                var userData = properties.userData && JSON.parse(properties.userData);
                soundData = userData && (userData[SOUND_DATA_KEY] || userData[old_sound_data_key]); // Don't store soundData yet. Let state changes compare.
                that.lastUserDataUpdate = now;                    // But do update these ...
                that.url = soundData && soundData.url;
                that.playAfter = that.url && now;
            } catch (err) {
                print(err, properties.userData);
            }
        }
        // Stumbling on big new pile of entities will do a lot of getEntityProperties. Once.
        if (that.lastUserDataUpdate < userDataCutoff) {                      // NO DATA => SOUND DATA
            ensureSoundData();
        }
        if (!that.url) {                                                     // NO DATA => NO DATA
            return that.stop();
        }
        if (!that.sound) {                                                   // SOUND DATA => DOWNLOADING
            that.sound = SoundCache.getSound(soundData.url); // SoundCache can manage duplicates better than we can.
        }
        if (!that.sound.downloaded) {                                        // DOWNLOADING => DOWNLOADING
            return;
        }
        if (that.playAfter > now) {                                          // DOWNLOADING | WAITING => WAITING
            return;
        }
        ensureSoundData(); // We'll try to play/setOptions and will need position, so we might as well get soundData, too.
        if (soundData.url !== that.url) {                                    // WAITING => NO DATA (update next time around)
            return that.stop();
        }
        var options = {
            position: properties.position,
            loop: soundData.loop || DEFAULT_SOUND_DATA.loop,
            volume: soundData.volume || DEFAULT_SOUND_DATA.volume
        };
        function repeat() {
            return !options.loop && (soundData.playbackGap >= 0);
        }
        function randomizedNextPlay() { // time of next play or recheck, randomized to distribute the work
            var range = soundData.playbackGapRange || DEFAULT_SOUND_DATA.playbackGapRange,
                base = repeat() ? ((that.sound.duration * MSEC_PER_SEC) + (soundData.playbackGap || DEFAULT_SOUND_DATA.playbackGap)) : RECHECK_TIME;
            return now + base + randFloat(-Math.min(base, range), range);
        }
        if (!that.injector) {                                                // WAITING => PLAYING | WAITING
            debug("starting", that, options);
            that.injector = Audio.playSound(that.sound, options); // Might be null if at at injector limit. Will try again later.
            if (that.injector) {
                print("started", entityIdentifier, that.url);
            } else { // Don't hammer ensureSoundData or injector manager.
                that.playAfter = randomizedNextPlay();
            }
            return;
        }
        that.injector.setOptions(options);                                   // PLAYING => UPDATE POSITION ETC
        if (!that.injector.playing) { // Subtle: a looping sound will not check playbackGap.
            if (repeat()) {                                                  // WAITING => PLAYING
                // Setup next play just once, now. Changes won't be looked at while we wait.
                that.playAfter = randomizedNextPlay();
                // Subtle: if the restart fails b/c we're at injector limit, we won't try again until next playAfter.
                that.injector.restart();
            } else {                                                        // PLAYING => NO DATA
                that.playAfter = Infinity; // was one-shot and we're finished
            }
        }
    };
}
function internEntityDatum(entityIdentifier, timestamp, avatarPosition, avatar) {
    ignore(avatarPosition, avatar); // We could use avatars and/or avatarPositions to prioritize which ones to play.
    var entitySound = entityCache[entityIdentifier];
    if (!entitySound) {
        entitySound = entityCache[entityIdentifier] = new EntityDatum(entityIdentifier);
    }
    entitySound.timestamp = timestamp; // Might be updated for multiple avatars. That's fine.
}
var nUpdates = UPDATES_PER_STATS_LOG, lastStats = Date.now();
function updateAllEntityData() { // A fast update of all entities we know about. A few make sounds.
    var now = Date.now(),
        expirationCutoff = now - EXPIRATION_TIME,
        userDataRecheckCutoff = now - RECHECK_TIME;
    Object.keys(entityCache).forEach(function (entityIdentifier) {
        entityCache[entityIdentifier].update(expirationCutoff, userDataRecheckCutoff, now);
    });
    if (nUpdates-- <= 0) { // Report statistics.
        // For example, with:
        //  injector-limit = 40 (in C++ code)
        //  N_SOUNDS = 1000    (from userData in, e.g., acAudioSearchCompatibleEntitySpawner.js)
        //  replay-period = 3 + 20 = 23 (seconds, ditto)
        //  stats-period = UPDATES_PER_STATS_LOG * UPDATE_TIME / MSEC_PER_SEC = 10 seconds
        // The log should show between each stats report:
        //  "start" lines ~= injector-limit * P(finish) = injector-limit * stats-period/replay-period = 17 ?
        //  total attempts at starting ("start" lines + "could not thread" lines) ~= N_SOUNDS = 1000 ?
        //  entities > N_SOUNDS * (1+ N_SILENT_ENTITIES_PER_SOUND) = 11000 + whatever was in the scene before running spawner
        //  sounds = N_SOUNDS = 1000
        //  getEntityPropertiesPerUpdate ~= playing + failed-starts/UPDATES_PER_STATS_LOG + other-rechecks-each-update
        //     = injector-limit + (total attempts - "start" lines)/UPDATES_PER_STATS__LOG
        //       + (entities - playing - failed-starts/UPDATES_PER_STATS_LOG) * P(recheck-in-update)
        //                     where failed-starts/UPDATES_PER_STATS_LOG = (1000-17)/100 = 10
        //     = 40 + 10 + (11000 - 40 - 10)*UPDATE_TIME/RECHECK_TIME
        //     = 40 + 10 + 10950*0.01 = 159   (mostly proportional to enties/RECHECK_TIME)
        //  millisecondsPerUpdate ~= UPDATE_TIME = 100 (+ some timer machinery time)
        //  this assignment client activity monitor < 100% cpu
        var stats = {
            entities: 0,
            sounds: 0,
            playing: 0,
            getEntityPropertiesPerUpdate: examinationCount / UPDATES_PER_STATS_LOG,
            millisecondsPerUpdate: (now - lastStats) / UPDATES_PER_STATS_LOG
        };
        nUpdates = UPDATES_PER_STATS_LOG;
        lastStats = now;
        examinationCount = 0;
        Object.keys(entityCache).forEach(function (entityIdentifier) {
            var datum = entityCache[entityIdentifier];
            stats.entities++;
            if (datum.url) {
                stats.sounds++;
                if (datum.injector && datum.injector.playing) {
                    stats.playing++;
                }
            }
        });
        print(JSON.stringify(stats));
    }
}

// Update the set of which EntityData we know about.
//
function updateEntiesForAvatar(avatarIdentifier) { // Just one piece of update work.
    // This does at most:
    //   one queryOctree request of the entity server, and
    //   one findEntities geometry query of our own octree, and
    //   a quick internEntityDatum of each of what may be a large number of entityIdentifiers.
    // The idea is that this is a nice bounded piece of work that should not be done too frequently.
    // However, it means that we won't learn about new entities until, on average (nAvatars * UPDATE_TIME) + query round trip.
    var avatar = AvatarList.getAvatar(avatarIdentifier), avatarPosition = avatar && avatar.position;
    if (!avatarPosition) { // No longer here.
        return;
    }
    var timestamp = Date.now();
    EntityViewer.setPosition(avatarPosition);
    EntityViewer.queryOctree(); // Requests an update, but there's no telling when we'll actually see different results.
    var entities = Entities.findEntities(avatarPosition, QUERY_RADIUS);
    debug("found", entities.length, "entities near", avatar.name || "unknown", "at", avatarPosition);
    entities.forEach(function (entityIdentifier) {
        internEntityDatum(entityIdentifier, timestamp, avatarPosition, avatar);
    });
}

// Slowly update the set of data we have to work with.
//
var workQueue = [];
function updateWorkQueueForAvatarsPresent() { // when nothing else to do, fill queue with individual avatar updates
    workQueue = AvatarList.getAvatarIdentifiers().map(function (avatarIdentifier) {
        return function () {
            updateEntiesForAvatar(avatarIdentifier);
        };
    });
}
Script.setInterval(function () {
    // There might be thousands of EntityData known to us, but only a few will require any work to update.
    updateAllEntityData(); // i.e., this better be pretty fast.
    // Each interval, we do no more than one updateEntitiesforAvatar.
    if (!workQueue.length) {
        workQueue = [updateWorkQueueForAvatarsPresent];
    }
    workQueue.pop()(); // There's always one
}, UPDATE_TIME);