//
// platform.js
//
// Created by Seiji Emery on 8/19/15
// Copyright 2015 High Fidelity, Inc.
//
// Entity stress test / procedural demo.
// Spawns a platform under your avatar made up of randomly sized and colored boxes or spheres. The platform follows your avatar
// around, and comes with a UI to update the platform's properties (radius, entity density, color distribution, etc) in real time.
//
// Distributed under the Apache License, Version 2.0.
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
//


// UI and debug console implemented using uiwidgets / 2d overlays
Script.include("../../libraries/uiwidgets.js");
if (typeof(UI) === 'undefined') {   // backup link in case the user downloaded this somewhere
    print("Missing library script -- loading from public.highfidelity.io");
    Script.include('http://public.highfidelity.io/scripts/libraries/uiwidgets.js');
    if (typeof(UI) === 'undefined') {
        print("Cannot load UIWidgets library -- check your internet connection", COLORS.RED);
        throw new Error("Could not load uiwidgets.js");
    }
}

// Platform script
(function () {
var SCRIPT_NAME = "platform.js";
var USE_DEBUG_LOG = true;   // Turns on the 2dOverlay-based debug log. If false, just redirects to print.
var NUM_DEBUG_LOG_LINES = 10;
var LOG_ENTITY_CREATION_MESSAGES = false;   // detailed debugging (init)
var LOG_UPDATE_STATUS_MESSAGES = false;     // detailed debugging (startup)

var MAX_UPDATE_INTERVAL = 0.2;  // restrict to 5 updates / sec
var AVATAR_HEIGHT_OFFSET = 1.5; // offset to make the platform spawn under your feet. Might need to be adjusted for unusually proportioned avatars.

var USE_ENTITY_TIMEOUTS = true;
var ENTITY_TIMEOUT_DURATION = 30.0; // kill entities in 30 secs if they don't get any updates
var ENTITY_REFRESH_INTERVAL = 10.0; // poke the entities every 10s so they don't die until we're done with them

// Initial state
var NUM_PLATFORM_ENTITIES = 400;
var RADIUS = 5.0;

// Defines min/max for onscreen platform radius, density, and entity width/height/depth sliders.
// Color limits are hardcoded at [0, 255].
var PLATFORM_RADIUS_RANGE  = [ 1.0, 15.0 ];
var PLATFORM_DENSITY_RANGE = [ 0.0, 35.0 ]; // do NOT increase this above 40! (~20k limit). Entity count = Math.PI * radius * radius * density.
var PLATFORM_SHAPE_DIMENSIONS_RANGE = [ 0.001, 2.0 ]; // axis-aligned entity dimension limits

// Utils
(function () {
    if (typeof(Math.randRange) === 'undefined') {
        Math.randRange = function (min, max) {
            return Math.random() * (max - min) + min;
        }
    }
    if (typeof(Math.randInt) === 'undefined') {
        Math.randInt = function (n) {
            return Math.floor(Math.random() * n) | 0;
        }
    }
    function fromComponents (r, g, b, a) {
        this.red   = r;
        this.green = g;
        this.blue  = b;
        this.alpha = a || 1.0;
    }
    function fromHex (c) {
        this.red   = parseInt(c[1] + c[2], 16);
        this.green = parseInt(c[3] + c[4], 16);
        this.blue  = parseInt(c[5] + c[6], 16);
    }
    var Color = this.Color = function () {
        if (arguments.length >= 3) {
            fromComponents.apply(this, arguments);
        } else if (arguments.length == 1 && arguments[0].length == 7 && arguments[0][0] == '#') {
            fromHex.apply(this, arguments);
        } else {
            throw new Error("Invalid arguments to new Color(): " + JSON.stringify(arguments));
        }
    }
    Color.prototype.toString = function () {
        return "[Color: " + JSON.stringify(this) + "]";
    }
})();

// RNG models
(function () {
    /// Encapsulates a simple color model that generates colors using a linear, pseudo-random color distribution.
    var RandomColorModel = this.RandomColorModel = function () {
        this.shadeRange = 0;    // = 200;
        this.minColor = 55;     // = 100;
        this.redRange = 255;    // = 200;
        this.greenRange = 0;    // = 10;
        this.blueRange = 0;     // = 0;
    };
    /// Generates 4 numbers in [0, 1] corresponding to each color attribute (uniform shade and additive red, green, blue).
    /// This is done in a separate step from actually generating the colors, since it allows us to either A) completely
    /// rebuild / re-randomize the color values, or B) reuse the RNG values but with different color parameters, which
    /// enables us to do realtime color editing on the same visuals (awesome!).
    RandomColorModel.prototype.generateSeed = function () {
        return [ Math.random(), Math.random(), Math.random(), Math.random() ];
    };
    /// Takes a random 'seed' (4 floats from this.generateSeed()) and calculates a pseudo-random
    /// color by combining that with the color model's current parameters.
    RandomColorModel.prototype.getRandom = function (r) {
        // logMessage("color seed values " + JSON.stringify(r));
        var shade = Math.min(255, this.minColor + r[0] * this.shadeRange);

        // No clamping on the color components, so they may overflow. 
        // However, this creates some pretty interesting visuals, so we're not "fixing" this.
        var color = {
            red:   shade + r[1] * this.redRange,
            green: shade + r[2] * this.greenRange,
            blue:  shade + r[3] * this.blueRange
        };
        // logMessage("this: " + JSON.stringify(this));
        // logMessage("color: " + JSON.stringify(color), COLORS.RED);
        return color;
    };
    /// Custom property iterator used to setup UI (sliders, etc)
    RandomColorModel.prototype.setupUI = function (callback) {
        var _this = this;
        [ 
            ['shadeRange', 'shade range'], 
            ['minColor', 'shade min'], 
            ['redRange', 'red (additive)'],
            ['greenRange', 'green (additive)'], 
            ['blueRange', 'blue (additive)'] 
        ].forEach(function (v) {
            //       name, value, min, max, onValueChanged
            callback(v[1], _this[v[0]], 0, 255, function (value) { _this[v[0]] = value });
        });
    }

    /// Generates pseudo-random dimensions for our cubes / shapes.
    var RandomShapeModel = this.RandomShapeModel = function () {
        this.widthRange = [ 0.3, 0.7 ];
        this.depthRange = [ 0.5, 0.8 ];
        this.heightRange = [ 0.01, 0.08 ];
    };
    /// Generates 3 seed numbers in [0, 1]
    RandomShapeModel.prototype.generateSeed = function () {
        return [ Math.random(), Math.random(), Math.random() ];
    }
    /// Combines seed values with width/height/depth ranges to produce vec3 dimensions for a cube / sphere.
    RandomShapeModel.prototype.getRandom = function (r) {
        return {
            x: r[0] * (this.widthRange[1] - this.widthRange[0]) + this.widthRange[0],
            y: r[1] * (this.heightRange[1] - this.heightRange[0]) + this.heightRange[0],
            z: r[2] * (this.depthRange[1] - this.depthRange[0]) + this.depthRange[0]
        };
    }
    /// Custom property iterator used to setup UI (sliders, etc)
    RandomShapeModel.prototype.setupUI = function (callback) {
        var _this = this;
        var dimensionsMin = PLATFORM_SHAPE_DIMENSIONS_RANGE[0];
        var dimensionsMax = PLATFORM_SHAPE_DIMENSIONS_RANGE[1];
        [
            ['widthRange', 'width'], 
            ['depthRange', 'depth'], 
            ['heightRange', 'height'] 
        ].forEach(function (v) {
            //       name, value, min, max, onValueChanged
            callback(v[1], _this[v[0]], dimensionsMin, dimensionsMax, function (value) { _this[v[0]] = value });
        });
    }

    /// Combines color + shape PRNG models and hides their implementation details.
    var RandomAttribModel = this.RandomAttribModel = function () {
        this.colorModel = new RandomColorModel();
        this.shapeModel = new RandomShapeModel();
    }
    /// Completely re-randomizes obj's `color` and `dimensions` parameters based on the current model params.
    RandomAttribModel.prototype.randomizeShapeAndColor = function (obj) {
        // logMessage("randomizing " + JSON.stringify(obj));
        obj._colorSeed = this.colorModel.generateSeed();
        obj._shapeSeed = this.shapeModel.generateSeed();
        this.updateShapeAndColor(obj);
        // logMessage("color seed: " + JSON.stringify(obj._colorSeed), COLORS.RED);
        // logMessage("randomized color: " + JSON.stringify(obj.color), COLORS.RED);
        // logMessage("randomized: " + JSON.stringify(obj));
        return obj;
    }
    /// Updates obj's `color` and `dimensions` params to use the current model params.
    /// Reuses hidden seed attribs; _must_ have called randomizeShapeAndColor(obj) at some point before
    /// calling this.
    RandomAttribModel.prototype.updateShapeAndColor = function (obj) {
        try {
            // logMessage("update shape and color: " + this.colorModel);
            obj.color      = this.colorModel.getRandom(obj._colorSeed);
            obj.dimensions = this.shapeModel.getRandom(obj._shapeSeed); 
        } catch (e) {
            logMessage("update shape / color failed", COLORS.RED);
            logMessage('' + e, COLORS.RED);
            logMessage("obj._colorSeed = " + JSON.stringify(obj._colorSeed));
            logMessage("obj._shapeSeed = " + JSON.stringify(obj._shapeSeed));
            // logMessage("obj = " + JSON.stringify(obj));
            throw e;
        }
        return obj;
    }
})();

// Status / logging UI (ignore this)
(function () {
    var COLORS = this.COLORS = {
        'GREEN': new Color("#2D870C"),
        'RED': new Color("#AF1E07"),
        'LIGHT_GRAY': new Color("#CCCCCC"),
        'DARK_GRAY': new Color("#4E4E4E")
    };
    function buildDebugLog () {
        var LINE_WIDTH = 400;
        var LINE_HEIGHT = 20;
    
        var lines = [];
        var lineIndex = 0;
        for (var i = 0; i < NUM_DEBUG_LOG_LINES; ++i) {
            lines.push(new UI.Label({ 
                text: " ", visible: false, 
                width: LINE_WIDTH, height: LINE_HEIGHT,
            }));
        }
        var title = new UI.Label({
            text: SCRIPT_NAME, visible: true,
            width: LINE_WIDTH, height: LINE_HEIGHT,
        });
    
        var overlay = new UI.Box({
            visible: true,
            width: LINE_WIDTH, height: 0,
            backgroundColor: COLORS.DARK_GRAY,
            backgroundAlpha: 0.3
        });
        overlay.setPosition(280, 10);
        relayoutFrom(0);
        UI.updateLayout();
    
        function relayoutFrom (n) {
            var layoutPos = {
                x: overlay.position.x,
                y: overlay.position.y
            };
    
            title.setPosition(layoutPos.x, layoutPos.y);
            layoutPos.y += LINE_HEIGHT;
    
            // for (var i = n; i >= 0; --i) {
            for (var i = n + 1; i < lines.length; ++i) {
                if (lines[i].visible) {
                    lines[i].setPosition(layoutPos.x, layoutPos.y);
                    layoutPos.y += LINE_HEIGHT;
                }
            }
            // for (var i = lines.length - 1; i > n; --i) {
            for (var i = 0; i <= n; ++i) {
                if (lines[i].visible) {
                    lines[i].setPosition(layoutPos.x, layoutPos.y);
                    layoutPos.y += LINE_HEIGHT;
                }
            }
            overlay.height = (layoutPos.y - overlay.position.y + 10);
            overlay.getOverlay().update({
                height: overlay.height
            });
        }
        this.logMessage = function (text, color, alpha) {
            lines[lineIndex].setVisible(true);
            relayoutFrom(lineIndex);
    
            lines[lineIndex].getOverlay().update({
                text: text,
                visible: true,
                color: color || COLORS.LIGHT_GRAY,
                alpha: alpha !== undefined ? alpha : 1.0,
                x: lines[lineIndex].position.x,
                y: lines[lineIndex].position.y
            });
            lineIndex = (lineIndex + 1) % lines.length;
            UI.updateLayout();
        }
    }
    if (USE_DEBUG_LOG) {
        buildDebugLog();
    } else {
        this.logMessage = function (msg) {
            print(SCRIPT_NAME + ": " + msg);
        }
    }
})();

// Utils (ignore)
(function () {
    // Utility function
    var withDefaults = this.withDefaults = function (properties, defaults) {
        // logMessage("withDefaults: " + JSON.stringify(properties) + JSON.stringify(defaults));
        properties = properties || {};
        if (defaults) {
            for (var k in defaults) {
                properties[k] = defaults[k];
            }
        }
        return properties;
    }
    var withReadonlyProp = this.withReadonlyProp = function (propname, value, obj) {
        Object.defineProperty(obj, propname, {
            value: value,
            writable: false
        });
        return obj;
    }

    // Math utils
    if (typeof(Math.randRange) === 'undefined') {
        Math.randRange = function (min, max) {
            return Math.random() * (max - min) + min;
        }
    }
    if (typeof(Math.randInt) === 'undefined') {
        Math.randInt = function (n) {
            return Math.floor(Math.random() * n) | 0;
        }
    }

    /// Random distrib: Get a random point within a circle on the xz plane with radius r, center p.
    this.randomCirclePoint = function (r, pos) {
        var a = Math.random(), b = Math.random();
        if (b < a) {
            var tmp = b;
            b = a;
            a = tmp;
        }
        var point = {
            x: pos.x + b * r * Math.cos(2 * Math.PI * a / b),
            y: pos.y,
            z: pos.z + b * r * Math.sin(2 * Math.PI * a / b)
        };
        if (LOG_ENTITY_CREATION_MESSAGES) {
            // logMessage("input params: " + JSON.stringify({ radius: r, position: pos }), COLORS.GREEN);
            // logMessage("a = " + a + ", b = " + b);
            logMessage("generated point: " + JSON.stringify(point), COLORS.RED);
        }
        return point;
    }

    // Entity utils. NOT using overlayManager for... reasons >.>
    var makeEntity = this.makeEntity = function (properties) {
        if (LOG_ENTITY_CREATION_MESSAGES) {
            logMessage("Creating entity: " + JSON.stringify(properties));
        }
        var entity = Entities.addEntity(properties);
        return withReadonlyProp("type", properties.type, {
            update: function (properties) {
                Entities.editEntity(entity, properties);
            },
            destroy: function () {
                Entities.deleteEntity(entity)
            },
            getId: function () {
                return entity;
            }
        });
    }
    // this.makeLight = function (properties) {
    //  return makeEntity(withDefaults(properties, {
    //      type: "Light",
    //      isSpotlight: false,
    //      diffuseColor: { red: 255, green: 100, blue: 100 },
    //      ambientColor: { red: 200, green: 80, blue: 80 }
    //  }));
    // }
    this.makeBox = function (properties) {
        // logMessage("Creating box: " + JSON.stringify(properties));
        return makeEntity(withDefaults(properties, {
            type: "Box"
        }));
    }
})();

// Platform
(function () {
    /// Encapsulates a platform 'piece'. Owns an entity (`box`), and handles destruction and some other state.
    var PlatformComponent = this.PlatformComponent = function (properties) {
        // logMessage("Platform component initialized with " + Object.keys(properties), COLORS.GREEN);
        this.position   = properties.position || null;
        this.color      = properties.color    || null;
        this.dimensions = properties.dimensions || null;
        this.entityType = properties.type || "Box";

        // logMessage("Spawning with type: '" + this.entityType + "' (properties.type = '" + properties.type + "')", COLORS.GREEN);

        if (properties._colorSeed)
            this._colorSeed = properties._colorSeed;
        if (properties._shapeSeed)
            this._shapeSeed = properties._shapeSeed;

        // logMessage("dimensions: " + JSON.stringify(this.dimensions));
        // logMessage("color: " + JSON.stringify(this.color));

        this.cachedEntity = null;
        this.activeEntity = this.spawnEntity(this.entityType);
    };
    PlatformComponent.prototype.spawnEntity = function (type) {
        return makeEntity({
            type: type,
            position: this.position,
            dimensions: this.dimensions,
            color: this.color,
            lifetime: USE_ENTITY_TIMEOUTS ? ENTITY_TIMEOUT_DURATION : -1.0,
            alpha: 0.5
        });
    }
    if (USE_ENTITY_TIMEOUTS) {
        PlatformComponent.prototype.pokeEntity = function () {
            // Kinda inefficient, but there's no way to get around this :/
            var age = Entities.getEntityProperties(this.activeEntity.getId()).age;
            this.activeEntity.update({ lifetime: ENTITY_TIMEOUT_DURATION + age });
        }
    } else {
        PlatformComponent.prototype.pokeEntity = function () {}
    }
    /// Updates platform to be at position p, and calls <entity>.update() with the current
    /// position, color, and dimensions parameters.
    PlatformComponent.prototype.update = function (position) {
        if (position)
            this.position = position;
        // logMessage("updating with " + JSON.stringify(this));
        this.activeEntity.update(this);
    }
    function swap (a, b) {
        var tmp = a;
        a = b;
        b = tmp;
    }   
    PlatformComponent.prototype.swapEntityType = function (newType) {
        if (this.entityType !== newType) {
            this.entityType = newType;
            // logMessage("Destroying active entity and rebuilding it (newtype = '" + newType + "')");
            if (this.activeEntity) {
                this.activeEntity.destroy();
            }
            this.activeEntity = this.spawnEntity(newType);
            // if (this.cachedEntity && this.cachedEntity.type == newType) {
            //  this.cachedEntity.update({ visible: true });
            //  this.activeEntity.update({ visible: false });
            //  swap(this.cachedEntity, this.activeEntity);
            //  this.update(this.position);
            // } else {
            //  this.activeEntity.update({ visible: false });
            //  this.cachedEntity = this.activeEntity;
            //  this.activeEntity = spawnEntity(newType);
            // }
        }
    }
    /// Swap state with another component
    PlatformComponent.prototype.swap = function (other) {
        swap(this.position, other.position);
        swap(this.dimensions, other.dimensions);
        swap(this.color, other.color);
        swap(this.entityType, other.entityType);
        swap(this.activeEntity, other.activeEntity);
        swap(this._colorSeed, other._colorSeed);
        swap(this._shapeSeed, other._shapeSeed);
    }
    PlatformComponent.prototype.destroy = function () {
        if (this.activeEntity) {
            this.activeEntity.destroy();
            this.activeEntity = null;
        }
        if (this.cachedEntity) {
            this.cachedEntity.destroy();
            this.cachedEntity = null;
        }
    }

    // util
    function inRange (p1, p2, radius) {
        return Vec3.distance(p1, p2) < Math.abs(radius);
    }

    /// Encapsulates a moving platform that follows the avatar around (mostly).
    var DynamicPlatform = this.DynamicPlatform = function (n, position, radius) {
        this.position = position;
        this.radius   = radius;
        this.randomizer = new RandomAttribModel();
        this.boxType = "Box";
        this.boxTypes = [ "Box", "Sphere" ];

        logMessage("Spawning " + n + " entities", COLORS.GREEN);
        var boxes = this.boxes = [];
        while (n > 0) {
            boxes.push(this.spawnEntity());
            --n;
        }
        this.targetDensity = this.getEntityDensity();
        this.pendingUpdates = {};
        this.updateTimer = 0.0;

        this.platformHeight = position.y;
        this.oldPos    = { x: position.x, y: position.y, z: position.z };
        this.oldRadius = radius;

        // this.sendPokes();
    }
    DynamicPlatform.prototype.toString = function () {
        return "[DynamicPlatform (" + this.boxes.length + " entities)]";
    }
    DynamicPlatform.prototype.spawnEntity = function () {
        // logMessage("Called spawn entity. this.boxType = '" + this.boxType + "'")
        var properties = { position: this.randomPoint(), type: this.boxType };
        this.randomizer.randomizeShapeAndColor(properties);
        return new PlatformComponent(properties);
    }
    DynamicPlatform.prototype.updateEntityAttribs = function () {
        var _this = this;
        this.setPendingUpdate('updateEntityAttribs', function () {
            // logMessage("updating model", COLORS.GREEN);
            _this.boxes.forEach(function (box) {
                this.randomizer.updateShapeAndColor(box);
                box.update();
            }, _this);
        });
    }
    DynamicPlatform.prototype.toggleBoxType = function () {
        var _this = this;
        this.setPendingUpdate('toggleBoxType', function () {
            // Swap / cycle through types: find index of current type and set next type to idx+1
            for (var idx = 0; idx < _this.boxTypes.length; ++idx) {
                if (_this.boxTypes[idx] === _this.boxType) {
                    var nextIndex = (idx + 1) % _this.boxTypes.length;
                    logMessage("swapping box type from '" + _this.boxType + "' to '" + _this.boxTypes[nextIndex] + "'", COLORS.GREEN);
                    _this.boxType = _this.boxTypes[nextIndex];
                    break;
                }
            }
            _this.boxes.forEach(function (box) {
                box.swapEntityType(_this.boxType);
            }, _this);
        });
    }
    DynamicPlatform.prototype.getBoxType = function () {
        return this.boxType;
    }

    // if (USE_ENTITY_TIMEOUTS) {
    //  DynamicPlatform.prototype.sendPokes = function () {
    //      var _this = this;
    //      function poke () {
    //          logMessage("Poking entities so they don't die", COLORS.GREEN);
    //          _this.boxes.forEach(function (box) {
    //              box.pokeEntity();
    //          }, _this);


    //          if (_this.pendingUpdates['keepalive']) {
    //              logMessage("previous timer: " + _this.pendingUpdates['keepalive'].timer + "; new timer: " + ENTITY_REFRESH_INTERVAL)
    //          }
    //          _this.pendingUpdates['keepalive'] = {
    //              callback: poke,
    //              timer: ENTITY_REFRESH_INTERVAL,
    //              skippedUpdates: 0
    //          };
    //          // _this.setPendingUpdate('keepalive', poke);
    //          // _this.pendingUpdates['keepalive'].timer = ENTITY_REFRESH_INTERVAL;
    //      }
    //      poke();
    //  }
    // } else {
    //  DynamicPlatform.prototype.sendPokes = function () {};
    // }

    /// Queue impl that uses the update loop to limit potentially expensive updates to only execute every x seconds (default: 200 ms).
    /// This is to prevent UI code from running full entity updates every 10 ms (or whatever).
    DynamicPlatform.prototype.setPendingUpdate = function (name, callback) {
        if (!this.pendingUpdates[name]) {
            // logMessage("Queued update for " + name, COLORS.GREEN);
            this.pendingUpdates[name] = {
                callback: callback,
                timer: 0.0,
                skippedUpdates: 0
            }
        } else {
            // logMessage("Deferred update for " + name, COLORS.GREEN);
            this.pendingUpdates[name].callback = callback;
            this.pendingUpdates[name].skippedUpdates++;
            // logMessage("scheduling update for \"" + name + "\" to run in " + this.pendingUpdates[name].timer + " seconds");
        }
    }
    /// Runs all queued updates as soon as they can execute (each one has a cooldown timer).
    DynamicPlatform.prototype.processPendingUpdates = function (dt) {
        for (var k in this.pendingUpdates) {
            if (this.pendingUpdates[k].timer >= 0.0)
                this.pendingUpdates[k].timer -= dt;

            if (this.pendingUpdates[k].callback && this.pendingUpdates[k].timer < 0.0) {
                // logMessage("Dispatching update for " + k);
                try {
                    this.pendingUpdates[k].callback();
                } catch (e) {
                    logMessage("update for \"" + k + "\" failed: " + e, COLORS.RED);
                }
                this.pendingUpdates[k].timer = MAX_UPDATE_INTERVAL;
                this.pendingUpdates[k].skippedUpdates = 0;
                this.pendingUpdates[k].callback = null;
            } else {
                // logMessage("Deferred update for " + k + " for " + this.pendingUpdates[k].timer + " seconds");
            }
        }
    }

    /// Updates the platform based on the avatar's current position (spawning / despawning entities as needed),
    /// and calls processPendingUpdates() once this is done.
    /// Does NOT have any update interval limits (it just updates every time it gets run), but these are not full
    /// updates (they're incremental), so the network will not get flooded so long as the avatar is moving at a
    /// normal walking / flying speed.
    DynamicPlatform.prototype.updatePosition = function (dt, position) {
        // logMessage("updating " + this);
        position.y = this.platformHeight;
        this.position = position;

        var toUpdate = [];
        this.boxes.forEach(function (box, i) {
            // if (Math.abs(box.position.y - position.y) > HEIGHT_TOLERANCE || !inRange(box, position, radius)) {
            if (!inRange(box.position, this.position, this.radius)) {
                toUpdate.push(i);
            }
        }, this);

        var MAX_TRIES = toUpdate.length * 8;
        var tries = MAX_TRIES;
        var moved = 0;
        var recalcs = 0;
        toUpdate.forEach(function (index) {
            if ((index % 2 == 0) || tries > 0) {
                do {
                    var randomPoint = this.randomPoint(this.position, this.radius);
                    ++recalcs
                } while (--tries > 0 && inRange(randomPoint, this.oldPos, this.oldRadiuss));
    
                if (LOG_UPDATE_STATUS_MESSAGES && tries <= 0) {
                    logMessage("updatePlatform() gave up after " + MAX_TRIES + " iterations (" + moved + " / " + toUpdate.length + " successful updates)", COLORS.RED);
                    logMessage("old pos: " + JSON.stringify(this.oldPos) + ", old radius: " + this.oldRadius);
                    logMessage("new pos: " + JSON.stringify(this.position) + ", new radius: " + this.radius);
                }
            } else {
                var randomPoint = this.randomPoint(position, this.radius);
            }

            this.randomizer.randomizeShapeAndColor(this.boxes[index]);
            this.boxes[index].update(randomPoint);
            // this.boxes[index].setValues({
            //  position: randomPoint,
            //  // dimensions: this.randomDimensions(),
            //  // color: this.randomColor()
            // });
            ++moved;
        }, this);
        recalcs = recalcs - toUpdate.length;

        this.oldPos    = position;
        this.oldRadius = this.radius;
        if (LOG_UPDATE_STATUS_MESSAGES && toUpdate.length > 0) {
            logMessage("updated " + toUpdate.length + " entities w/ " + recalcs + " recalcs");
        }
    }

    DynamicPlatform.prototype.update = function (dt, position) {
        this.updatePosition(dt, position);
        this.processPendingUpdates(dt);
        this.sendPokes(dt);
    }

    if (USE_ENTITY_TIMEOUTS) {
        DynamicPlatform.prototype.sendPokes = function (dt) {
            logMessage("starting keepalive", COLORS.GREEN);
            // logMessage("dt = " + dt, COLORS.RED);
            // var original = this.sendPokes;
            var pokeTimer = 0.0;
            this.sendPokes = function (dt) {
                // logMessage("dt = " + dt);
                if ((pokeTimer -= dt) < 0.0) {
                    // logMessage("Poking entities so they don't die", COLORS.GREEN);
                    this.boxes.forEach(function (box) {
                        box.pokeEntity();
                    }, this);
                    pokeTimer = ENTITY_REFRESH_INTERVAL;
                } else {
                    // logMessage("Poking entities in " + pokeTimer + " seconds");
                }
            }
            // logMessage("this.sendPokes === past this.sendPokes? " + (this.sendPokes === original), COLORS.GREEN);
            this.sendPokes(dt);
        }
    } else {
        DynamicPlatform.prototype.sendPokes = function () {};
    }
    DynamicPlatform.prototype.getEntityCount = function () {
        return this.boxes.length;
    }
    DynamicPlatform.prototype.getEntityCountWithRadius = function (radius) {
        var est = Math.floor((radius * radius) / (this.radius * this.radius) * this.getEntityCount());
        var actual = Math.floor(Math.PI * radius * radius * this.getEntityDensity());

        if (est != actual) {
            logMessage("assert failed: getEntityCountWithRadius() -- est " + est + " != actual " + actual);
        }
        return est;
    }
    DynamicPlatform.prototype.getEntityCountWithDensity = function (density) {
        return Math.floor(Math.PI * this.radius * this.radius * density);
    }

    /// Sets the entity count to n. Don't call this directly -- use setRadius / density instead.
    DynamicPlatform.prototype.setEntityCount = function (n) {
        if (n > this.boxes.length) {
            // logMessage("Setting entity count to " + n + " (adding " + (n - this.boxes.length) + " entities)", COLORS.GREEN);

            // Spawn new boxes
            n = n - this.boxes.length;
            for (; n > 0; --n) {
                // var properties = { position: this.randomPoint() };
                // this.randomizer.randomizeShapeAndColor(properties);
                // this.boxes.push(new PlatformComponent(properties));
                this.boxes.push(this.spawnEntity());
            }
        } else if (n < this.boxes.length) {
            // logMessage("Setting entity count to " + n + " (removing " + (this.boxes.length - n) + " entities)", COLORS.GREEN);

            // Destroy random boxes (technically, the most recent ones, but it should be sorta random)
            n = this.boxes.length - n;
            for (; n > 0; --n) {
                this.boxes.pop().destroy();
            }
        }
    }
    /// Calculate the entity density based on radial surface area.
    DynamicPlatform.prototype.getEntityDensity = function () {
        return (this.boxes.length * 1.0) / (Math.PI * this.radius * this.radius);
    }
    /// Queues a setDensity update. This is expensive, so we don't call it directly from UI.
    DynamicPlatform.prototype.setDensityOnNextUpdate = function (density) {
        var _this = this;
        this.targetDensity = density;
        this.setPendingUpdate('density', function () {
            _this.updateEntityDensity(density);
        });
    }
    DynamicPlatform.prototype.updateEntityDensity = function (density) {
        this.setEntityCount(Math.floor(density * Math.PI * this.radius * this.radius));
    }
    DynamicPlatform.prototype.getRadius = function () {
        return this.radius;
    }
    /// Queues a setRadius update. This is expensive, so we don't call it directly from UI.
    DynamicPlatform.prototype.setRadiusOnNextUpdate = function (radius) {
        var _this = this;
        this.setPendingUpdate('radius', function () {
            _this.setRadius(radius);
        });
    }
    var DEBUG_RADIUS_RECALC = false;
    DynamicPlatform.prototype.setRadius = function (radius) {
        if (radius < this.radius) { // Reduce case
            // logMessage("Setting radius to " + radius + " (shrink by " + (this.radius - radius) + ")", COLORS.GREEN );
            this.radius = radius;

            // Remove all entities outside of current bounds. Requires swapping, since we want to maintain a contiguous array.
            // Algorithm: two pointers at front and back. We traverse fwd and back, swapping elems so that all entities in bounds
            // are at the front of the array, and all entities out of bounds are at the back. We then pop + destroy all entities
            // at the back to reduce the entity count.
            var count = this.boxes.length;
            var toDelete = 0;
            var swapList = [];
            if (DEBUG_RADIUS_RECALC) {
                logMessage("starting at i = 0, j = " + (count - 1));
            }
            for (var i = 0, j = count - 1; i < j; ) {
                // Find first elem outside of bounds that we can move to the back
                while (inRange(this.boxes[i].position, this.position, this.radius) && i < j) {
                    ++i;
                }
                // Find first elem in bounds that we can move to the front
                while (!inRange(this.boxes[j].position, this.position, this.radius) && i < j) {
                    --j; ++toDelete;
                }
                if (i < j) {
                    // swapList.push([i, j]);
                    if (DEBUG_RADIUS_RECALC) {
                        logMessage("swapping " + i + ", " + j); 
                    }
                    this.boxes[i].swap(this.boxes[j]);
                    ++i, --j; ++toDelete;
                } else {
                    if (DEBUG_RADIUS_RECALC) {
                        logMessage("terminated at i = " + i + ", j = " + j, COLORS.RED);
                    }
                }
            }
            if (DEBUG_RADIUS_RECALC) {
                logMessage("toDelete = " + toDelete, COLORS.RED);
            }
            // Sanity check
            if (toDelete > this.boxes.length) {
                logMessage("Error: toDelete " + toDelete + " > entity count " + this.boxes.length + " (setRadius algorithm)", COLORS.RED);
                toDelete = this.boxes.length;
            }
            if (toDelete > 0) {
                // logMessage("Deleting " + toDelete + " entities as part of radius resize", COLORS.GREEN);
            }
            // Delete cleared boxes
            for (; toDelete > 0; --toDelete) {
                this.boxes.pop().destroy();
            }
            // fix entity density (just in case -- we may have uneven entity distribution)
            this.updateEntityDensity(this.targetDensity);
        } else if (radius > this.radius) {
            // Grow case (much simpler)
            // logMessage("Setting radius to " + radius + " (grow by " + (radius - this.radius) + ")", COLORS.GREEN);

            // Add entities based on entity density
            // var density = this.getEntityDensity();
            var density = this.targetDensity;
            var oldArea = Math.PI * this.radius * this.radius;
            var n = Math.floor(density * Math.PI * (radius * radius - this.radius * this.radius));

            if (n > 0) {
                // logMessage("Adding " + n + " entities", COLORS.GREEN);

                // Add entities (we use a slightly different algorithm to place them in the area between two concentric circles.
                // This is *slightly* less uniform (the reason we're not using this everywhere is entities would be tightly clustered
                // at the platform center and become spread out as the radius increases), but the use-case here is just incremental 
                // radius resizes and the user's not likely to notice the difference).
                for (; n > 0; --n) {
                    var theta = Math.randRange(0.0, Math.PI * 2.0);
                    var r = Math.randRange(this.radius, radius);
                    // logMessage("theta = " + theta + ", r = " + r);
                    var pos = {
                        x: Math.cos(theta) * r + this.position.x,
                        y: this.position.y,
                        z: Math.sin(theta) * r + this.position.y
                    };

                    // var properties = { position: pos };
                    // this.randomizer.randomizeShapeAndColor(properties);
                    // this.boxes.push(new PlatformComponent(properties));
                    this.boxes.push(this.spawnEntity());
                }
            }
            this.radius = radius;
        }
    }
    DynamicPlatform.prototype.updateHeight = function (height) {
        logMessage("Setting platform height to " + height);
        this.platformHeight = height;

        // Invalidate current boxes to trigger a rebuild
        this.boxes.forEach(function (box) {
            box.position.x += this.oldRadius * 100;
        });
        // this.update(dt, position, radius);
    }
    /// Gets a random point within the platform bounds.
    /// Should maybe get moved to the RandomAttribModel (would be much cleaner), but this works for now.
    DynamicPlatform.prototype.randomPoint = function (position, radius) {
        position = position || this.position;
        radius = radius !== undefined ? radius : this.radius;
        return randomCirclePoint(radius, position);
    }
    /// Old. The RandomAttribModel replaces this and enables realtime editing of the *****_RANGE params.
    // DynamicPlatform.prototype.randomDimensions = function () {
    //  return {
    //      x: Math.randRange(WIDTH_RANGE[0], WIDTH_RANGE[1]),
    //      y: Math.randRange(HEIGHT_RANGE[0], HEIGHT_RANGE[1]),
    //      z: Math.randRange(DEPTH_RANGE[0], DEPTH_RANGE[1])
    //  };
    // }
    // DynamicPlatform.prototype.randomColor = function () {
    //  var shade = Math.randRange(SHADE_RANGE[0], SHADE_RANGE[1]);
    //  // var h = HUE_RANGE;
    //  return {
    //      red:    shade + Math.randRange(RED_RANGE[0], RED_RANGE[1]) | 0,
    //      green:  shade + Math.randRange(GREEN_RANGE[0], GREEN_RANGE[1]) | 0,
    //      blue:   shade + Math.randRange(BLUE_RANGE[0], BLUE_RANGE[1]) | 0
    //  }
    //  // return COLORS[Math.randInt(COLORS.length)]
    // }

    /// Cleanup.
    DynamicPlatform.prototype.destroy = function () {
        this.boxes.forEach(function (box) {
            box.destroy();
        });
        this.boxes = [];
    }
})();

// UI
(function () {
    var CATCH_SETUP_ERRORS = true;

    // Util functions for setting up widgets (the widget library is intended to be used like this)
    function makePanel (dir, properties) {
        return new UI.WidgetStack(withDefaults(properties, {
            dir: dir
        }));
    }
    function addSpacing (parent, width, height) {
        parent.add(new UI.Box({
            backgroundAlpha: 0.0,
            width: width, height: height
        }));
    }
    function addLabel (parent, text) {
        return parent.add(new UI.Label({
            text: text,
            width: 200,
            height: 20
        }));
    }
    function addSlider (parent, label, min, max, getValue, onValueChanged) {
        try {
            var layout = parent.add(new UI.WidgetStack({ dir: "+x" }));
            var textLabel = layout.add(new UI.Label({
                text: label,
                width: 130,
                height: 20
            }));
            var valueLabel = layout.add(new UI.Label({
                text: "" + (+getValue().toFixed(1)),
                width: 60,
                height: 20
            }));
            var slider = layout.add(new UI.Slider({
                value: getValue(), minValue: min, maxValue: max,
                width: 300, height: 20,
                slider: {
                    width: 30,
                    height: 18
                },
                onValueChanged: function (value) {
                    valueLabel.setText("" + (+value.toFixed(1)));
                    onValueChanged(value, slider);
                    UI.updateLayout();
                }
            }));
            return slider;
        } catch (e) {
            logMessage("" + e, COLORS.RED);
            logMessage("parent: " + parent, COLORS.RED);
            logMessage("label:  " + label, COLORS.RED);
            logMessage("min:    " + min, COLORS.RED);
            logMessage("max:    " + max, COLORS.RED);
            logMessage("getValue: " + getValue, COLORS.RED);
            logMessage("onValueChanged: " + onValueChanged, COLORS.RED);
            throw e;
        }
    }
    function addButton (parent, label, onClicked) {
        var button = parent.add(new UI.Box({
            text: label,
            width: 160,
            height: 26,
            leftMargin: 8,
            topMargin: 3
        }));
        button.addAction('onClick', onClicked);
        return button;
    }
    function moveToBottomLeftScreenCorner (widget) {
        var border = 5;
        var pos = {
            x: border,
            y: Controller.getViewportDimensions().y - widget.getHeight() - border
        };
        if (widget.position.x != pos.x || widget.position.y != pos.y) {
            widget.setPosition(pos.x, pos.y);
            UI.updateLayout();
        }
    }
    var _export = this;

    /// Setup the UI. Creates a bunch of sliders for setting the platform radius, density, and entity color / shape properties.
    /// The entityCount slider is readonly.
    function _setupUI (platform) {
        var layoutContainer = makePanel("+y", { visible: false });
        // layoutContainer.setPosition(10, 280);
        // makeDraggable(layoutContainer);
        _export.onScreenResize = function () {
            moveToBottomLeftScreenCorner(layoutContainer);
        }
        var topSection = layoutContainer.add(makePanel("+x")); addSpacing(layoutContainer, 1, 5);
        var btmSection = layoutContainer.add(makePanel("+x"));

        var controls = topSection.add(makePanel("+y")); addSpacing(topSection, 20, 1);
        var buttons  = topSection.add(makePanel("+y")); addSpacing(topSection, 20, 1);

        var colorControls = btmSection.add(makePanel("+y")); addSpacing(btmSection, 20, 1);
        var shapeControls = btmSection.add(makePanel("+y")); addSpacing(btmSection, 20, 1);

        // Top controls
        addLabel(controls, "Platform (platform.js)");
        controls.radiusSlider = addSlider(controls, "radius", PLATFORM_RADIUS_RANGE[0], PLATFORM_RADIUS_RANGE[1], function () { return platform.getRadius() },
            function (value) { 
                platform.setRadiusOnNextUpdate(value);
                controls.entityCountSlider.setValue(platform.getEntityCountWithRadius(value));
            });
        addSpacing(controls, 1, 2);
        controls.densitySlider = addSlider(controls, "entity density", PLATFORM_DENSITY_RANGE[0], PLATFORM_DENSITY_RANGE[1], function () { return platform.getEntityDensity() }, 
            function (value) {
                platform.setDensityOnNextUpdate(value);
                controls.entityCountSlider.setValue(platform.getEntityCountWithDensity(value));
            });
        addSpacing(controls, 1, 2);

        var minEntities = Math.PI * PLATFORM_RADIUS_RANGE[0] * PLATFORM_RADIUS_RANGE[0] * PLATFORM_DENSITY_RANGE[0];
        var maxEntities = Math.PI * PLATFORM_RADIUS_RANGE[1] * PLATFORM_RADIUS_RANGE[1] * PLATFORM_DENSITY_RANGE[1];
        controls.entityCountSlider = addSlider(controls, "entity count", minEntities, maxEntities, function () { return platform.getEntityCount() },
            function (value) {});
        controls.entityCountSlider.actions = {}; // hack: make this slider readonly (clears all attached actions)
        controls.entityCountSlider.slider.actions = {};

        // Buttons
        addSpacing(buttons, 1, 22);
        addButton(buttons, 'rebuild', function () {
            platform.updateHeight(MyAvatar.position.y - AVATAR_HEIGHT_OFFSET);
        });
        addSpacing(buttons, 1, 2);
        addButton(buttons, 'toggle entity type', function () {
            platform.toggleBoxType();
        });
        
        // Bottom controls

        // Iterate over controls (making sliders) for the RNG shape / dimensions model
        platform.randomizer.shapeModel.setupUI(function (name, value, min, max, setValue) {
            // logMessage("platform.randomizer.shapeModel." + name + " = " + value);
            var internal = {
                avg: (value[0] + value[1]) * 0.5,
                range: Math.abs(value[0] - value[1])
            };
            // logMessage(JSON.stringify(internal), COLORS.GREEN);
            addSlider(shapeControls, name + ' avg', min, max, function () { return internal.avg; }, function (value) {
                internal.avg = value;
                setValue([ internal.avg - internal.range * 0.5, internal.avg + internal.range * 0.5 ]);
                platform.updateEntityAttribs();
            });
            addSpacing(shapeControls, 1, 2);
            addSlider(shapeControls, name + ' range', min, max, function () { return internal.range }, function (value) {
                internal.range = value;
                setValue([ internal.avg - internal.range * 0.5, internal.avg + internal.range * 0.5 ]);
                platform.updateEntityAttribs();
            });
            addSpacing(shapeControls, 1, 2);
        });
        // Do the same for the color model
        platform.randomizer.colorModel.setupUI(function (name, value, min, max, setValue) {
            // logMessage("platform.randomizer.colorModel." + name + " = " + value);
            addSlider(colorControls, name, min, max, function () { return value; }, function (value) {
                setValue(value);
                platform.updateEntityAttribs();
            });
            addSpacing(colorControls, 1, 2);
        });
        
        moveToBottomLeftScreenCorner(layoutContainer);
        layoutContainer.setVisible(true);
    }
    this.setupUI = function (platform) {
        if (CATCH_SETUP_ERRORS) {
            try {
                _setupUI(platform);
            } catch (e) {
                logMessage("Error setting up ui: " + e, COLORS.RED);
            }
        } else {
            _setupUI(platform);
        }
    }
})();

// Error handling w/ explicit try / catch blocks. Good for catching unexpected errors with the onscreen debugLog 
// (if it's enabled); bad for detailed debugging since you lose the file and line num even if the error gets rethrown.

// Catch errors from init
var CATCH_INIT_ERRORS = true;

// Catch errors from everything (technically, Script and Controller signals that runs platform / ui code)
var CATCH_ERRORS_FROM_EVENT_UPDATES = false;

// Setup everything
(function () {
    var doLater = null;
    if (CATCH_ERRORS_FROM_EVENT_UPDATES) {
        // Decorates a function w/ explicit error catching + printing to the debug log.
        function catchErrors (fcn) {
            return function () {
                try {
                    fcn.apply(this, arguments);
                } catch (e) {
                    logMessage('' + e, COLORS.RED);
                    logMessage("while calling " + fcn);
                    logMessage("Called by: " + arguments.callee.caller);
                }
            }
        }
        // We need to do this after the functions are registered...
        doLater = function () {
            // Intercept errors from functions called by Script.update and Script.ScriptEnding.
            [ 'teardown', 'startup', 'update', 'initPlatform', 'setupUI' ].forEach(function (fcn) {
                this[fcn] = catchErrors(this[fcn]);
            });
        };
        // These need to be wrapped first though:

        // Intercept errors from UI functions called by Controller.****Event.
        [ 'handleMousePress', 'handleMouseMove', 'handleMouseRelease' ].forEach(function (fcn) {
            UI[fcn] = catchErrors(UI[fcn]);
        });
    }

    function getTargetPlatformPosition () {
        var pos = MyAvatar.position;
        pos.y -= AVATAR_HEIGHT_OFFSET;
        return pos;
    }

    // Program state
    var platform = this.platform = null;
    var lastHeight = null;

    // Init
    this.initPlatform = function () {
        platform = new DynamicPlatform(NUM_PLATFORM_ENTITIES, getTargetPlatformPosition(), RADIUS);
        lastHeight = getTargetPlatformPosition().y;
    }

    // Handle relative screen positioning (UI)
    var lastDimensions = Controller.getViewportDimensions();
    function checkScreenDimensions () {
        var dimensions = Controller.getViewportDimensions();
        if (dimensions.x != lastDimensions.x || dimensions.y != lastDimensions.y) {
            onScreenResize(dimensions.x, dimensions.y);
        }
        lastDimensions = dimensions;
    }

    // Update
    this.update = function (dt) {
        checkScreenDimensions();
        var pos = getTargetPlatformPosition();
        platform.update(dt, getTargetPlatformPosition(), platform.getRadius());
    }

    // Teardown
    this.teardown = function () {
        try {
            platform.destroy();
            UI.teardown();

            Controller.mousePressEvent.disconnect(UI.handleMousePress);
            Controller.mouseMoveEvent.disconnect(UI.handleMouseMove);
            Controller.mouseReleaseEvent.disconnect(UI.handleMouseRelease);
        } catch (e) {
            logMessage("" + e, COLORS.RED);
        }
    }

    if (doLater) {
        doLater();
    }

    // Delays startup until / if entities can be spawned.
    this.startup = function (dt) {
        if (Entities.canAdjustLocks() && Entities.canRez()) {
            Script.update.disconnect(this.startup);

            function init () {
                logMessage("initializing...");
                
                this.initPlatform();

                Script.update.connect(this.update);
                Script.scriptEnding.connect(this.teardown);
    
                this.setupUI(platform);
    
                logMessage("finished initializing.", COLORS.GREEN);
            }
            if (CATCH_INIT_ERRORS) {
                try {
                    init();
                } catch (error) {
                    logMessage("" + error, COLORS.RED);
                }
            } else {
                init();
            }

            Controller.mousePressEvent.connect(UI.handleMousePress);
            Controller.mouseMoveEvent.connect(UI.handleMouseMove);
            Controller.mouseReleaseEvent.connect(UI.handleMouseRelease);
        } else {
            if (!startup.printedWarnMsg) {
                startup.timer = startup.timer || startup.ENTITY_SERVER_WAIT_TIME;
                if ((startup.timer -= dt) < 0.0) {
                    logMessage("Waiting for entity server");
                    startup.printedWarnMsg = true;
                }

            }
        }
    }
    startup.ENTITY_SERVER_WAIT_TIME = 0.2;   // print "waiting for entity server" if more than this time has elapsed in startup()

    Script.update.connect(this.startup);
})();

})();