foundry.js

/**
 * The global CONSTANTS object
 * @type {Object}
 */
const CONST = {
  vtt: "Foundry VTT",
  VTT: "Foundry Virtual Tabletop",
  WEBSITE_URL: "https://foundryvtt.com"
};

/**
 * Define the string name used for the base entity type when specific sub-types are not defined by the system
 * @type {String}
 */
CONST.BASE_ENTITY_TYPE = "base";

/**
 * Valid Chat Message types
 * @type {Object}
 */
CONST.CHAT_MESSAGE_TYPES = {
  OTHER: 0,
  OOC: 1,
  IC: 2,
  EMOTE: 3,
  WHISPER: 4,
  ROLL: 5
};

/**
 * The allowed Entity types which may exist within a Compendium pack
 * This is a subset of ENTITY_TYPES
 * @type {Array}
 */
CONST.COMPENDIUM_ENTITY_TYPES = ["Actor", "Item", "Scene", "JournalEntry", "RollTable", "Playlist"];

/**
 * The default artwork used for Token images if none is provided
 * @type {String}
 */
CONST.DEFAULT_TOKEN = 'icons/svg/mystery-man.svg';

/**
 * The default artwork used for Note placeables if none is provided
 * @type {String}
 */
CONST.DEFAULT_NOTE_ICON = 'icons/svg/book.svg';

/**
 * The supported dice roll visibility modes
 * @type {Object}
 */
CONST.DICE_ROLL_MODES = {
  PUBLIC: "roll",
  PRIVATE: "gmroll",
  BLIND: "blindroll",
  SELF: "selfroll"
};


/* -------------------------------------------- */


/**
 * The allowed Drawing types which may be saved
 * @type {Object}
 */
CONST.DRAWING_TYPES = {
  RECTANGLE: "r",
  ELLIPSE: "e",
  TEXT: "t",
  POLYGON: "p",
  FREEHAND: "f"
};

/**
 * The allowed fill types which a Drawing object may display
 * NONE: The drawing is not filled
 * SOLID: The drawing is filled with a solid color
 * PATTERN: The drawing is filled with a tiled image pattern
 * @type {Object}
 */
CONST.DRAWING_FILL_TYPES = {
  NONE: 0,
  SOLID: 1,
  PATTERN: 2
};


/**
 * The default configuration values used for Drawing objects
 * @type {Object}
 */
CONST.DRAWING_DEFAULT_VALUES = {
  width: 0,
  height: 0,
  rotation: 0,
  z: 0,
  hidden: false,
  locked: false,
  fillType: CONST.DRAWING_FILL_TYPES.NONE,
  fillAlpha: 0.5,
  bezierFactor: 0.1,
  strokeAlpha: 1.0,
  strokeWidth: 8,
  fontSize: 48,
  textAlpha: 1.0,
  textColor: "#FFFFFF"
};


/* -------------------------------------------- */



/**
 * Define the allowed Entity class types
 * @type {Array}
 */
CONST.ENTITY_TYPES = [
  "Actor",
  "ChatMessage",
  "Combat",
  "Item",
  "Folder",
  "JournalEntry",
  "Macro",
  "Playlist",
  "RollTable",
  "Scene",
  "User",
];

/**
 * EULA version number
 * @type {String}
 */
CONST.EULA_VERSION = "0.4.7";

/**
 * Define the allowed permission levels for a non-user Entity.
 * Each level is assigned a value in ascending order. Higher levels grant more permissions.
 * @type {Object}
 */
CONST.ENTITY_PERMISSIONS = {
  "NONE": 0,
  "LIMITED": 1,
  "OBSERVER": 2,
  "OWNER": 3
};

/**
 * The maximum allowed level of depth for Folder nesting
 * @type {Number}
 */
CONST.FOLDER_MAX_DEPTH = 3;

/**
 * Define the allowed Entity types which Folders may contain
 * @type {Array}
 */
CONST.FOLDER_ENTITY_TYPES = ["Actor", "Item", "Scene", "JournalEntry", "RollTable"];

/**
 * Define the allowed Entity types which may be dynamically linked in chat
 * @type {Array}
 */
CONST.ENTITY_LINK_TYPES = ["Actor", "Item", "Scene", "JournalEntry", "RollTable"];

/**
 * The allowed Grid types which are supported by the software
 * @type {Object}
 */
CONST.GRID_TYPES = {
  "GRIDLESS": 0,
  "SQUARE": 1,
  "HEXODDR": 2,
  "HEXEVENR": 3,
  "HEXODDQ": 4,
  "HEXEVENQ": 5
};

/**
 * The minimum allowed grid size which is supported by the software
 * @type {Number}
 */
CONST.GRID_MIN_SIZE = 50;


/**
 * An Array of valid MacroAction scope values
 * @type {Array.<string>}
 */
CONST.MACRO_SCOPES = ["global", "actors", "actor"];


/**
 * The allowed playback modes for an audio Playlist
 * DISABLED: The playlist does not play on its own, only individual Sound tracks played as a soundboard
 * SEQUENTIAL: The playlist plays sounds one at a time in sequence
 * SHUFFLE: The playlist plays sounds one at a time in randomized order
 * SIMULTANEOUS: The playlist plays all contained sounds at the same time
 * @type {Object}
 */
CONST.PLAYLIST_MODES = {
  "DISABLED": -1,
  "SEQUENTIAL": 0,
  "SHUFFLE": 1,
  "SIMULTANEOUS": 2
};


/**
 * Define the threshold version which packages must support as their minimumCoreVersion in order to be usable
 * @type {string}
 */
CONST.REQUIRED_PACKAGE_CORE_VERSION = "0.4.4";


/**
 * Encode the reasons why a package may be available or unavailable for use
 * @type {Object}
 */
CONST.PACKAGE_AVAILABILITY_CODES = {
  "AVAILABLE": 0,
  "REQUIRES_UPDATE": 1,
  "REQUIRES_SYSTEM": 2,
  "REQUIRES_DEPENDENCY": 3,
  "REQUIRES_CORE": 4
};

/**
 * A safe password string which can be displayed
 */
CONST.PASSWORD_SAFE_STRING = "•".repeat(16);


/**
 * The default sorting density for manually ordering child objects within a parent
 * @type {Number}
 */
CONST.SORT_INTEGER_DENSITY = 100000;

/**
 * The allowed types of a TableResult document
 * @type {Object}
 */
CONST.TABLE_RESULT_TYPES = {
  TEXT: 0,
  ENTITY: 1,
  COMPENDIUM: 2
};

/**
 * Define the valid anchor locations for a Tooltip displayed on a Placeable Object
 * @type {Object}
 */
CONST.TEXT_ANCHOR_POINTS = {
  CENTER: 0,
  BOTTOM: 1,
  TOP: 2,
  LEFT: 3,
  RIGHT: 4
};

/**
 * Describe the various thresholds of token control upon which to show certain pieces of information
 * NONE - no information is displayed
 * CONTROL - displayed when the token is controlled
 * OWNER HOVER - displayed when hovered by a GM or a user who owns the actor
 * HOVER - displayed when hovered by any user
 * OWNER - always displayed for a GM or for a user who owns the actor
 * ALWAYS - always displayed for everyone
 * @type {Object}
 */
CONST.TOKEN_DISPLAY_MODES = {
  "NONE": 0,
  "CONTROL": 10,
  "OWNER_HOVER": 20,
  "HOVER": 30,
  "OWNER": 40,
  "ALWAYS": 50
};

/**
 * The allowed Token disposition types
 * HOSTILE - Displayed as an enemy with a red border
 * NEUTRAL - Displayed as neutral with a yellow border
 * FRIENDLY - Displayed as an ally with a cyan border
 */
CONST.TOKEN_DISPOSITIONS = {
  "HOSTILE": -1,
  "NEUTRAL": 0,
  "FRIENDLY": 1
};

/**
 * Define the allowed User permission levels.
 * Each level is assigned a value in ascending order. Higher levels grant more permissions.
 * @type {Object}
 */
CONST.USER_ROLES = {
  "NONE": 0,
  "PLAYER": 1,
  "TRUSTED": 2,
  "ASSISTANT": 3,
  "GAMEMASTER": 4
};

/**
 * Invert the User Role mapping to recover role names from a role integer
 * @type {Object}
 */
CONST.USER_ROLE_NAMES = Object.entries(CONST.USER_ROLES).reduce((obj, r) => {
  obj[r[1]] = r[0];
  return obj;
}, {});


/**
 * Define the named actions which users or user roles can be permitted to do.
 * Each key of this Object denotes an action for which permission may be granted (true) or withheld (false)
 * @type {Object}
 */
CONST.USER_PERMISSIONS = [
  'BROADCAST_AUDIO',
  'BROADCAST_VIDEO',
  'ACTOR_CREATE',
  'DRAWING_CREATE',
  'ITEM_CREATE',
  'FILES_BROWSE',
  'FILES_UPLOAD',
  'MACRO_SCRIPT',
  'MESSAGE_WHISPER',
  'SETTINGS_MODIFY',
  'SHOW_CURSOR',
  'TOKEN_CREATE',
  'TOKEN_CONFIGURE',
  'WALL_DOORS'
];


/**
 * The allowed directions of effect that a Wall can have
 * BOTH: The wall collides from both directions
 * LEFT: The wall collides only when a ray strikes its left side
 * RIGHT: The wall collides only when a ray strikes its right side
 * @type {Object}
 */
CONST.WALL_DIRECTIONS = {
  BOTH: 0,
  LEFT: 1,
  RIGHT: 2
};

/**
 * The allowed door types which a Wall may contain
 * NONE: The wall does not contain a door
 * DOOR: The wall contains a regular door
 * SECRET: The wall contains a secret door
 * @type {Object}
 */
CONST.WALL_DOOR_TYPES = {
  NONE: 0,
  DOOR: 1,
  SECRET: 2
};

/**
 * The allowed door states which may describe a Wall that contains a door
 * CLOSED: The door is closed
 * OPEN: The door is open
 * LOCKED: The door is closed and locked
 * @type {Object}
 */
CONST.WALL_DOOR_STATES = {
  CLOSED: 0,
  OPEN: 1,
  LOCKED: 2
};

/**
 * The types of movement collision which a Wall may impose
 * NONE: Movement does not collide with this wall
 * NORMAL: Movement collides with this wall
 * @type {Object}
 */
CONST.WALL_MOVEMENT_TYPES = {
  NONE: 0,
  NORMAL: 1
};

/**
 * The types of sensory collision which a Wall may impose
 * NONE: Senses do not collide with this wall
 * NORMAL: Senses collide with this wall
 * LIMITED: Senses collide with the second intersection, bypassing the first
 * @type {Object}
 */
CONST.WALL_SENSE_TYPES = {
  NONE: 0,
  NORMAL: 1,
  LIMITED: 2
};

/**
 * The supported file extensions for image-type files
 * @type {Array}
 */
CONST.IMAGE_FILE_EXTENSIONS = ["jpg", "jpeg", "png", "svg", "webp"];

/**
 * The supported file extensions for video-type files
 * @type {Array}
 */
CONST.VIDEO_FILE_EXTENSIONS = ["mp4", "ogg", "webm"];

/**
 * The supported file extensions for audio-type files
 * @type {Array}
 */
CONST.AUDIO_FILE_EXTENSIONS = ["flac", "mp3", "ogg", "wav", "webm"];

// Freeze the CONST object to prevent changes
Object.freeze(CONST);

try {
module.exports = CONST;
} catch(err) {}

try {
window.CONST = CONST;
} catch(err) {}

/* -------------------------------------------- */
/*  Math Functions                              */
/* -------------------------------------------- */

Math.clamped = function(x, min, max) {
  return Math.min(max, Math.max(x, min));
};

Math.decimals = function(number, places) {
  let scl = Math.pow(10, places);
  return Math.round(number * scl) / scl;
};

toDegrees = function(angle) {
  return angle * (180 / Math.PI);
};

normalizeDegrees = function(degrees) {
  let nd = (degrees + 360) % 360;
  return (nd > 180) ? nd - 360 : nd;
};

toRadians = function(degree) {
  return (Math.round(degree) % 360) * (Math.PI / 180);
};

normalizeRadians = function(rad) {
  let pi2 = 2 * Math.PI;
  let nr = (rad + pi2) % pi2;
  return (nr > Math.PI) ? nr - pi2 : nr;
};

/* -------------------------------------------- */
/* String Methods                               */
/* -------------------------------------------- */


String.prototype.capitalize = function() {
  if ( !this.length ) return this;
  return this.charAt(0).toUpperCase() + this.slice(1);
};


String.prototype.titleCase = function() {
  if (!this.length) return this;
  return this.toLowerCase().split(' ').map(function (word) {
    return word.replace(word[0], word[0].toUpperCase());
  }).join(' ');
};


/**
 * Strip any <script> tags which were included within a provided string
 * @return {String|*}
 */
String.prototype.stripScripts = function() {
  let el = document.createElement("div");
  el.innerHTML = this;
  for ( let s of el.getElementsByTagName("script") ) {
    s.parentNode.removeChild(s);
  }
  return el.innerHTML;
};


/* -------------------------------------------- */


/**
 * Transform any string into a url-viable slug string
 * @param {string} replacement    The replacement character to separate terms, default is '-'
 * @param {boolean} strict        Replace all non-alphanumeric characters, or allow them? Default false
 * @return {string}               The cleaned slug string
 */
String.prototype.slugify = function({replacement='-', strict=false}={}) {

  // Map characters to lower case ASCII
  const charMap = JSON.parse('{"$":"dollar","%":"percent","&":"and","<":"less",">":"greater","|":"or","¢":"cent","£":"pound","¤":"currency","¥":"yen","©":"(c)","ª":"a","®":"(r)","º":"o","À":"A","Á":"A","Â":"A","Ã":"A","Ä":"A","Å":"A","Æ":"AE","Ç":"C","È":"E","É":"E","Ê":"E","Ë":"E","Ì":"I","Í":"I","Î":"I","Ï":"I","Ð":"D","Ñ":"N","Ò":"O","Ó":"O","Ô":"O","Õ":"O","Ö":"O","Ø":"O","Ù":"U","Ú":"U","Û":"U","Ü":"U","Ý":"Y","Þ":"TH","ß":"ss","à":"a","á":"a","â":"a","ã":"a","ä":"a","å":"a","æ":"ae","ç":"c","è":"e","é":"e","ê":"e","ë":"e","ì":"i","í":"i","î":"i","ï":"i","ð":"d","ñ":"n","ò":"o","ó":"o","ô":"o","õ":"o","ö":"o","ø":"o","ù":"u","ú":"u","û":"u","ü":"u","ý":"y","þ":"th","ÿ":"y","Ā":"A","ā":"a","Ă":"A","ă":"a","Ą":"A","ą":"a","Ć":"C","ć":"c","Č":"C","č":"c","Ď":"D","ď":"d","Đ":"DJ","đ":"dj","Ē":"E","ē":"e","Ė":"E","ė":"e","Ę":"e","ę":"e","Ě":"E","ě":"e","Ğ":"G","ğ":"g","Ģ":"G","ģ":"g","Ĩ":"I","ĩ":"i","Ī":"i","ī":"i","Į":"I","į":"i","İ":"I","ı":"i","Ķ":"k","ķ":"k","Ļ":"L","ļ":"l","Ľ":"L","ľ":"l","Ł":"L","ł":"l","Ń":"N","ń":"n","Ņ":"N","ņ":"n","Ň":"N","ň":"n","Ő":"O","ő":"o","Œ":"OE","œ":"oe","Ŕ":"R","ŕ":"r","Ř":"R","ř":"r","Ś":"S","ś":"s","Ş":"S","ş":"s","Š":"S","š":"s","Ţ":"T","ţ":"t","Ť":"T","ť":"t","Ũ":"U","ũ":"u","Ū":"u","ū":"u","Ů":"U","ů":"u","Ű":"U","ű":"u","Ų":"U","ų":"u","Ŵ":"W","ŵ":"w","Ŷ":"Y","ŷ":"y","Ÿ":"Y","Ź":"Z","ź":"z","Ż":"Z","ż":"z","Ž":"Z","ž":"z","ƒ":"f","Ơ":"O","ơ":"o","Ư":"U","ư":"u","Lj":"LJ","lj":"lj","Nj":"NJ","nj":"nj","Ș":"S","ș":"s","Ț":"T","ț":"t","˚":"o","Ά":"A","Έ":"E","Ή":"H","Ί":"I","Ό":"O","Ύ":"Y","Ώ":"W","ΐ":"i","Α":"A","Β":"B","Γ":"G","Δ":"D","Ε":"E","Ζ":"Z","Η":"H","Θ":"8","Ι":"I","Κ":"K","Λ":"L","Μ":"M","Ν":"N","Ξ":"3","Ο":"O","Π":"P","Ρ":"R","Σ":"S","Τ":"T","Υ":"Y","Φ":"F","Χ":"X","Ψ":"PS","Ω":"W","Ϊ":"I","Ϋ":"Y","ά":"a","έ":"e","ή":"h","ί":"i","ΰ":"y","α":"a","β":"b","γ":"g","δ":"d","ε":"e","ζ":"z","η":"h","θ":"8","ι":"i","κ":"k","λ":"l","μ":"m","ν":"n","ξ":"3","ο":"o","π":"p","ρ":"r","ς":"s","σ":"s","τ":"t","υ":"y","φ":"f","χ":"x","ψ":"ps","ω":"w","ϊ":"i","ϋ":"y","ό":"o","ύ":"y","ώ":"w","Ё":"Yo","Ђ":"DJ","Є":"Ye","І":"I","Ї":"Yi","Ј":"J","Љ":"LJ","Њ":"NJ","Ћ":"C","Џ":"DZ","А":"A","Б":"B","В":"V","Г":"G","Д":"D","Е":"E","Ж":"Zh","З":"Z","И":"I","Й":"J","К":"K","Л":"L","М":"M","Н":"N","О":"O","П":"P","Р":"R","С":"S","Т":"T","У":"U","Ф":"F","Х":"H","Ц":"C","Ч":"Ch","Ш":"Sh","Щ":"Sh","Ъ":"U","Ы":"Y","Ь":"","Э":"E","Ю":"Yu","Я":"Ya","а":"a","б":"b","в":"v","г":"g","д":"d","е":"e","ж":"zh","з":"z","и":"i","й":"j","к":"k","л":"l","м":"m","н":"n","о":"o","п":"p","р":"r","с":"s","т":"t","у":"u","ф":"f","х":"h","ц":"c","ч":"ch","ш":"sh","щ":"sh","ъ":"u","ы":"y","ь":"","э":"e","ю":"yu","я":"ya","ё":"yo","ђ":"dj","є":"ye","і":"i","ї":"yi","ј":"j","љ":"lj","њ":"nj","ћ":"c","ѝ":"u","џ":"dz","Ґ":"G","ґ":"g","Ғ":"GH","ғ":"gh","Қ":"KH","қ":"kh","Ң":"NG","ң":"ng","Ү":"UE","ү":"ue","Ұ":"U","ұ":"u","Һ":"H","һ":"h","Ә":"AE","ә":"ae","Ө":"OE","ө":"oe","฿":"baht","ა":"a","ბ":"b","გ":"g","დ":"d","ე":"e","ვ":"v","ზ":"z","თ":"t","ი":"i","კ":"k","ლ":"l","მ":"m","ნ":"n","ო":"o","პ":"p","ჟ":"zh","რ":"r","ს":"s","ტ":"t","უ":"u","ფ":"f","ქ":"k","ღ":"gh","ყ":"q","შ":"sh","ჩ":"ch","ც":"ts","ძ":"dz","წ":"ts","ჭ":"ch","ხ":"kh","ჯ":"j","ჰ":"h","Ẁ":"W","ẁ":"w","Ẃ":"W","ẃ":"w","Ẅ":"W","ẅ":"w","ẞ":"SS","Ạ":"A","ạ":"a","Ả":"A","ả":"a","Ấ":"A","ấ":"a","Ầ":"A","ầ":"a","Ẩ":"A","ẩ":"a","Ẫ":"A","ẫ":"a","Ậ":"A","ậ":"a","Ắ":"A","ắ":"a","Ằ":"A","ằ":"a","Ẳ":"A","ẳ":"a","Ẵ":"A","ẵ":"a","Ặ":"A","ặ":"a","Ẹ":"E","ẹ":"e","Ẻ":"E","ẻ":"e","Ẽ":"E","ẽ":"e","Ế":"E","ế":"e","Ề":"E","ề":"e","Ể":"E","ể":"e","Ễ":"E","ễ":"e","Ệ":"E","ệ":"e","Ỉ":"I","ỉ":"i","Ị":"I","ị":"i","Ọ":"O","ọ":"o","Ỏ":"O","ỏ":"o","Ố":"O","ố":"o","Ồ":"O","ồ":"o","Ổ":"O","ổ":"o","Ỗ":"O","ỗ":"o","Ộ":"O","ộ":"o","Ớ":"O","ớ":"o","Ờ":"O","ờ":"o","Ở":"O","ở":"o","Ỡ":"O","ỡ":"o","Ợ":"O","ợ":"o","Ụ":"U","ụ":"u","Ủ":"U","ủ":"u","Ứ":"U","ứ":"u","Ừ":"U","ừ":"u","Ử":"U","ử":"u","Ữ":"U","ữ":"u","Ự":"U","ự":"u","Ỳ":"Y","ỳ":"y","Ỵ":"Y","ỵ":"y","Ỷ":"Y","ỷ":"y","Ỹ":"Y","ỹ":"y","‘":"\'","’":"\'","“":"\\\"","”":"\\\"","†":"+","•":"*","…":"...","₠":"ecu","₢":"cruzeiro","₣":"french franc","₤":"lira","₥":"mill","₦":"naira","₧":"peseta","₨":"rupee","₩":"won","₪":"new shequel","₫":"dong","€":"euro","₭":"kip","₮":"tugrik","₯":"drachma","₰":"penny","₱":"peso","₲":"guarani","₳":"austral","₴":"hryvnia","₵":"cedi","₸":"kazakhstani tenge","₹":"indian rupee","₽":"russian ruble","₿":"bitcoin","℠":"sm","™":"tm","∂":"d","∆":"delta","∑":"sum","∞":"infinity","♥":"love","元":"yuan","円":"yen","﷼":"rial"}');
  let slug = this.split("").reduce((result, char) => {
    return result + (charMap[char] || char);
  }, "").trim().toLowerCase();

  // Convert any spaces to the replacement character and de-dupe
  slug = slug.replace(new RegExp('[\\s' + replacement + ']+', 'g'), replacement);

  // If we're being strict, replace anything that is not alphanumeric
  if (strict) {
    slug = slug.replace(new RegExp('[^a-zA-Z0-9' + replacement + ']', 'g'), '');
  }
  return slug;
};


/* -------------------------------------------- */
/* Number Methods                               */
/* -------------------------------------------- */


Number.prototype.ordinalString = function() {
  let s=["th","st","nd","rd"],
    v=this%100;
  return this+(s[(v-20)%10]||s[v]||s[0]);
};


Number.prototype.paddedString = function(digits) {
  let s = "000000000" + this;
  return s.substr(s.length-digits);
};


Number.prototype.signedString = function() {
  return (( this < 0 ) ? "" : "+") + this;
};


Number.prototype.between = function(a, b, inclusive=true) {
  let min = Math.min(a, b);
  let max = Math.max(a, b);
  return inclusive ? (this >= min) && (this <= max) : (this > min) && (this < max);
};


/**
 * A faster numeric between check which avoids type coercion to the Number object
 * Since this avoids coercion, if non-numbers are passed in unpredictable results will occur. Use with caution.
 * @param {number} num
 * @param {number} a
 * @param {number} b
 * @param {boolean} inclusive
 * @return {boolean}
 */
Number.between = function(num, a, b, inclusive=true) {
  let min = Math.min(a, b);
  let max = Math.max(a, b);
  return inclusive ? (num >= min) && (num <= max) : (num > min) && (num < max);
};


/**
 * Test whether a value is numeric
 * This is the highest performing algorithm currently available
 * https://jsperf.com/isnan-vs-typeof/5
 * @param {*} n       A value to test
 * @return {Boolean}  Is it a number?
 */
Number.isNumeric = function(n) {
  if ( n instanceof Array ) return false;
  else if ( [null, ""].includes(n) ) return false;
  return +n === +n;
};



/* -------------------------------------------- */
/* Array Methods                                */
/* -------------------------------------------- */


Array.fromRange = function(n) {
  return Array.from(new Array(parseInt(n)).keys());
};


Array.prototype.deepFlatten = function() {
  return this.reduce((acc, val) => Array.isArray(val) ? acc.concat(val.deepFlatten()) : acc.concat(val), []);
};


/**
 * Test equality of the values of this array against the values of some other Array
 * @param {Array} other
 */
Array.prototype.equals = function(other) {
  if ( !(other instanceof Array) || (other.length !== this.length) ) return false;
  return this.every((v, i) => other[i] === v);
};


/**
 * Partition an original array into two children array based on a logical test
 * Elements which test as false go into the first result while elements testing as true appear in the second
 * @param rule {Function}
 * @return {Array}    An Array of length two whose elements are the partitioned pieces of the original
 */
Array.prototype.partition = function(rule) {
  return this.reduce((acc, val) => {
    let test = rule(val);
    acc[Number(test)].push(val);
    return acc;
  }, [[], []]);
};

/**
 * Join an Array using a string separator, first filtering out any parts which return a false-y value
 * @param {string} sep    The separator string
 * @return {string}       The joined string, filtered of any false values
 */
Array.prototype.filterJoin = function(sep) {
  return this.filter(p => !!p).join(sep);
};


/**
 * Find an element within the Array and remove it from the array
 * @param {Function} find   A function to use as input to findIndex
 * @return {*|null}         The removed item or null if none was found
 */
Array.prototype.findSplice = function(find) {
  const idx = this.findIndex(find);
  if ( idx === -1 ) return null;
  const item = this[idx];
  this.splice(idx, 1);
  return item;
};


/* -------------------------------------------- */
/* Object Methods                               */
/* -------------------------------------------- */


/**
 * A cheap data duplication trick, surprisingly relatively performant
 * @param {Object} original   Some sort of data
 */
function duplicate(original) {
  return JSON.parse(JSON.stringify(original));
}

/* -------------------------------------------- */

/**
 * Learn the named type of a token - extending the functionality of typeof to recognize some core Object types
 * @param {*} token     Some passed token
 * @return {string}     The named type of the token
 */
function getType(token) {
  const tof = typeof token;
  if ( tof === "object" ) {
    if ( token === null ) return "null";
    let cn = token.constructor.name;
    if ( ["String", "Number", "Boolean", "Array", "Set"].includes(cn)) return cn;
    else return "Object";
  }
  return tof;
}

/* -------------------------------------------- */

/**
 * A temporary shim to invert an object, flipping keys and values
 * @param {Object} obj    Some object where the values are unique
 * @return {Object}       An inverted object where the values of the original object are the keys of the new object
 */
function invertObject(obj) {
	return Object.entries(obj).reduce((inverted, entry) => {
		let [k, v] = entry;
		inverted[v] = k;
		return inverted;
	}, {})
}


/* -------------------------------------------- */


/**
 * Filter the contents of some source object using the structure of a template object.
 * Only keys which exist in the template are preserved in the source object.
 *
 * @param {Object} source           An object which contains the data you wish to filter
 * @param {Object} template         An object which contains the structure you wish to preserve
 * @param {boolean} keepSpecial     Whether to keep special tokens like deletion keys
 * @param {boolean} templateValues  Instead of keeping values from the source, instead draw values from the template
 *
 * @example
 * const source = {foo: {number: 1, name: "Tim", topping: "olives"}, bar: "baz"};
 * const template = {foo: {number: 0, name: "Mit", style: "bold"}, other: 72};
 * filterObject(source, template); // {foo: {number: 1, name: "Tim"}};
 * filterObject(source, template, {templateValues: true}); // {foo: {number: 0, name: "Mit"}};
 */
function filterObject(source, template, {keepSpecial=false, templateValues=false}={}) {

  // Validate input
  const ts = getType(source);
  const tt = getType(template);
  if ( (ts !== "Object") || (tt !== "Object")) throw new Error("One of source or template are not Objects!");

  // Define recursive filtering function
  const _filter = function(s, t, filtered) {
    for ( let [k, v] of Object.entries(s) ) {
      let has = t.hasOwnProperty(k);
      let x = t[k];

      // Case 1 - inner object
      if ( has && (getType(v) === "Object") && (getType(x) === "Object") ) {
        filtered[k] = _filter(v, x, {});
      }

      // Case 2 - inner key
      else if ( has ) {
        filtered[k] = templateValues ? x : v;
      }

      // Case 3 - special key
      else if ( keepSpecial && k.startsWith("-=") ) {
        filtered[k] = v;
      }
    }
    return filtered;
  };

  // Begin filtering at the outer-most layer
  return _filter(source, template, {});
}


/* -------------------------------------------- */


/**
 * Flatten a possibly multi-dimensional object to a one-dimensional one by converting all nested keys to dot notation
 * @param {Object} obj  The object to flatten
 * @param {Number} _d   Recursion depth, to prevent overflow
 * @return {Object}     A flattened object
 */
function flattenObject(obj, _d=0) {
  const flat = {};
  if ( _d > 10 ) throw new Error("Maximum depth exceeded");
  for ( let [k, v] of Object.entries(obj) ) {
    let t = getType(v);

    // Inner objects
    if ( t === "Object" ) {
      if ( isObjectEmpty(v) ) flat[k] = v;
      let inner = flattenObject(v, _d+1);
      for ( let [ik, iv] of Object.entries(inner) ) {
        flat[`${k}.${ik}`] = iv;
      }
    }

    // Inner values
    else flat[k] = v;
  }
  return flat;
}


/* -------------------------------------------- */


/**
 * Expand a flattened object to be a standard multi-dimensional nested Object by converting all dot-notation keys to
 * inner objects.
 *
 * @param {Object} obj  The object to expand
 * @param {Number} _d   Recursion depth, to prevent overflow
 * @return {Object}     An expanded object
 */
function expandObject(obj, _d=0) {
  const expanded = {};
  if ( _d > 10 ) throw new Error("Maximum depth exceeded");
  for ( let [k, v] of Object.entries(obj) ) {
    if ( v instanceof Object && !Array.isArray(v) ) v = expandObject(v, _d+1);
    setProperty(expanded, k, v);
  }
  return expanded;
}


/* -------------------------------------------- */

/**
 * A simple function to test whether or not an Object is empty
 * @param {Object} obj    The object to test
 * @return {Boolean}      Is the object empty?
 */
function isObjectEmpty(obj) {
  if ( getType(obj) !== "Object" ) throw new Error("The provided data is not an object!");
  return Object.keys(obj).length === 0;
}

/* -------------------------------------------- */


/**
 * Update a source object by replacing its keys and values with those from a target object.
 *
 * @param {Object} original     The initial object which should be updated with values from the target
 * @param {Object} other        A new object whose values should replace those in the source
 * @param {boolean} insert      Control whether to insert new parent objects in the structure which did not previously
 *                              exist in the source object.
 * @param {boolean} overwrite   Control whether to replace existing values in the source, or only merge values which
 *                              do not exist in the source.
 * @param {boolean} inplace     Update the values of original inplace? Otherwise duplicate the original and return a
 *                              safe copy.
 * @param {boolean} enforceTypes  Enforce that the type of an inner value in the source object match the type of the
 *                              new value. Default is false for now, but should be true in the future.
 * @param {number} _d           A privately used parameter to track recursion depth
 *
 * @returns {Object}            The original source object including updated, inserted, or overwritten records.
 */
function mergeObject(original, other={}, {
  insertKeys=true, insertValues=true, overwrite=true, inplace=true, enforceTypes=false
  }={}, _d=0) {
  other = other || {};
  if (!(original instanceof Object) || !(other instanceof Object)) {
    throw new Error("One of original or other are not Objects!");
  }
  let depth = _d + 1;

  // Maybe copy the original data at depth 0
  if ( !inplace && (_d === 0) ) original = duplicate(original);

  // Enforce object expansion at depth 0
  if ( (_d === 0 ) && Object.keys(original).some(k => /\./.test(k)) ) original = expandObject(original);
  if ( (_d === 0 ) && Object.keys(other).some(k => /\./.test(k)) ) other = expandObject(other);

  // Iterate over the other object
  for ( let [k, v] of Object.entries(other) ) {
    let tv = getType(v);

    // Prepare to delete
    let toDelete = false;
    if ( k.startsWith("-=") ) {
      k = k.slice(2);
      toDelete = (v === null)
    }

    // Get the existing object
    let x = original[k];
    let has = original.hasOwnProperty(k);
    let tx = getType(x);

    // Ensure that inner objects exist
    if ( !has && (tv === "Object") ) {
      x = original[k] = {};
      has = true;
      tx = "Object";
    }

    // Case 1 - Key exists
    if (has) {

      // 1.1 - Recursively merge an inner object
      if ( (tv === "Object") && (tx === "Object") ) {
        mergeObject(x, v, {
          insertKeys: insertKeys,
          insertValues: insertValues,
          overwrite: overwrite,
          inplace: true,
          enforceTypes: enforceTypes
        }, depth);
      }

      // 1.2 - Remove an existing key
      else if ( toDelete ) {
        delete original[k];
      }

      // 1.3 - Overwrite existing value
      else if ( overwrite ) {
        if ( tx && (tv !== tx) && enforceTypes ) {
          throw new Error(`Mismatched data types encountered during object merge.`);
        }
        original[k] = v;
      }

      // 1.4 - Insert new value
      else if ( (x === undefined) && insertValues ) {
        original[k] = v;
      }
    }

    // Case 2 - Key does not exist
    else if ( !toDelete ) {
      let canInsert = (depth === 1 && insertKeys ) || ( depth > 1 && insertValues );
      if (canInsert) original[k] = v;
    }
  }

  // Return the object for use
  return original;
}


/* -------------------------------------------- */


/**
 * Deeply difference an object against some other, returning the update keys and values
 * @param {Object} original
 * @param {Object} other
 * @return {Object}
 */
function diffObject(original, other) {
  function _difference(v0, v1) {
    let t0 = getType(v0);
    let t1 = getType(v1);
    if ( t0 !== t1 ) return [true, v1];
    if ( t0 === "Array" ) return [!v0.equals(v1), v1];
    if ( t0 === "Object" ) {
      if ( isObjectEmpty(v0) !== isObjectEmpty(v1) ) return [true, v1];
      let d = diffObject(v0, v1);
      return [!isObjectEmpty(d), d];
    }
    return [v0 !== v1, v1];
  }

  // Recursively call the _difference function
  return Object.keys(other).reduce((obj, key) => {
    let [isDifferent, difference] = _difference(original[key], other[key]);
    if ( isDifferent ) obj[key] = difference;
    return obj;
  }, {});
}


/* -------------------------------------------- */


/**
 * A helper function which tests whether an object has a property or nested property given a string key.
 * The string key supports the notation a.b.c which would return true if object[a][b][c] exists
 * @param object {Object}   The object to traverse
 * @param key {String}      An object property with notation a.b.c
 *
 * @return {Boolean}        An indicator for whether the property exists
 */
function hasProperty(object, key) {
  if ( !key ) return false;
  let target = object;
  for ( let p of key.split('.') ) {
    target = target || {};
    if ( p in target ) target = target[p];
    else return false;
  }
  return true;
}


/* -------------------------------------------- */


/**
 * A helper function which searches through an object to retrieve a value by a string key.
 * The string key supports the notation a.b.c which would return object[a][b][c]
 * @param object {Object}   The object to traverse
 * @param key {String}      An object property with notation a.b.c
 *
 * @return {*}              The value of the found property
 */
function getProperty(object, key) {
  if ( !key ) return undefined;
  let target = object;
  for ( let p of key.split('.') ) {
    target = target || {};
    if ( p in target ) target = target[p];
    else return undefined;
  }
  return target;
}


/* -------------------------------------------- */


/**
 * A helper function which searches through an object to assign a value using a string key
 * This string key supports the notation a.b.c which would target object[a][b][c]
 *
 * @param object {Object}   The object to update
 * @param key {String}      The string key
 * @param value             The value to be assigned
 *
 * @return {Boolean}        A flag for whether or not the object was updated
 */
function setProperty(object, key, value) {
  let target = object;
  let changed = false;

  // Convert the key to an object reference if it contains dot notation
  if ( key.indexOf('.') !== -1 ) {
    let parts = key.split('.');
    key = parts.pop();
    target = parts.reduce((o, i) => {
      if ( !o.hasOwnProperty(i) ) o[i] = {};
      return o[i];
    }, object);
  }

  // Update the target
  if ( target[key] !== value ) {
    changed = true;
    target[key] = value;
  }

  // Return changed status
  return changed;
}


/* -------------------------------------------- */
/*  Form Methods                                */
/* -------------------------------------------- */


RegExp.escape= function(string) {
    return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
};


/* -------------------------------------------- */


validateForm = function(formElement) {
  const form = new FormData(formElement);
  const formData = {};

  // Always include Boolean checkboxes
  for ( let box of formElement.querySelectorAll('input[type="checkbox"]') ) {
    if ( box.disabled ) continue;
    formData[box.name] = Boolean(box.checked) || false;
  }

  // Grab images which are editable
  for ( let img of formElement.querySelectorAll('img[data-edit]') ) {
    if ( img.getAttribute("disabled") ) continue;
    formData[img.dataset.edit] = img.src.replace(window.location.origin+"/", "");
  }

  // Grab divs which are editable
  for ( let div of formElement.querySelectorAll('div[data-edit]') ) {
    if ( div.getAttribute("disabled") ) continue;
    formData[div.dataset.edit] = div.innerHTML.trim();
  }

  // Iterate over form elements, validating and converting type
  form.forEach((v, k) => {
    let input = formElement[k];

    // Skip checkboxes which have already been handled
    if ( input.type === "checkbox" ) return;

    // Skip fields which are set as disabled
    if ( input.disabled ) return;

    // Cast the input to a specific dtype
    let dtype = input.dataset.dtype || "String";
    if ( (dtype !== "String") && (v !== "") ) {
      formData[k] = window[dtype] instanceof Function ? window[dtype](v) : v;
    }
    else formData[k] = v;
  });
  return formData;
};


/* -------------------------------------------- */
/*  URL Manipulation                            */
/* -------------------------------------------- */


/**
 * Encode a url-like string by replacing any characters which need encoding
 * @param {string} path     A fully-qualified URL or url component (like a relative path)
 * @return {string}         An encoded URL string
 */
function encodeURL(path) {

  // Determine whether the path is a well-formed URL
  let url = null;
  try {
    url = new URL(path);
  } catch(err) {}

  // If URL, remove the initial protocol
  if ( url ) path = path.replace(url.protocol, "");

  // Split and encode each URL part
  path = path.split("/").map(p => encodeURIComponent(p).replace(/\'/g, "%27")).join("/");

  // Return the encoded URL
  return url ? url.protocol + path : path;
}


/* -------------------------------------------- */
/*  Datetime Manipulation
/* -------------------------------------------- */


/**
 * Express a timestamp as a relative string
 * @param timeStamp {Date}
 * @return {string}
 */
timeSince = function(timeStamp) {
  timeStamp = new Date(timeStamp);
  let now = new Date(),
      secondsPast = (now - timeStamp) / 1000,
      since = "";

  // Format the time
  if (secondsPast < 60) {
    since = parseInt(secondsPast);
    if ( since <= 0 ) return "Now";
    else since = since + "s";
  }
  else if (secondsPast < 3600) since = parseInt(secondsPast/60) + 'm';
  else if (secondsPast <= 86400) since = parseInt(secondsPast/3600) + 'h';
  else {
    let hours = parseInt(secondsPast/3600),
        days = parseInt(hours/24);
    since = `${days}d ${hours % 24}h`;
  }

  // Return the string
  return since + " ago";
};


/* -------------------------------------------- */
/*  Colors
/* -------------------------------------------- */

/**
 * Converts an RGB color value to HSV. Conversion formula
 * adapted from http://en.wikipedia.org/wiki/HSV_color_space.
 * Assumes r, g, and b are contained in the set [0, 1] and
 * returns h, s, and v in the set [0, 1].
 *
 * @param   Number  r       The red color value
 * @param   Number  g       The green color value
 * @param   Number  b       The blue color value
 * @return  Array           The HSV representation
 */
function rgbToHsv(r, g, b) {
  let max = Math.max(r, g, b), min = Math.min(r, g, b);
  let h, s, v = max;
  let d = max - min;
  s = max == 0 ? 0 : d / max;
  if (max == min) {
    h = 0; // achromatic
  } else {
    switch (max) {
      case r: h = (g - b) / d + (g < b ? 6 : 0); break;
      case g: h = (b - r) / d + 2; break;
      case b: h = (r - g) / d + 4; break;
    }
    h /= 6;
  }
  return [h, s, v];
}

/* -------------------------------------------- */

/**
 * Converts an HSV color value to RGB. Conversion formula
 * adapted from http://en.wikipedia.org/wiki/HSV_color_space.
 * Assumes h, s, and v are contained in the set [0, 1] and
 * returns r, g, and b in the set [0, 1].
 *
 * @param   Number  h       The hue
 * @param   Number  s       The saturation
 * @param   Number  v       The value
 * @return  Array           The RGB representation
 */
function hsvToRgb(h, s, v) {
  let r, g, b;
  let i = Math.floor(h * 6);
  let f = h * 6 - i;
  let p = v * (1 - s);
  let q = v * (1 - f * s);
  let t = v * (1 - (1 - f) * s);
  switch (i % 6) {
    case 0: r = v, g = t, b = p; break;
    case 1: r = q, g = v, b = p; break;
    case 2: r = p, g = v, b = t; break;
    case 3: r = p, g = q, b = v; break;
    case 4: r = t, g = p, b = v; break;
    case 5: r = v, g = p, b = q; break;
  }
  return [r, g, b];
}

/**
 * Converts a color as an [R, G, B] array of normalized floats to a hexadecimal number.
 * @param {Array.<Number>} rgb - Array of numbers where all values are normalized floats from 0.0 to 1.0.
 * @return {Number} Number in hexadecimal.
 */
function rgbToHex(rgb) {
  return (((rgb[0] * 255) << 16) + ((rgb[1] * 255) << 8) + (rgb[2] * 255 | 0));
}

/* ----------------------------------------- */

/**
 * Convert a hex color code to an RGB array
 * @param {number} hex    A hex color number
 * @return {Array}        An array of [r,g,b] colors normalized on the range of [0,1]
 */
function hexToRGB(hex) {
  return [
    ((hex >> 16) & 0xFF) / 255,
    ((hex >> 8) & 0xFF) / 255,
    (hex & 0xFF) / 255
  ];
}

/* ----------------------------------------- */

/**
 * Convert a hex color code to an RGBA color string which can be used for CSS styling
 * @param {number} hex    A hex color number
 * @param {number} alpha  A level of transparency
 * @return {string}       An rgba style string
 */
function hexToRGBAString(hex, alpha=1.0) {
  const rgb = hexToRGB(hex).map(h => h * 255);
  rgb.push(alpha);
  return `rgba(${rgb.join(", ")})`;
}

/* ----------------------------------------- */

/**
 * Convert a string color to a hex integer
 * @param {String} color    The string color
 * @return {Number}         The hexidecimal color code
 */
function colorStringToHex(color) {
  if ( !color ) return null;
  color = color.startsWith("#") ? color.substr(1) : color;
  return parseInt(color, 16);
}


/* -------------------------------------------- */
/*  Version Checking
/* -------------------------------------------- */

/**
 * Return whether or not a version (v1) is more advanced than some other version (v0)
 * Supports numeric or string version numbers
 * @param {Number|String} v0
 * @param {Number|String} v1
 * @return {Boolean}
 */
function isNewerVersion(v1, v0) {

  // Handle numeric versions
  if ( (typeof v1 === "number") && (typeof v0 === "number") ) return v1 > v0;

  // Handle string parts
  let v1Parts = String(v1).split(".");
  let v0Parts = String(v0).split(".");

  // Iterate over version parts
  for ( let [i, p1] of v1Parts.entries() ) {
    let p0 = v0Parts[i];

    // If the prior version doesn't have a part, v1 wins
    if ( p0 === undefined ) return true;

    // If both parts are numbers, use numeric comparison to avoid cases like "12" < "5"
    if ( Number.isNumeric(p0) && Number.isNumeric(p1) ) {
      if ( Number(p1) !== Number(p0) ) return Number(p1) > Number(p0);
    }

    // Otherwise, compare as strings
    else if ( p1 < p0 ) return false;
  }

  // If there are additional parts to v0, it is not newer
  if ( v0Parts.length > v1Parts.length ) return false;

  // If we have not returned false by now, its either newer or the same
  return !v1Parts.equals(v0Parts);
}

/* -------------------------------------------- */

/**
 * Generate a random ID
 * Generate random number and convert it to base 36 and remove the '0.' at the beginning
 * As long as the string is not long enough, generate more random data into it
 * Use substring in case we generated a string with a length higher than the requested length
 *
 * @param {number} length    The length of the random ID to generate
 * @return {string}          Return a string containing random letters and numbers
 */
function randomID(length=10) {
  const rnd = () => Math.random().toString(36).substr(2);
  let id = "";
  while (id.length < length)
    id += rnd();
  return id.substr(0, length);
}


try {
  module.exports = {
    duplicate,
    diffObject,
    filterObject,
    flattenObject,
    encodeURL,
    expandObject,
    invertObject,
    isObjectEmpty,
    mergeObject,
    hasProperty,
    getProperty,
    setProperty,
    validateForm,
    hsvToRgb,
    rgbToHsv,
    rgbToHex,
    colorStringToHex,
    isNewerVersion,
    randomID
  };
} catch(err) {}


/* -------------------------------------------- */

/**
 * A helper class to provide common functionality for working with HTML5 audio and Howler instances
 * A singleton instance of this class is available as ``game.audio``
 *
 * Audio playback in Foundry VTT is managed by Howler.js (https://howlerjs.com/). Several methods and
 * attributes in this API return :class:`Howl` instances. See the Howler documentation for details
 * and example usage of the Howl API.
 */
class AudioHelper {
  constructor() {
    if ( game.audio instanceof this.constructor ) {
      throw new Error("You may not re-initialize the singleton AudioHelper. Use game.audio instead.");
    }

    /**
     * The set of Howl instances which have been created for different audio paths
     * @type {Object}
     */
    this.sounds = {};

    /**
     * A user gesture must be registered before audio can be played.
     * This Array contains the Howl instances which are requested for playback prior to a gesture.
     * Once a gesture is observed, we begin playing all elements of this Array.
     * @type {Array}
     */
    this.pending = [];

    /**
     * A flag for whether video playback is currently locked by awaiting a user gesture
     * @type {Boolean}
     */
    this.locked = true;

    /**
     * Audio Context singleton used for analysing audio levels of each stream
     * Only created if necessary to listen to audio streams.
     *
     * @type {AudioContext}
     */
    this._audioContext = null;

    /**
     * Map of all streams that we listen to for determining the decibel levels.
     * Used for analyzing audio levels of each stream.
     * Format of the object stored is :
     * {id:
     *   {
     *     stream: MediaStream,
     *     analyser: AudioAnalyser,
     *     interval: Number,
     *     callback: Function
     *   }
     * }
     *
     * @type {Object}
     * @private
     */
    this._analyserStreams = {};

    /**
     * Interval ID as returned by setInterval for analysing the volume of streams
     * When set to 0, means no timer is set.
     * @type {number}
     * @private
     */
    this._analyserInterval = 0;

    /**
     * Fast Fourrier Transform Array.
     * Used for analysing the decibel level of streams. The array is allocated only once
     * then filled by the analyser repeatedly. We only generate it when we need to listen to
     * a stream's level, so we initialize it to null.
     * @type {Float32Array}
     * @private
     */
    this._fftArray = null;
  }

  /* -------------------------------------------- */
  
  /**
   * Register client-level settings for global volume overrides
   */
  static registerSettings() {

    // Playlist Volume
    game.settings.register("core", "globalPlaylistVolume", {
      name: "Global Playlist Volume",
      hint: "Define a global playlist volume modifier",
      scope: "client",
      config: false,
      default: 1.0,
      type: Number,
      onChange: volume => {
        for ( let p of game.playlists.entities ) {
          p.sounds.filter(s => s.playing).forEach(s => p.playSound(s));
        }
      }
    });

    // Ambient Volume
    game.settings.register("core", "globalAmbientVolume", {
      name: "Global Ambient Volume",
      hint: "Define a global ambient volume modifier",
      scope: "client",
      config: false,
      default: 1.0,
      type: Number,
      onChange: volume => {
        if ( canvas.ready ) {
          if ( canvas.background.isVideo ) canvas.background.source.volume = volume;
          canvas.sounds.update();
        }
      }
    });

    // UI Volume
    game.settings.register("core", "globalInterfaceVolume", {
      name: "Global Interface Volume",
      hint: "Define a global interface volume modifier",
      scope: "client",
      config: false,
      default: 0.5,
      type: Number
    });
  }

  /* -------------------------------------------- */

  /**
   * Create a Howl instance
   * @param src
   * @param preload
   * @param autoplay
   * @return {Howl}
   */
  create({src, preload=false, autoplay=false, volume=0.0, loop=false} = {}) {

    // Return an existing howl if one already exists for the source
    if ( src in this.sounds ) {
      return this.sounds[src].howl;
    }

    // Create the Howl instance
    let howl = new Howl({
      src: src,
      preload: preload,
      autoplay: autoplay,
      volume: volume,
      loop: loop,
      html5: true,
      onload: () => this.sounds[src].loaded = true,
      onplay: id => this.sounds[src].ids.push(id)
    });

    // Record the Howl instance for later use
    this.sounds[src] = {
      howl: howl,
      loaded: false,
      ids: []
    };
    return howl;
  }

  /* -------------------------------------------- */

  /**
   * Play a single audio effect by it's source path and Howl ID
   * @param {String} src
   * @param {Number} id
   */
  play(src, id) {
    let howl = this.sounds[src];
    if ( !howl ) throw new Error("Howl instance does not exist for sound " + src);
    howl.play(id);
  }

  /* -------------------------------------------- */


  /**
   * Register an event listener to await the first mousemove gesture and begin playback once observed
   */
  awaitFirstGesture() {
    if ( !this.locked ) return;
    const interactions = ['contextmenu', 'auxclick', 'mousedown', 'mouseup', 'keydown'];
    interactions.forEach(event => document.addEventListener(event, this._onFirstGesture.bind(this), {once: true}));
  }

  /* -------------------------------------------- */

  /**
   * Handle the first observed user gesture
   * @param {Event} event   The mouse-move event which enables playback
   */
  _onFirstGesture(event) {
    if ( !this.pending.length ) return;
    console.log(`${vtt} | Activating pending audio playback with user gesture.`);
    this.locked = false;
    this.pending.forEach(fn => fn());
    this.pending = [];
  }

  /* -------------------------------------------- */

  preload(data) {
    game.socket.emit("preloadAudio", data);
    this.constructor.preload(data);
  }


  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /**
   * Open socket listeners which transact ChatMessage data
   * @private
   */
  static socketListeners(socket) {
    socket.on('playAudio', this.play);
    socket.on('preloadAudio', this.preload);
  }

  /* -------------------------------------------- */

  /**
   * Play a one-off sound effect which is not part of a Playlist
   *
   * @param {Object} data           An object configuring the audio data to play
   * @param {String} data.src       The audio source file path, either a public URL or a local path relative to the public directory
   * @param {Number} data.volume    The volume level at which to play the audio, between 0 and 1.
   * @param {Boolean} data.autoplay Begin playback of the audio effect immediately once it is loaded.
   * @param {Boolean} data.loop     Loop the audio effect and continue playing it until it is manually stopped.
   * @param {Boolean} [push]        Push the audio sound effect to other connected clients?
   *
   * @return {Howl}                 A Howl instance which controls audio playback.
   *
   * @example
   * // Play the sound of a locked door for all players
   * Audio.play({src: "sounds/lock.wav", volume: 0.8, autoplay: true, loop: false}, true);
   */
  static play(data, push=false) {
    let audioData = mergeObject({src: null, volume: 1.0, autoplay: true, loop: false}, data, {insertKeys: true});
    audioData.volume *= game.settings.get("core", "globalInterfaceVolume");
    if ( push ) game.socket.emit("playAudio", audioData);
    new Howl(audioData);
  }

  /* -------------------------------------------- */

  /**
   * Create a Howl object and load it to be ready for later playback
   * @param {Object} data         The audio data to preload
   */
  static preload(data) {
    game.audio.create({
      src: data.path,
      autoplay: false,
      preload: true
    }).load();
  }

  /* -------------------------------------------- */

  /**
   * Returns the volume value based on a range input volume control's position.
   * This is using an exponential approximation of the logarithmic nature of audio level perception
   * Based on https://www.dr-lex.be/info-stuff/volumecontrols.html
   * We're using x^3 by default instead of x^4 otherwise the audio becomes nearly silent around the 40% mark.
   * @param {number|string} value   Value between [0, 1] of the range input
   * @param {number} order          [optional] the exponent of the curve (default: 2)
   * @return {number}
   */
  static inputToVolume(value, order=2) {
    return Math.pow(parseFloat(value), order);
  }

  /* -------------------------------------------- */

  /**
   * Counterpart to inputToVolume()
   * Returns the input range value based on a volume
   * @param {number} volume     Value between [0, 1] of the volume level
   * @param {number} order      [optional] the exponent of the curve (default: 2)
   * @return {number}
   */
  static volumeToInput(volume, order=2) {
    return Math.pow(volume, 1 / order);
  }

  /* -------------------------------------------- */
  /*  Audio Stream Analysis                       */
  /* -------------------------------------------- */

  /**
   * Returns a singleton AudioContext if one can be created.
   * An audio context may not be available due to limited resources or browser compatibility
   * in which case null will be returned
   *
   * @return {AudioContext}   A singleton AudioContext or null if one is not available
   */
  getAudioContext() {
    if ( this._audioContext )
      return this._audioContext;
    try {
      // Use one Audio Context for all the analysers.
      return new (AudioContext || webkitAudioContext)();
    } catch (err) {
      console.log("Could not create AudioContext. Will not be able to analyse stream volumes.");
    }
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Registers a stream for periodic reports of audio levels.
   * Once added, the callback will be called with the maximum decibel level of
   * the audio tracks in that stream since the last time the event was fired.
   * The interval needs to be a multiple of AudioHelper.levelAnalyserNativeInterval which defaults at 50ms
   *
   * @param {string} id             An id to assign to this report. Can be used to stop reports
   * @param {MediaStream} stream    The media stream to report on its audio tracks
   * @param {Function} callback     The callback function to call with the decibel level. `callback(dbLevel)`
   * @param {number} interval       (optional) The interval at which to produce reports.
   * @param {number} smoothing      (optional) The smoothingTimeConstant to set on the audio analyser. Refer to AudioAnalyser API docs.
   * @return {boolean}              Returns whether or not listening to the stream was successful
   */
  startLevelReports(id, stream, callback, interval = 50, smoothing = 0.1) {
    let audioContext = this.getAudioContext();
    if (audioContext === null) return false;

    // Clean up any existing report with the same ID
    this.stopLevelReports(id);

    // Make sure this stream has audio tracks, otherwise we can't connect the analyser to it
    if (stream.getAudioTracks().length === 0)
      return false;

    // Create the analyser
    let analyser = audioContext.createAnalyser();
    analyser.fftSize = 512;
    analyser.smoothingTimeConstant = smoothing;

    // Connect the analyser to the MediaStreamSource
    audioContext.createMediaStreamSource(stream).connect(analyser);
    this._analyserStreams[id] = {
      stream,
      analyser,
      interval,
      callback,
      // Used as a counter of 50ms increments in case the interval is more than 50
      _lastEmit: 0
    };

    // Ensure the analyser timer is started as we have at least one valid stream to listen to
    this._ensureAnalyserTimer();
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Stop sending audio level reports
   * This stops listening to a stream and stops sending reports.
   * If we aren't listening to any more streams, cancel the global analyser timer.
   * @param {string} id      The id of the reports that passed to startLevelReports.
   */
  stopLevelReports(id) {
    delete this._analyserStreams[id];
    if (isObjectEmpty(this._analyserStreams)) this._cancelAnalyserTimer();
  }

  /* -------------------------------------------- */

  /**
   * Ensures the global analyser timer is started
   * 
   * We create only one timer that runs every 50ms and only create it if needed, this is meant to optimize things
   * and avoid having multiple timers running if we want to analyse multiple streams at the same time.
   * I don't know if it actually helps much with performance but it's expected that limiting the number of timers
   * running at the same time is good practice and with JS itself, there's a potential for a timer congestion
   * phenomenon if too many are created.
   * @private
   */
  _ensureAnalyserTimer() {
    if (this._analyserInterval === 0) {
      this._analyserInterval = setInterval(this._emitVolumes.bind(this), AudioHelper.levelAnalyserNativeInterval);
    }
  }

  /* -------------------------------------------- */

  /**
   * Cancel the global analyser timer
   * If the timer is running and has become unnecessary, stops it.
   * @private
   */
  _cancelAnalyserTimer() {
    if (this._analyserInterval !== 0) {
      clearInterval(this._analyserInterval);
      this._analyserInterval = 0;
    }
  }

  /* -------------------------------------------- */

  /**
   * Capture audio level for all speakers and emit a webrtcVolumes custom event with all the volume levels
   * detected since the last emit.
   * The event's detail is in the form of {userId: decibelLevel}
   * @private
   */
  _emitVolumes() {
    for (let id in this._analyserStreams) {
      const analyserStream = this._analyserStreams[id];
      if (++analyserStream._lastEmit < analyserStream.interval / AudioHelper.levelAnalyserNativeInterval)
        continue;

      // Create the Fast Fourier Transform Array only once. Assume all analysers use the same fftSize
      if (this._fftArray === null) this._fftArray = new Float32Array(analyserStream.analyser.frequencyBinCount);

      // Fill the array
      analyserStream.analyser.getFloatFrequencyData(this._fftArray);
      let maxDecibel = Math.max(...this._fftArray);
      analyserStream.callback(maxDecibel, this._fftArray);
      analyserStream._lastEmit = 0;
    }
  }
}

/**
 * The Native interval for the AudioHelper to analyse audio levels from streams
 * Any interval passed to startLevelReports() would need to be a multiple of this value.
 * Defaults to 50ms.
 * @type {Number}
 */
AudioHelper.levelAnalyserNativeInterval = 50;
/**
 * A configuration of font families which are initialized when the page loads
 * @type{Object}
 */
const FONTS = {
  "Signika": {
    custom: {
      families: ['Signika'],
      urls: ['fonts/signika/signika.css']
    }
  },
  "FontAwesome": {
    custom: {
      families: ['FontAwesome'],
      urls: ['fonts/fontawesome/css/all.min.css']
    }
  },
  _loaded: []
};


/**
 * Load font, and perform a callback once the font has been rendered
 * @param fontName
 * @param callback
 */
function loadFont(fontName, callback) {
  const font = $.extend(FONTS[fontName], {
    fontloading: function(fontFamily, fvd) {
      console.log("Foundry VTT | Loading Font: " + fontFamily);
      let temp = document.createElement('p');
      temp.id = fontFamily;
      temp.classList.add("font-preload");
      temp.style.fontFamily = fontFamily;
      temp.style.fontSize = "0px";
      temp.style.visibility = "hidden";
      temp.innerHTML = ".";
      document.body.appendChild(temp);
    },
    fontactive: () => {
      console.log(`${vtt} | Loaded font ${fontName}`);
      $( 'p#'+fontName ).remove();
      if ( callback ) callback();
    },
    fontinactive: () => {
      console.log("Something went wrong with " + fontName);
      $( 'p#'+fontName ).remove();
    }
  });

  if (!FONTS._loaded.includes(fontName)) {
    WebFont.load(font);
    FONTS._loaded.push(fontName);
  }
}

class Hooks {

  /**
   * Register a callback handler which should be triggered when a hook is triggered.
   *
   * @param {String} hook   The unique name of the hooked event
   * @param {Function} fn   The callback function which should be triggered when the hook event occurs
   */
  static on(hook, fn) {
    console.debug(`${vtt} | Registered callback for ${hook} hook`);
    const id = this._id++;
    this._hooks[hook] = this._hooks[hook] || [];
    this._hooks[hook].push(fn);
    this._ids[id] = fn;
    return id;
  }

  /* -------------------------------------------- */

  /**
   * Register a callback handler for an event which is only triggered once the first time the event occurs.
   * After a "once" hook is triggered the hook is automatically removed.
   *
   * @param {String} hook   The unique name of the hooked event
   * @param {Function} fn   The callback function which should be triggered when the hook event occurs
   */
  static once(hook, fn) {
    this._once.push(fn);
    return this.on(hook, fn);
  }

  /* -------------------------------------------- */

  /**
   * Unregister a callback handler for a particular hook event
   *
   * @param {String} hook   The unique name of the hooked event
   * @param {Function} fn   The function that should be removed from the set of hooked callbacks
   */
  static off(hook, fn) {
    console.debug(`${vtt} | Unregistered callback for ${hook} hook`);
    if ( typeof fn === "number" ) {
      let id = fn;
      fn = this._ids[fn];
      delete this._ids[id];
    }
    if ( !this._hooks.hasOwnProperty(hook) ) return;
    const fns = this._hooks[hook];
    let idx = fns.indexOf(fn);
    if ( idx !== -1 ) fns.splice(idx, 1);
  }

  /* -------------------------------------------- */

  /**
   * Call all hook listeners in the order in which they were registered
   * Hooks called this way can not be handled by returning false and will always trigger every hook callback.
   *
   * @param {String} hook   The hook being triggered
   * @param {Array} args    Arguments passed to the hook callback functions
   */
  static callAll(hook, ...args) {
    if ( CONFIG.debug.hooks ) {
      console.log(`DEBUG | Calling ${hook} hook with args:`);
      console.log(args);
    }
    if ( !this._hooks.hasOwnProperty(hook) ) return;
    const fns = new Array(...this._hooks[hook]);
    for ( let fn of fns ) {
      this._call(hook, fn, args);
    }
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Call hook listeners in the order in which they were registered.
   * Continue calling hooks until either all have been called or one returns `false`.
   *
   * Hook listeners which return `false` denote that the original event has been adequately handled and no further
   * hooks should be called.
   *
   * @param {String} hook   The hook being triggered
   * @param {...*} args      Arguments passed to the hook callback functions
   */
  static call(hook, ...args) {
    if ( CONFIG.debug.hooks ) {
      console.log(`DEBUG | Calling ${hook} hook with args:`);
      console.log(args);
    }
    if ( !this._hooks.hasOwnProperty(hook) ) return;
    const fns = new Array(...this._hooks[hook]);
    for ( let fn of fns ) {
      let callAdditional = this._call(hook, fn, args);
      if ( callAdditional === false ) return false;
    }
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Call a hooked function using provided arguments and perhaps unregister it.
   * @private
   */
  static _call(hook, fn, args) {
    if ( this._once.includes(fn) ) this.off(hook, fn);
    try {
      return fn(...args);
    } catch(err) {
      console.warn(`Error thrown in hooked function ${fn.name}`);
      console.error(err);
    }
  }
}

// Static class attributes
Hooks._hooks = {};
Hooks._once = [];
Hooks._ids = {};
Hooks._id = 1;

/**
 * A set of helpers and management functions for dealing with user input from keyboard events.
 * {@link https://keycode.info/}
 */
class KeyboardManager {
  constructor() {

    /**
     * The set of key codes which are currently depressed (down)
     * @type {Set}
     */
    this._downKeys = null;

    /**
     * The set of key codes which have been already handled per workflow
     * @type {Set}
     */
    this._handled = null;

    /**
     * A mapping of movement keys which are pending
     * @type {Set}
     * @private
     */
    this._moveKeys = null;

    // Status handlers
    this._moveTime = null;
    this._tabState = 0;
    this._wheelTime = 0;

    // Initial reset
    this._reset();

    // Activate input listeners
    window.addEventListener('keydown', e => this._onKeyDown(e));
    window.addEventListener('keyup', e => this._onKeyUp(e));
    window.addEventListener("visibilitychange", e => this._reset(e));
    window.addEventListener("wheel", e => this._onWheel(e), {passive: false});
  }

  /* -------------------------------------------- */

  /**
   * Reset tracking for which keys are in the down and released states
   * @private
   */
  _reset() {
    this._downKeys = new Set();
    this._handled = new Set();
    this._moveKeys = new Set();
    this._tabState = 0;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Return whether the key code is currently in the DOWN state
   * @param {string} code    The key code to test
   * @type {Boolean}
   */
  isDown(code) {
    return this._downKeys.has(code);
  }

  /* -------------------------------------------- */

  /**
   * A helper method to test whether, given an Event, the CTRL (or CMD) keys are pressed
   * @param event
   * @return {Boolean}
   */
  isCtrl(event) {
    event = "ctrlKey" in event ? event : event.data.originalEvent;
    return event.ctrlKey || event.metaKey;
  }

  /* -------------------------------------------- */

  /**
   * Get a standardized keyboard code for a given event
   * @param {KeyboardEvent} event   The originating keypress event
   * @return {string}               The standardized string code to use
   */
  getKey(event) {

    // Spacebar gets a code because its key is misleading
    if ( event.code === "Space" ) return event.code;

    // Enforce that numpad keys are differentiated from digits
    if ( (event.location === 3) && this.digitKeys.includes(event.key) ) {
      return event.code;
    }

    // Otherwise always use the character key
    return event.key;
  }

  /* -------------------------------------------- */

  /**
   * The key codes which represent a possible movement key
   * @return {Object.<Array>}
   */
  get moveKeys() {
    return {
      w: ["up"],
      a: ["left"],
      s: ["down"],
      d: ["right"],
      W: ["up"],
      A: ["left"],
      S: ["down"],
      D: ["right"],
      ArrowUp: ["up"],
      ArrowRight: ["right"],
      ArrowDown: ["down"],
      ArrowLeft: ["left"],
      Numpad1: ["down", "left"],
      Numpad4: "left",
      Numpad7: ["up", "left"],
      Numpad8: "up",
      Numpad9: ["up", "right"],
      Numpad6: ["right"],
      Numpad3: ["down", "right"]
    };
  }

  /* -------------------------------------------- */

  /**
   * The key codes which represent a digit key
   * @return {Array.<string>}
   */
  get digitKeys() {
    return ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]
  }

  /* -------------------------------------------- */

  /**
   * Test whether an input currently has focus
   * @return {boolean}
   */
  get hasFocus() {
    return $(":focus").length > 0;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle a key press into the down position
   * @param {KeyboardEvent} event   The originating keyboard event
   * @private
   */
  _onKeyDown(event) {
    const key = this.getKey(event);
    if ( this._handled.has(key) ) return;
    this._downKeys.add(key);
    this._handleKeys(event, key, false);
  }

  /* -------------------------------------------- */

  /**
   * Handle a key release into the up position
   * @param {KeyboardEvent} event   The originating keyboard event
   * @private
   */
  _onKeyUp(event) {
    const key = this.getKey(event);
    this._downKeys.delete(key);
    this._handleKeys(event, key, true);
    if ( this._handled.has(key) ) {
      this._handled.clear();
      this._downKeys.clear();
    }
  }

  /* -------------------------------------------- */

  /**
   * Delegate tracked key codes by dispatching to their various handlers
   * @param {Event} event     The keydown or keyup event
   * @param {string} key      The key being depressed
   * @param {Boolean} up      A flag for whether the key is down or up
   * @private
   */
  _handleKeys(event, key, up) {

    // Collect meta modifiers
    const modifiers = {
      key: key,
      isShift: event.shiftKey,
      isCtrl: event.ctrlKey || event.metaKey,
      isAlt: event.altKey,
      hasFocus: this.hasFocus
    };

    // Dispatch events to bound handlers
    if ( key === "Tab" ) this._onTab(event, up, modifiers);
    else if ( key === "Escape" ) this._onEscape(event, up, modifiers);
    else if ( key === "Space" ) this._onSpace(event, up, modifiers);
    else if ( key in this.moveKeys ) this._onMovement(event, up, modifiers);
    else if ( this.digitKeys.includes(key) ) this._onDigit(event, up, modifiers);
    else if ( ["Delete", "Backspace"].includes(key) ) this._onDelete(event, up, modifiers);
    else if ( key === "Alt" ) this._onAlt(event, up, modifiers);
    else if ( key === "z" ) this._onUndo(event, up, modifiers);
    else if ( key === "c" ) this._onCopy(event, up, modifiers);
    else if ( key === "v" ) this._onPaste(event, up, modifiers);
  }

  /* -------------------------------------------- */

  /**
   * Master mouse-wheel event keyboard handler
   * @private
   */
  _onWheel(event) {

    // Prevent zooming the entire browser window
    if ( event.ctrlKey ) event.preventDefault();

    // Handle wheel events for the canvas if it is ready and if it is our hover target
    let hover = document.elementFromPoint(event.clientX, event.clientY);
    if ( canvas && canvas.ready && hover && (hover.id === "board") ) {
      event.preventDefault();
      let layer = canvas.activeLayer;
      let isCtrl = event.ctrlKey || event.metaKey,
          isShift = event.shiftKey;

      // Case 1 - rotate tokens or tiles
      if ( layer instanceof PlaceablesLayer && ( isCtrl || isShift ) ) layer._onMouseWheel(event);

      // Case 2 - zoom the canvas
      else canvas._onMouseWheel(event);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle TAB keypress events
   * @param event {Event}
   * @param up {Boolean}
   * @param modifiers {Object}
   * @private
   */
  _onTab(event, up, modifiers) {

    // Always prevent tab actions if a form field is not in focus
    if ( !modifiers.hasFocus ) event.preventDefault();
    else return;
    if ( up || !canvas.ready ) {
      this._tabState = 0;
      return;
    }
    if ( this._tabState > 0 ) return;

    // Attempt to cycle tokens, otherwise re-center the canvas
    let cycled = canvas.tokens.cycleTokens(!modifiers.shiftKey);
    if ( !cycled ) canvas.recenter();
    this._tabState = 1;
  }

  /* -------------------------------------------- */

  /**
   * Handle ESC keypress events
   * @param event {Event}
   * @param up {Boolean}
   * @param modifiers {Object}
   * @private
   */
  _onEscape(event, up, modifiers) {
    if ( up || modifiers.hasFocus ) return;

    // Save fog of war if there are pending changes
    if ( canvas.ready ) canvas.sight.saveFog();

    // Case 1 - dismiss an open context menu
    if ( ui.context && ui.context.menu.length ) ui.context.close();

    // Case 2 - close open UI windows
    else if ( Object.keys(ui.windows).length ) {
      Object.values(ui.windows).forEach(app => app.close());
    }

    // Case 3 (GM) - release controlled objects
    else if ( canvas.ready && game.user.isGM && Object.keys(canvas.activeLayer._controlled).length ) {
      event.preventDefault();
      canvas.activeLayer.releaseAll();
    }

    // Case 4 - toggle the main menu
    else ui.menu.toggle();

    // Flag the keydown workflow as handled
    this._handled.add(modifiers.key);
  }

  /* -------------------------------------------- */

  /**
   * Handle SPACE keypress events
   * @param event {Event}
   * @param up {Boolean}
   * @param modifiers {Object}
   * @private
   */
  _onSpace(event, up, modifiers) {
    const ruler = canvas.controls.ruler;
    if ( up ) return;

    // Move along a measured ruler
    if ( canvas.ready && ruler.active ) {
      let moved = ruler.moveToken(event);
      if ( moved ) event.preventDefault();
    }

    // Pause the game
    else if ( !modifiers.hasFocus && game.user.isGM ) {
      event.preventDefault();
      game.togglePause(null, true);
    }

    // Flag the keydown workflow as handled
    this._handled.add(modifiers.key);
  }

  /* -------------------------------------------- */

  /**
   * Handle ALT keypress events
   * @param event {Event}
   * @param up {Boolean}
   * @param modifiers {Object}
   * @private
   */
  _onAlt(event, up, modifiers) {
    if ( !canvas.ready ) return;
    event.preventDefault();

    // Highlight placeable objects on any layers which are visible
    const layers = canvas.layers.filter(l => l.objects && l.objects.visible);
    for ( let layer of layers ) {
      layer.placeables.filter(t => t.visible).forEach(t => {
        if ( !up ) t._onMouseOver(event);
        else t._onMouseOut(event);
      })
    }

    // Flag the keydown workflow as handled
    if ( !up ) this._handled.add(modifiers.key);
  }

  /* -------------------------------------------- */

  /**
   * Handle WASD or ARROW keypress events
   * @param event {Event}
   * @param up {Boolean}
   * @param modifiers {Object}
   * @private
   */
  _onMovement(event, up, modifiers) {
    if ( !canvas.ready || up || modifiers.hasFocus ) return;
    event.preventDefault();
    const layer = canvas.activeLayer;

    // Track the movement set
    const directions = this.moveKeys[modifiers.key];
    this._moveKeys.add(...directions);

    // Delay 50ms before shifting tokens in order to capture diagonal movements
    if ( layer instanceof TokenLayer || layer instanceof TilesLayer ) {
      const now = Date.now();
      if ( now - this._moveTime < 150 ) return;
      setTimeout(() => this._handleMovement(event, layer), 50);
      this._moveTime = now;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle keyboard movement for a PlaceablesLayer where object movement is supported.
   * @private
   */
  _handleMovement(event, layer) {

    // Get controlled objects
    let objects = layer.placeables.filter(o => o._controlled);
    if ( objects.length === 0 ) return;

    // Define movement offsets and get moved directions
    const directions = this._moveKeys;
    let dx = 0;
    let dy = 0;

    // Assign movement offsets
    if ( directions.has("left") ) dx -= 1;
    if ( directions.has("up") ) dy -= 1;
    if ( directions.has("right") ) dx += 1;
    if ( directions.has("down") ) dy += 1;

    // Perform the shift or rotation
    layer.moveMany({dx, dy, rotate: event.shiftKey});

    // Clear the pending set
    this._moveKeys.clear();
  }

  /* -------------------------------------------- */

  /**
   * Handle number key presses
   * @param {Event} event       The original digit key press
   * @param {boolean} up        Is it a keyup?
   * @param {Object}modifiers   What modifiers affect the keypress?
   * @private
   */
  _onDigit(event, up, modifiers) {
    if ( modifiers.hasFocus || up ) return;
    const num = parseInt(event.key);
    const slot = ui.hotbar.macros.find(m => m.key === num);
    if ( slot.macro ) slot.macro.execute();
    this._handled.add(modifiers.key);
  }

  /* -------------------------------------------- */

  /**
   * Handle Z Keypress Events to generally undo previous actions
   * @param event {Event}
   * @param up {Boolean}
   * @param modifiers {Object}
   * @private
   */
  _onUndo(event, up, modifiers) {
    if ( modifiers.hasFocus || !canvas.ready ) return;

    // Ensure we are on a Placeables layer
    const layer = canvas.activeLayer;
    if ( !layer instanceof PlaceablesLayer ) return;

    // Undo history for the Layer
    if ( up && modifiers.isCtrl && layer.history.length ) {
      layer.undoHistory();
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle "C" keypress events to copy data to clipboard
   * @param event {Event}
   * @param up {Boolean}
   * @param modifiers {Object}
   * @private
   */
  _onCopy(event, up, modifiers) {
    if ( !game.user.isGM ) return;
    if ( !canvas.ready || up || modifiers.hasFocus || !modifiers.isCtrl ) return;

    // If text is selected, allow a regular copy operation
    if ( window.getSelection().toString() !== "" ) return;

    // Otherwise attempt to copy objects on the Placeables Layer
    let layer = canvas.activeLayer;
    if ( layer instanceof PlaceablesLayer ) layer.copyObjects();
  }

  /* -------------------------------------------- */

  /**
   * Handle "V" keypress events to paste data from clipboard
   * @param event {Event}
   * @param up {Boolean}
   * @param modifiers {Object}
   * @private
   */
  _onPaste(event, up, modifiers ) {
    if ( !game.user.isGM ) return;
    if ( !canvas.ready || up || modifiers.hasFocus || !modifiers.isCtrl ) return;
    let layer = canvas.activeLayer;
    if ( layer instanceof PlaceablesLayer ) {
      let pos = canvas.app.renderer.plugins.interaction.mouse.getLocalPosition(canvas.tokens);
      layer.pasteObjects(pos, {hidden: modifiers.isAlt});
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle DELETE Keypress Events
   * @param {Event} event
   * @param {Boolean} up
   * @param modifiers {Object}
   * @private
   */
  _onDelete(event, up, modifiers) {
    if ( this.hasFocus ) return;
    event.preventDefault();

    // Remove hotbar Macro
    if ( ui.hotbar._hover ) game.user.assignHotbarMacro(null, ui.hotbar._hover);

    // Delete placeables from Canvas layer
    else if ( canvas.ready && ( canvas.activeLayer instanceof PlaceablesLayer ) ) {
      return canvas.activeLayer._onDeleteKey(event);
    }
  }
}

/**
 * Specify a rate limit for mouse wheel to gate repeated scrolling.
 * This is especially important for continuous scrolling mice which emit hundreds of events per second.
 * This designates a minimum number of milliseconds which must pass before another wheel event is handled
 * @type {Number}
 */
KeyboardManager.MOUSE_WHEEL_RATE_LIMIT = 50;
/**
 * An abstract interface for defining setting storage patterns
 * Each setting is a key/value pair
 */
class ClientSettings {
  constructor(worldSettings) {

    /**
     * A object of registered game settings for this scope
     * @type {Object}
     */
    this.settings = {};

    /**
     * The storage interfaces used for persisting settings
     * Each storage interface shares the same API as window.localStorage
     */
    this.storage = {
      client: window.localStorage,
      world: new WorldSettingsStorage(worldSettings)
    }
  }

  /* -------------------------------------------- */

  /**
   * Return a singleton instance of the Game Settings Configuration app
   * @return {SettingsConfig}
   */
  get sheet() {
    if ( !this._sheet ) this._sheet = new SettingsConfig(this.settings);
    return this._sheet;
  }

  /* -------------------------------------------- */

  /**
   * Register a new game setting under this setting scope
   * @param module {String}   The module namespace under which the setting is registered
   * @param key {String}      The key name for the setting under the namespace module
   * @param data {Object}     Configuration for setting data
   */
  register(module, key, data) {
    if ( !module || !key ) throw new Error("You must specify both module and key portions of the setting");
    data["key"] = key;
    data["module"] = module;
    data["scope"] = ["client", "world"].includes(data.scope) ? data.scope : "client";
    key = `${module}.${key}`;
    this.settings[key] = data;
  }

  /* -------------------------------------------- */

  /**
   * Get the value of a game setting for a certain module and setting key
   * @param module {String}   The module namespace under which the setting is registered
   * @param key {String}      The setting key to retrieve
   */
  get(module, key) {
    if ( !module || !key ) throw new Error("You must specify both module and key portions of the setting");
    key = `${module}.${key}`;
    if ( !this.settings.hasOwnProperty(key) ) throw new Error("This is not a registered game setting");

    // Get the setting and the correct storage interface
    let setting = this.settings[key],
        storage = this.storage[setting.scope];

    // Get the setting value
    let value = JSON.parse(storage.getItem(key));
    value = value !== null ? value : setting.default;

    // Cast the value to a requested type
    return setting.type ? setting.type(value) : value;
  }

  /* -------------------------------------------- */

  /**
   * Set the value of a game setting for a certain module and setting key
   * @param module {String}   The module namespace under which the setting is registered
   * @param key {String}      The setting key to retrieve
   * @param value             The data to assign to the setting key
   */
  async set(module, key, value) {
    if ( !module || !key ) throw new Error("You must specify both module and key portions of the setting");
    key = `${module}.${key}`;

    // Get the setting and the correct storage interface
    if ( !this.settings.hasOwnProperty(key) ) throw new Error("This is not a registered game setting");
    let setting = this.settings[key];

    // Push world setting changes
    return this.update(key, value);
  }

  /* -------------------------------------------- */

  /**
   * Update the setting storage with a new value
   * @param {String} key
   * @param {*} value
   * @return {Promise}
   */
  async update(key, value) {
    let setting = this.settings[key],
        storage = this.storage[setting.scope];
    await storage.setItem(key, JSON.stringify(value));

    // Trigger change callback
    let onChange = this.settings[key].onChange;
    if ( onChange instanceof Function ) onChange(value);

    // Return the new value
    return value;
  }

  /* -------------------------------------------- */

  static socketListeners(socket) {
    socket.on('updateWorldSetting', (key, value) => {
      const setting = game.settings.settings[key];
      game.settings.storage.world._set(key, value);
      if ( setting.onChange instanceof Function ) setting.onChange(JSON.parse(value));
      game.settings.sheet.render();
    });
  }
}


/* -------------------------------------------- */


/**
 * A simple interface for World settings storage which imitates the API provided by localStorage
 */
class WorldSettingsStorage {
  constructor(settings) {
    this.data = {};
    for ( let s of settings ) {
      this._set(s.key, s.value);
    }
  }

  getItem(key) {
    return this.data[key] || null;
  }

  setItem(key, value) {
    return new Promise((resolve, reject) => {
      game.socket.emit("updateWorldSetting", key, value, setting => {
        this._set(key, setting.value);
        resolve();
      });
    });
  }

  _set(key, value) {
    this.data[key] = value;
  }
}


/* -------------------------------------------- */
/**
 * A library of package management commands which are used by various interfaces around the software.
 */
class SetupConfiguration {

  /**
   * A reference to the setup URL used under the current route prefix, if any
   * @return {string}
   */
  static get setupURL() {
    return ROUTE_PREFIX ? `/${ROUTE_PREFIX}/setup` : "/setup";
  }

  /* -------------------------------------------- */
  /*  Package Management                          */
  /* -------------------------------------------- */

  /**
   * Check with the server whether a package of a certain type is able to be installed or updated.
   * @param {string} type       The package type to check
   * @param {string} name       The package name to check
   * @param {string} manifest   The manifest URL to check
   * @return {Promise<Object>}  The return manifest
   */
  static async checkPackage({type="module", name=null, manifest=null}={}) {
    const request = await this.post({action: "checkPackage", type, name, manifest});
    return request.json();
  }

  /* -------------------------------------------- */

  static async installPackage({type="module", name=null, manifest=null}={}) {
    const request = await this.post({action: "installPackage", type, name, manifest});
    return request.json();
  }

  /* -------------------------------------------- */

  static async uninstallPackage({type="module", name=null}={}) {
    const request = await this.post({action: "uninstallPackage", type, name});
    return request.json();
  }

  /* -------------------------------------------- */

  /**
   * Return the named scopes which can exist for packages.
   * Scopes are returned in the prioritization order that their content is loaded.
   * @return {Array<string>}    An array of string package scopes
   */
  static getPackageScopes() {
    let scopes = ["core", game.system.id];
    scopes = scopes.concat(game.modules.keys());
    scopes.push("world");
    return scopes;
  }

  /* -------------------------------------------- */
  /*  Helper Functions                            */
  /* -------------------------------------------- */

  /**
   * A helper method to submit a POST request to setup configuration with a certain body, returning the JSON response
   * @param {Object} body         The request body to submit
   * @return {Promise<Object>}    The response body
   * @private
   */
  static async post(body) {
    if ( game.ready && !game.user.isGM ) {
      throw new Error("You may not submit POST requests to the setup page as a non-GM user");
    }
    return await fetch(this.setupURL, {
      method: "POST",
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify(body)
    });
  }
}
/**
 * A collection of helper methods designed to orchestrate the client side socket workflow in a standardized way.
 */
class SocketInterface {

  /**
   * A generalized socket trigger interface which standardizes the way that information is provided to the server
   *
   * @param {String} eventName      The socket event name to emit
   * @param {Object} eventData      Data provided to the server as part of the event
   * @param {Object} options        Additional options which contextualize the socket request
   * @param {String} preHook        If an optional preHook is provided, ensure it does not return false before
   *                                proceeding with the socket emission.
   * @param {String} postHook       The name of an optional post-hook event to call with the resolved context and the
   *                                provided event arguments.
   * @param {*} context             The Entity or Object context for the request. Passed as the first argument to the
   *                                preHook.
   * @param {Function} success      A function to call upon successful receipt of response.
   *
   * @return {Promise}              A Promise which resolves to the return value of the provided handler function
   */
  static async trigger(eventName, eventData, options={}, {preHook=null, postHook=null, context=null, success=null}={}) {

    // Dispatch the pre-hook
    if ( preHook ) {
      const hookArgs = Array(...Object.values(eventData), options);
      if ( context ) hookArgs.unshift(context);
      const allowed = Hooks.call(preHook, ...hookArgs);
      if ( allowed === false ) {
        console.log(`${vtt} | ${eventName} submission prevented by ${preHook} hook`);
        return null;
      }
    }

    // Dispatch the socket event
    const response = await this._dispatch(eventName, eventData, options);

    // Handle the server response
    return this.handle(response, success, {postHook, context});
  }

  /* -------------------------------------------- */

  /**
   * Generalized handler for single-target responses
   */
  static async handle(response, handler, {postHook=null, context=null}={}) {

    // Call the response handler
    let result;
    if ( handler instanceof Function ) {
      if ( !handler.name.startsWith("bound_") && context ) handler = handler.bind(context);
      result = handler(response);
    }

    // Trigger a post-event hook
    if ( postHook ) {
      const hookArgs = Object.values(response);
      if ( context ) hookArgs.unshift(context);
      Hooks.callAll(postHook, ...hookArgs);
    }

    // Return the result returned by the handler
    return result;
  }

  /* -------------------------------------------- */

  /**
   * A generalized socket trigger interface which standardizes the way that information is provided to the server
   * Similar to the basic trigger method, except this method is responsible for triggering multi-object operations
   *
   * @param {String} eventName      The socket event name to emit
   * @param {Array} eventData       Data provided to the server as part of the event
   * @param {Object} options        Additional options which contextualize the socket request
   * @param {String} preHook        If an optional preHook is provided, ensure it does not return false before
   *                                proceeding with the socket emission.
   * @param {String} postHook       The name of an optional post-hook event to call with the resolved context and the
   *                                provided event arguments.
   * @param {*} context             The Entity or Object context for the request. Passed as the first argument to the
   *                                preHook.
   * @param {Function} success      A function to call upon successful receipt of response.
   *
   * @return {Promise}              A Promise which resolves to the return value of the provided handler function
   */
  static async triggerMany(eventName, eventData, options={}, {preHook=null, postHook=null, context=null, success=null}={}) {

    // Pass each update to a pre-hook filter
    if ( preHook ) {
      const eventDataCopy = duplicate(eventData);
      for ( let data of eventData.data ) {
        eventDataCopy.data = data;
        const hookArgs = Object.values(eventDataCopy);
        if ( context ) hookArgs.unshift(context);
        let allowed = Hooks.call(preHook, ...hookArgs);
        if ( allowed === false ) {
          console.log(`${vtt} | ${eventName} submission prevented by ${preHook} hook`);
          return null;
        }
      }
    }

    // Dispatch the socket event
    const response = await this._dispatch(eventName, eventData, options);

    // Handle the server response
    return this.handleMany(response, success, {postHook, context});
  }

  /* -------------------------------------------- */

  /**
   * Generalized handler for multi-target responses
   */
  static async handleMany(response, handler, {postHook=null, context=null}={}) {
    const responseCopy = postHook ? duplicate(response) : null;

    // Call the response handler
    let result;
    if ( handler instanceof Function ) {
      if ( context ) handler = handler.bind(context);
      result = handler(response);
    }

    // Pass each updated object through a post hook
    if ( postHook ) {
      const updates = response.data;
      if ( !updates.length ) return;
      for (let update of updates) {
        responseCopy.data = update;
        const hookArgs = Object.values(responseCopy);
        if ( context ) hookArgs.unshift(context);
        Hooks.callAll(postHook, ...hookArgs);
      }
    }

    // Return the result provided by the handler
    return result;
  }

  /* -------------------------------------------- */
  /*  Internal Helpers                            */
  /* -------------------------------------------- */

  /**
   * A helper to standardize dispatching the socket request and handling its response
   * @param {String} eventName    The socket event name being handled
   * @param {Object} eventData    Data provided for the socket event
   * @param {Object} options      Additional options which modify the request
   * @return {Promise}            A promise which resolves to the socket response, if successful
   * @private
   */
  static _dispatch(eventName, eventData, options) {
    return new Promise((resolve, reject) => {
      game.socket.emit(eventName, eventData, options, response => {
        if ( response.error ) {
          this._handleDatabaseError(response.error);
          reject();
        }
        else resolve(response);
      });
    })
  }

  /* -------------------------------------------- */

  /**
   * General use handler for receiving a database error and transforming it into a notification and log message
   * @param err
   * @private
   */
  static _handleDatabaseError(err) {
    let error = err instanceof Error ? err : err.error;
    ui.notifications.error(error.message);
    console.error(error.stack);
  }
}

/**
 * A collection of functions related to sorting objects within a parent container.
 */
class SortingHelpers {

  /**
   * Given a source object to sort, a target to sort relative to, and an Array of siblings in the container:
   * Determine the updated sort keys for the source object, or all siblings if a reindex is required.
   * Return an Array of updates to perform, it is up to the caller to dispatch these updates.
   * Each update is structured as:
   * {
   *   target: object,
   *   update: {sortKey: sortValue}
   * }
   *
   * @param {*} source            The source object being sorted
   * @param {*} target            The target object relative which to sort
   * @param {Array} siblings      The sorted Array of siblings which share the same sorted container
   * @param {String} sortKey      The name of the data property within the source object which defines the sort key
   * @param {Boolean} sortBefore  Whether to sort before the target (if true) or after (if false)
   *
   * @returns {Array}             An Array of updates for the caller of the helper function to perform
   */
  static performIntegerSort(source, {target=null, siblings=[], sortKey="sort", sortBefore=true}={}) {

    // Ensure the siblings are sorted
    siblings.sort((a, b) => a.data[sortKey] - b.data[sortKey]);

    // Determine the index target for the sort
    let defaultIdx = sortBefore ? siblings.length : 0;
    let idx = target ? siblings.findIndex(sib => sib === target) : defaultIdx;

    // Determine the indices to sort between
    let min, max;
    if ( sortBefore ) [min, max] = this._sortBefore(siblings, idx, sortKey);
    else [min, max] = this._sortAfter(siblings, idx, sortKey);

    // Easiest case - no siblings
    if ( siblings.length === 0 ) {
      return [{
        target: source,
        update: {[sortKey]: CONST.SORT_INTEGER_DENSITY}
      }]
    }

    // No minimum - sort to beginning
    else if ( Number.isFinite(max) && (min === null) ) {
      return [{
        target: source,
        update: {[sortKey]: max - CONST.SORT_INTEGER_DENSITY}
      }];
    }

    // No maximum - sort to end
    else if ( Number.isFinite(min) && (max === null) ) {
      return [{
        target: source,
        update: {[sortKey]: min + CONST.SORT_INTEGER_DENSITY}
      }];
    }

    // Sort between two
    else if ( Number.isFinite(min) && Number.isFinite(max) && (Math.abs(max - min) > 1) ) {
      return [{
        target: source,
        update: {[sortKey]: Math.round(0.5 * (min + max))}
      }];
    }

    // Reindex all siblings
    else {
      siblings.splice(idx, 0, source);
      return siblings.map((sib, i) => {
        return {
          target: sib,
          update: {[sortKey]: (i+1) * CONST.SORT_INTEGER_DENSITY}
        }
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * Given an ordered Array of siblings and a target position, return the [min,max] indices to sort before the target
   * @private
   */
  static _sortBefore(siblings, idx, sortKey) {
    let max = siblings[idx] ? siblings[idx].data[sortKey] : null;
    let min = siblings[idx-1] ? siblings[idx-1].data[sortKey] : null;
    return [min, max];
  }

  /* -------------------------------------------- */

  /**
   * Given an ordered Array of siblings and a target position, return the [min,max] indices to sort after the target
   * @private
   */
  static _sortAfter(siblings, idx, sortKey) {
    let min = siblings[idx] ? siblings[idx].data[sortKey] : null;
    let max = siblings[idx+1] ? siblings[idx+1].data[sortKey] : null;
    return [min, max];
  }

  /* -------------------------------------------- */
}
/**
 * Export data content to be saved to a local file
 * @param {String} data       Data content converted to a string
 * @param {String} type       The type of
 * @param {String} filename   The filename of the resulting download
 */
function saveDataToFile(data, type, filename) {
  const blob = new Blob([data], {type: type});

  // Create an element to trigger the download
  let a = document.createElement('a');
  a.href = window.URL.createObjectURL(blob);
  a.download = filename;

  // Dispatch a click event to the element
  a.dispatchEvent(new MouseEvent("click", {bubbles: true, cancelable: true, view: window}));
  setTimeout(() => window.URL.revokeObjectURL(a.href), 100);
}


/* -------------------------------------------- */


/**
 * Read text data from a user provided File object
 * @param {File} file           A File object
 * @return {Promise.<String>}   A Promise which resolves to the loaded text data
 */
function readTextFromFile(file) {
  const reader = new FileReader();
  return new Promise((resolve, reject) => {
    reader.onload = ev => {
      resolve(reader.result);
    };
    reader.onerror = ev => {
      reader.abort();
      reject();
    };
    reader.readAsText(file);
  });
}


/* -------------------------------------------- */


/**
 * Retrieve an Entity or Embedded Entity by its Universally Unique Identifier (uuid).
 * @param {string} uuid   The uuid of the Entity or Embedded Entity to retrieve
 * @return {Promise.<Entity|Object|null>}
 */
async function fromUuid(uuid) {
  const parts = uuid.split(".");

  // Compendium Entries
  if ( parts[0] === "Compendium" ) {
    if ( parts.length < 4 ) throw new Error(`Incorrect UUID format for Compendium entry: ${uuid}`);
    const [prefix, scope, packName, entryId] = parts;
    const pack = game.packs.find(p => p.collection === `${scope}.${packName}`);
    return pack.getEntity(entryId);
  }

  // World Entities
  const [entityName, entityId, embeddedName, embeddedId] = parts;
  const entity = CONFIG[entityName].entityClass.collection.get(entityId);
  if ( entity === null ) return null;

  // Embedded Entities
  if ( parts.length === 4 ) {
    return entity.getEmbeddedEntity(embeddedName, embeddedId);
  }
  else return entity;
}

/**
 * A helper class to provide common functionality for working with HTML5 video objects
 * A singleton instance of this class is available as ``game.video``
 */
class VideoHelper {
  constructor() {
    if ( game.video instanceof this.constructor ) {
      throw new Error("You may not re-initialize the singleton VideoHelper. Use game.video instead.");
    }

    /**
     * A collection of HTML5 video objects which are currently active within the FVTT page
     * @type {Object}
     */
    this.videos = [];

    /**
     * A user gesture must be registered before video playback can begin.
     * This Set records the video elements which await such a gesture.
     * @type {Set}
     */
    this.pending = new Set();

    /**
     * A flag for whether video playback is currently locked by awaiting a user gesture
     * @type {Boolean}
     */
     this.locked = true;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  static hasVideoExtension(src) {
    let rgx = new RegExp("(\\."+CONST.VIDEO_FILE_EXTENSIONS.join("|\\.")+")(\\?.*)?", "i");
    return rgx.test(src);
  }

  /* -------------------------------------------- */

  /**
   * Play a single video source
   * If playback is not yet enabled, add the video to the pending queue
   * @param {HTMLElement} video   The VIDEO element to play
   */
  play(video) {
    video.play().catch(err => {
      if ( this.locked ) this.pending.add(video);
      else throw new Error(err.toString());
    });
  }

  /* -------------------------------------------- */

  /**
   * Register an event listener to await the first mousemove gesture and begin playback once observed
   * A user interaction must involve a mouse click or keypress.
   * Listen for any of these events, and handle the first observed gesture.
   */
  awaitFirstGesture() {
    if ( !this.locked ) return;
    const interactions = ['contextmenu', 'auxclick', 'mousedown', 'mouseup', 'keydown'];
    interactions.forEach(event => document.addEventListener(event, this._onFirstGesture.bind(this), {once: true}));
  }

  /* -------------------------------------------- */

  /**
   * Handle the first observed user gesture
   * We need a slight delay because unfortunately Chrome is stupid and doesn't always acknowledge the gesture fast enough.
   * @param {Event} event   The mouse-move event which enables playback
   */
  _onFirstGesture(event) {
    if ( !this.pending.size ) return;
    console.log(`${vtt} | Activating pending video playback with user gesture.`);
    this.locked = false;
    for ( let video of Array.from(this.pending) ) {
      this.play(video);
    }
    this.pending.clear();
  }
}

let _appId = 0;
let _maxZ = 100;

const MIN_WINDOW_WIDTH = 200,
      MIN_WINDOW_HEIGHT = 50;

/**
 * The standard application window that is rendered for a large variety of UI elements in Foundry VTT.
 * @abstract
 *
 * @param {Object} options                      Configuration options which control how the application is rendered.
 *                                              Application subclasses may add additional supported options, but the
 *                                              following configurations are supported for all Applications. The values
 *                                              passed to the constructor are combined with the defaultOptions defined
 *                                              at the class level.
 * @param {string} options.baseApplication      A named "base application" which generates an additional hook
 * @param {number} options.width                The default pixel width for the rendered HTML
 * @param {number} options.height               The default pixel height for the rendered HTML
 * @param {number} options.top                  The default offset-top position for the rendered HTML
 * @param {number} options.left                 The default offset-left position for the rendered HTML
 * @param {boolean} options.popOut              Whether to display the application as a pop-out container
 * @param {boolean} options.minimizable         Whether the rendered application can be minimized (popOut only)
 * @param {boolean} options.resizable           Whether the rendered application can be drag-resized (popOut only)
 * @param {string} options.id                   The default CSS id to assign to the rendered HTML
 * @param {Array.<string>} options.classes      An array of CSS string classes to apply to the rendered HTML
 * @param {string} options.title                A default window title string (popOut only)
 * @param {string} options.template             The default HTML template path to render for this Application
 * @param {Array.<string>} options.scrollY      A list of unique CSS selectors which target containers that should
 *                                              have their vertical scroll positions preserved during a re-render.
 */
class Application {
  constructor(options) {

    /**
     * The options provided to this application upon initialization
     * @type {Object}
     */
    this.options = mergeObject(this.constructor.defaultOptions, options || {}, {
      insertKeys: true,
      insertValues: false,
      overwrite: true,
      inplace: false
    });

    /**
     * The application ID is a unique incrementing integer which is used to identify every application window
     * drawn by the VTT
     * @type {Number}
     */
    this.appId = _appId += 1;

    /**
     * An internal reference to the HTML element this application renders
     * @type {jQuery}
     */
    this._element = null;

    /**
     * Track the current position and dimensions of the Application UI
     * @type {Object}
     */
    this.position = {
      width: this.options.width,
      height: this.options.height,
      left: this.options.left,
      top: this.options.top,
      scale: this.options.scale
    };

    /**
     * Track Tab navigation handlers which are active for this Application
     * @type {Array}
     */
    this._tabs = this.options.tabs.map(t => {
      t.callback = this._onChangeTab.bind(this);
      return new TabsV2(t);
    });

    /**
     * Track whether the Application is currently minimized
     * @type {boolean}
     * @private
     */
    this._minimized = false;

    /**
     * Track the render state of the Application
     * @see {Application.RENDER_STATES}
     * @type {number}
     * @private
     */
    this._state = Application.RENDER_STATES.NONE;

    /**
     * Track the most recent scroll positions for any vertically scrolling containers
     * @type {Object|null}
     */
    this._scrollPositions = null;
  }

	/* -------------------------------------------- */

  /**
   * Assign the default options configuration which is used by this Application class. The options and values defined
   * in this object are merged with any provided option values which are passed to the constructor upon initialization.
   * Application subclasses may include additional options which are specific to their usage.
   */
	static get defaultOptions() {
    let config = CONFIG[this.name] || {}; // TODO: Deprecate this aspect of CONFIG
    return {
      baseApplication: null,
      width: config.width,
      height: config.height,
      top: null,
      left: null,
      popOut: true,
      minimizable: true,
      resizable: false,
      id: "",
      classes: [],
      tabs: [],
      title: "",
      template: config.template,
      scrollY: []
    };
  };

  /* -------------------------------------------- */

  /**
   * Return the CSS application ID which uniquely references this UI element
   */
  get id() {
    return this.options.id ? this.options.id : `app-${this.appId}`;
  }

  /* -------------------------------------------- */

  /**
   * Return the active application element, if it currently exists in the DOM
   * @type {jQuery|HTMLElement}
   */
  get element() {
    if ( this._element ) return this._element;
    let selector = "#"+this.id;
    return $(selector);
  }

  /* -------------------------------------------- */

  /**
   * The path to the HTML template file which should be used to render the inner content of the app
   * @type {string}
   */
  get template() {
    return this.options.template;
  }

  /* -------------------------------------------- */

  /**
   * Control the rendering style of the application. If popOut is true, the application is rendered in its own
   * wrapper window, otherwise only the inner app content is rendered
   * @type {boolean}
   */
  get popOut() {
    return (this.options.popOut !== undefined) ? Boolean(this.options.popOut) : true;
  }

  /* -------------------------------------------- */

  /**
   * Return a flag for whether the Application instance is currently rendered
   * @type {boolean}
   */
  get rendered() {
    return this._state === Application.RENDER_STATES.RENDERED;
  }

  /* -------------------------------------------- */

  /**
   * An Application window should define its own title definition logic which may be dynamic depending on its data
   * @type {string}
   */
  get title() {
    return this.options.title;
  }

  /* -------------------------------------------- */
  /* Application rendering
  /* -------------------------------------------- */

  /**
   * An application should define the data object used to render its template.
   * This function may either return an Object directly, or a Promise which resolves to an Object
   * If undefined, the default implementation will return an empty object allowing only for rendering of static HTML
   *
   * @return {Object|Promise}
   */
  getData(options) {
    return {};
  }

	/* -------------------------------------------- */

  /**
   * Render the Application by evaluating it's HTML template against the object of data provided by the getData method
   * If the Application is rendered as a pop-out window, wrap the contained HTML in an outer frame with window controls
   *
   * @param {Boolean} force   Add the rendered application to the DOM if it is not already present. If false, the
   *                          Application will only be re-rendered if it is already present.
   * @param {Object} options  Additional rendering options which are applied to customize the way that the Application
   *                          is rendered in the DOM.
   *
   * @param {Number} options.left           The left positioning attribute
   * @param {Number} options.top            The top positioning attribute
   * @param {Number} options.width          The rendered width
   * @param {Number} options.height         The rendered height
   * @param {Number} options.scale          The rendered transformation scale
   * @param {Boolean} options.log           Whether to display a log message that the Application was rendered
   * @param {String} options.renderContext  A context-providing string which suggests what event triggered the render
   * @param {*} options.renderData          The data change which motivated the render request
   *
   */
	render(force=false, options={}) {
	  this._render(force, options);
	  return this;
  }

  /* -------------------------------------------- */

  /**
   * An asynchronous inner function which handles the rendering of the Application
   * @param {boolean} force     Render and display the application even if it is not currently displayed.
   * @param {Object} options    Provided rendering options, see the render function for details
   * @return {Promise}
   * @private
   */
  async _render(force=false, options={}) {

    // Do not render under certain conditions
    const states = Application.RENDER_STATES;
    if ( [states.CLOSING, states.RENDERING].includes(this._state) ) return;
    if ( !force && (this._state <= states.NONE) ) return;
    if ( [states.NONE, states.CLOSED].includes(this._state) ) {
      console.log(`${vtt} | Rendering ${this.constructor.name}`);
    }
    this._state = states.RENDERING;

    // Get the existing HTML element and application data used for rendering
    const element = this.element;
    const data = await this.getData(options);

    // Store scroll positions
    const scrollY = this.options.scrollY;
    if ( element.length && scrollY ) this._saveScrollPositions(element, scrollY);

    // Render the inner content
    const inner = await this._renderInner(data, options);
    let html = inner;

    // If the application already exists in the DOM, replace the inner content
    if ( element.length ) this._replaceHTML(element, html, options);

    // Otherwise render a new app
    else {

      // Wrap a popOut application in an outer frame
      if ( this.popOut ) {
        html = await this._renderOuter(options);
        html.find('.window-content').append(inner);
        ui.windows[this.appId] = this;
      }

      // Add the HTML to the DOM and record the element
      this._injectHTML(html, options);
    }

    // Activate event listeners on the inner HTML
    this.activateListeners(inner);

    // Set the application position (if it's not currently minimized)
    if ( this.popOut && !this._minimized ) this.setPosition(this.position);

    // Restore scroll positions
    if ( scrollY ) this._restoreScrollPositions(html, scrollY);

    // Dispatch Hooks for rendering the base and subclass applications
    const base = this.options.baseApplication;
    if ( base && (base !== this.constructor.name) ) {
      Hooks.call(`render${base}`, this, html, data);
    }
    Hooks.call("render"+this.constructor.name, this, html, data);
    this._state = states.RENDERED;
  }

	/* -------------------------------------------- */

  /**
   * Persist the scroll positions of containers within the app before re-rendering the content
   * @param {jQuery} html
   * @param {Array} selectors
   * @private
   */
  _saveScrollPositions(html, selectors) {
    selectors = selectors || [];
    this._scrollPositions = selectors.reduce((pos, sel) => {
      const el = html.find(sel);
      if ( el.length === 1 ) pos[sel] = el[0].scrollTop;
      return pos;
    }, {});
  }

	/* -------------------------------------------- */

  /**
   * Restore the scroll positions of containers within the app after re-rendering the content
   * @param {jQuery} html
   * @param {Array} selectors
   * @private
   */
  _restoreScrollPositions(html, selectors) {
    const positions = this._scrollPositions || {};
    for ( let sel of selectors ) {
      const el = html.find(sel);
      if ( el.length === 1 ) el[0].scrollTop = positions[sel] || 0;
    }
  }

	/* -------------------------------------------- */

  /**
   * Render the outer application wrapper
   * @return {Promise.<HTMLElement>}   A promise resolving to the constructed jQuery object
   * @private
   */
	async _renderOuter(options) {

    // Gather basic application data
    const classes = options.classes || this.options.classes;
    const windowData = {
      id: this.id,
      classes: classes.join(" "),
      appId: this.appId,
      title: this.title,
      headerButtons: this._getHeaderButtons()
    };

    // Render the template and return the promise
    let html = await renderTemplate("templates/app-window.html", windowData);
    html = $(html);

    // Activate header button click listeners
    windowData.headerButtons.forEach(button => {
      const btn = html.find(`a.${button.class}`);
      btn.mousedown(ev => ev.stopPropagation()).mouseup(ev => {
        ev.preventDefault();
        button.onclick(ev);
      })
    });

    // Make the outer window draggable
    const header = html.find('header')[0];
    new Draggable(this, html, header, this.options.resizable);

    // Make the outer window minimizable
    if ( this.options.minimizable ) {
      header.addEventListener('dblclick', this._onToggleMinimize.bind(this));
    }

    // Set the outer frame z-index
    if ( Object.keys(ui.windows).length === 0 ) _maxZ = 100;
    html.css({zIndex: Math.min(++_maxZ, 9999)});

    // Return the outer frame
    return html;
  }

  /* -------------------------------------------- */

  /**
   * Render the inner application content
   * @param {Object} data         The data used to render the inner template
   * @return {Promise.<jQuery>}   A promise resolving to the constructed jQuery object
   * @private
   */
	async _renderInner(data, options) {
    let html = await renderTemplate(this.template, data);
    if ( html === "" ) throw new Error(`No data was returned from template ${this.template}`);
    return $(html);
  }

	/* -------------------------------------------- */

  /**
   * Customize how inner HTML is replaced when the application is refreshed
   * @param {HTMLElement|jQuery} element  The original HTML element
   * @param {HTMLElement|jQuery} html     New updated HTML
   * @private
   */
	_replaceHTML(element, html, options) {
	  if ( !element.length ) return;

	  // For pop-out windows update the inner content and the window title
    if ( this.popOut ) {
      element.find('.window-content').html(html);
      element.find('.window-title').text(this.title);
    }

    // For regular applications, replace the whole thing
    else element.replaceWith(html);
  }

	/* -------------------------------------------- */

  /**
   * Customize how a new HTML Application is added and first appears in the DOC
   * @param html {jQuery}
   * @private
   */
	_injectHTML(html, options) {
    $('body').append(html);
    this._element = html;
    html.hide().fadeIn(200);
  }

	/* -------------------------------------------- */

  /**
   * Specify the set of config buttons which should appear in the Application header
   * Buttons should be returned as an Array of Objects with the following keys:
   * label: The button label
   * icon: A font-awesome glyph icon
   * class: the css class of the button
   * onclick: the button click handler
   * @return {Array.<Object>}
   * @private
   */
  _getHeaderButtons() {
    return [
      {
        label: "Close",
        class: "close",
        icon: "fas fa-times",
        onclick: ev => this.close()
      }
    ];
  }

	/* -------------------------------------------- */
	/* Event Listeners and Handlers
	/* -------------------------------------------- */

  /**
   * Once the HTML for an Application has been rendered, activate event listeners which provide interactivity for
   * the application
   * @param html {jQuery|HTMLElement}
   */
  activateListeners(html) {

    // Bind tab navigation
    this._tabs.forEach(t => t.bind(html[0]));
  }

	/* -------------------------------------------- */

  /**
   * Handle changes to the active tab in a configured Tabs controller
   * @param {MouseEvent} event    A left click event
   * @param {TabsV2} tabs         The TabsV2 controller
   * @param {string} active       The new active tab name
   * @private
   */
  _onChangeTab(event, tabs, active) {
    this.setPosition();
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Close the application and un-register references to it within UI mappings
   * This function returns a Promise which resolves once the window closing animation concludes
   * @return {Promise}
   */
  async close() {
    const states = Application.RENDER_STATES;
    if ( this._state !== states.RENDERED ) return;
    this._state = states.CLOSING;

    // Get the element
    let el = this.element;
    el.css({minHeight: 0});

    // Dispatch Hooks for closing the base and subclass applications
    const base = this.options.baseApplication;
    if ( base && (base !== this.constructor.name) ) {
      Hooks.call(`close${base}`, this, el);
    }
    Hooks.call("close"+this.constructor.name, this, el);

    // Animate closing the element
    return new Promise(resolve => {
      el.slideUp(200, () => {
        el.remove();

        // Clean up data
        this._element = null;
        delete ui.windows[this.appId];
        this._minimized = false;
        this._scrollPositions = null;
        this._state = states.CLOSED;
        resolve();
      });
    });
  }

  /* -------------------------------------------- */

  /**
   * Minimize the pop-out window, collapsing it to a small tab
   * Take no action for applications which are not of the pop-out variety or apps which are already minimized
   * @return {Promise}    A Promise which resolves to true once the minimization action has completed
   */
  async minimize() {
    if ( !this.popOut || [true, null].includes(this._minimized) ) return;
    this._minimized = null;

    // Get content
    let window = this.element,
        header = window.find('.window-header'),
        content = window.find('.window-content');

    // Remove minimum width and height styling rules
    window.css({minWidth: 100, minHeight: 30});

    // Slide-up content
    content.slideUp(100);

    // Slide up window height
    return new Promise((resolve) => {
      window.animate({height: `${header[0].offsetHeight+1}px`}, 100, () => {
        header.children().not(".window-title").not(".close").hide();
        window.animate({width: MIN_WINDOW_WIDTH}, 100, () => {
          window.addClass("minimized");
          this._minimized = true;
          resolve(true);
        });
      });
    })
  }

  /* -------------------------------------------- */

  /**
   * Maximize the pop-out window, expanding it to its original size
   * Take no action for applications which are not of the pop-out variety or are already maximized
   * @return {Promise}    A Promise which resolves to true once the maximization action has completed
   */
  async maximize() {
    if ( !this.popOut || [false, null].includes(this._minimized) ) return;
    this._minimized = null;

    // Get content
    let window = this.element,
        header = window.find('.window-header'),
        content = window.find('.window-content');

    // Expand window
    return new Promise((resolve) => {
      window.animate({width: this.position.width, height: this.position.height}, 100, () => {
        header.children().show();
        content.slideDown(100, () => {
          window.removeClass("minimized");
          this._minimized = false;
          window.css({minWidth: '', minHeight: ''});
          this.setPosition(this.position);
          resolve(true);
        });
      });
    })
  }

  /* -------------------------------------------- */

  /**
   * Set the application position and store it's new location.
   *
   * @param {number|null} left            The left offset position in pixels
   * @param {number|null} top             The top offset position in pixels
   * @param {number|null} width           The application width in pixels
   * @param {number|string|null} height   The application height in pixels
   * @param {number|null} scale           The application scale as a numeric factor where 1.0 is default
   *
   * @returns {Object}                    The updated position object for the application containing the new values
   */
  setPosition({left, top, width, height, scale}={}) {
    const el = this.element[0];
    const p = this.position;
    const pop = this.popOut;
    const styles = window.getComputedStyle(el);

    // If Height is "auto" unset current preference
    if ( (height === "auto") || (this.options.height === "auto") ) {
      el.style.height = "";
      height = null;
    }

    // Update Width
    if ( !el.style.width || width ) {
      const minWidth = parseInt(styles.minWidth) || (pop ? MIN_WINDOW_WIDTH : 0);
      p.width = Math.clamped(
        minWidth,
        width || el.offsetWidth,
        el.style.maxWidth || window.innerWidth
      );
      el.style.width = p.width+"px";
    }

    // Update Height
    if ( !el.style.height || height ) {
      const minHeight = parseInt(styles.minHeight) || (pop ? MIN_WINDOW_HEIGHT : 0);
      p.height = Math.clamped(
        minHeight,
        height || el.offsetHeight,
        el.style.maxHeight || window.innerHeight
      );
      el.style.height = p.height+"px";
    }

    // Update Left
    if ( (pop && !el.style.left) || Number.isFinite(left) ) {
      const maxLeft = Math.max(window.innerWidth - el.offsetWidth, 0);
      if ( !Number.isFinite(left) ) left = (window.innerWidth - el.offsetWidth) / 2;
      p.left = Math.clamped(left, 0, maxLeft);
      el.style.left = p.left+"px";
    }

    // Update Top
    if ( (pop && !el.style.top) || Number.isFinite(top) ) {
      const maxTop = Math.max(window.innerHeight - el.offsetHeight, 0);
      if ( !Number.isFinite(top) ) top = (window.innerHeight - el.offsetHeight) / 2;
      p.top = Math.clamped(top, 0, maxTop);
      el.style.top = p.top+"px";
    }

    // Update Scale
    if ( scale ) {
      p.scale = scale;
      if ( scale === 1 ) el.style.transform = "";
      else el.style.transform = `scale(${scale})`;
    }

    // Return the updated position object
    return p;
  }

  /* -------------------------------------------- */

  /**
   * Handle application minimization behavior - collapsing content and reducing the size of the header
   * @param {Event} ev
   * @private
   */
  _onToggleMinimize(ev) {
    ev.preventDefault();
    if ( this._minimized ) this.maximize(ev);
    else this.minimize(ev);
  }

  /* -------------------------------------------- */

  /**
   * Additional actions to take when the application window is resized
   * @param {Event} event
   * @private
   */
  _onResize(event) {}
}

/* -------------------------------------------- */

Application.RENDER_STATES = {
  CLOSING: -2,
  CLOSED: -1,
  NONE: 0,
  RENDERING: 1,
  RENDERED: 2
};
Object.freeze(Application.RENDER_STATES);

/**
 * An abstract pattern for defining an Application responsible for updating some object using an HTML form
 *
 * A few critical assumptions:
 * 1) This application is used to only edit one object at a time
 * 2) The template used contains one (and only one) HTML <form> as it's outer-most element
 * 3) This abstract layer has no knowledge of what is being updated, so the implementation must define _updateObject
 *
 * @type {Application}
 *
 * @param object {*}                    Some object or entity which is the target to be updated.
 * @param [options] {Object}            Additional options which modify the rendering of the sheet.
 */
class FormApplication extends Application {
  constructor(object, options) {
    super(options);

    /**
     * The object target which we are using this form to modify
     * @type {*}
     */
    this.object = object;

    /**
     * A convenience reference to the form HTMLElement
     * @type {HTMLElement}
     */
    this.form = null;

    /**
     * Keep track of any FilePicker instances which are associated with this form
     * The values of this Array are inner-objects with references to the FilePicker instances and other metadata
     * @type {Array}
     */
    this.filepickers = [];

    /**
     * Keep track of any mce editors which may be active as part of this form
     * The values of this Array are inner-objects with references to the MCE editor and other metadata
     * @type {Object}
     */
    this.editors = {};
  }

	/* -------------------------------------------- */

  /**
   * Assign the default options which are supported by the entity edit sheet.
   * In addition to the default options object supported by the parent Application class, the Form Application
   * supports the following additional keys and values:
   *
   * @returns {Object} options                    The default options for this FormApplication class, see Application
   * @returns {boolean} options.closeOnSubmit     Whether to automatically close the application when it's contained
   *                                              form is submitted. Default is true.
   * @returns {boolean} options.submitOnChange    Whether to automatically submit the contained HTML form when an input
   *                                              or select element is changed. Default is false.
   * @returns {boolean} options.submitOnClose     Whether to automatically submit the contained HTML form when the
   *                                              application window is manually closed. Default is false.
   * @returns {boolean} options.editable          Whether the application form is editable - if true, it's fields will
   *                                              be unlocked and the form can be submitted. If false, all form fields
   *                                              will be disabled and the form cannot be submitted. Default is true.
   */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    classes: ["form"],
      closeOnSubmit: true,
      submitOnChange: false,
      submitOnClose: false,
      editable: true
    });
  }

	/* -------------------------------------------- */

  /**
   * Is the Form Application currently editable?
   * @type {boolean}
   */
	get isEditable() {
	  return this.options.editable;
  }

	/* -------------------------------------------- */
  /*  Rendering                                   */
	/* -------------------------------------------- */

  /** @override */
  getData(options) {
    return {
      object: duplicate(this.object),
      options: this.options
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async _render(...args) {
    let focus = this.element.find(":focus");
    focus = focus.length ? focus[0] : null;
    await super._render(...args);
    if ( focus && focus.name ) {
      this.form[focus.name].focus();
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async _renderInner(...args) {
    const html = await super._renderInner(...args);
    this.form = html[0];
    return html;
  }

  /* -------------------------------------------- */

  /**
   * A helper function to transform an HTML form into a FormData object which is ready for dispatch
   * @param {HTMLElement} form    The form-type HTMLElement
   * @return {FormData}           The prepared FormData object
   * @private
   */
  _getFormData(form) {
    const FD = new FormData(form);
    const dtypes = {};
    const editorTargets = Object.keys(this.editors);

    // Always include checkboxes
    for ( let el of form.elements ) {
      if ( !el.name ) continue;

      // Handle Radio groups
      if ( form[el.name] instanceof RadioNodeList ) {
        const inputs = Array.from(form[el.name]);
        if ( inputs.every(i => i.disabled) ) FD.delete(k);
        let values = inputs.map(i => i.type === "checkbox" ? i.checked || false : i.value);
        FD.set(el.name, JSON.stringify(values));
        dtypes[el.name] = "Radio";
      }

      // Remove disabled elements
      else if ( el.disabled ) FD.delete(el.name);

      // Checkboxes
      else if ( el.type === "checkbox" ) {
        FD.set(el.name, el.checked || false);
        dtypes[el.name] = "Boolean";
      }

      // Include dataset dtype
      else if ( el.dataset.dtype ) dtypes[el.name] = el.dataset.dtype;
    }

    // Process editable images
    for ( let img of form.querySelectorAll('img[data-edit]') ) {
      if ( img.getAttribute("disabled") ) continue;
      let basePath = window.location.origin+"/";
      if ( ROUTE_PREFIX ) basePath += ROUTE_PREFIX+"/";
      FD.set(img.dataset.edit, img.src.replace(basePath, ""));
    }

    // Process editable divs (excluding MCE editors)
    for ( let div of form.querySelectorAll('div[data-edit]') ) {
      if ( div.getAttribute("disabled") ) continue;
      else if ( editorTargets.includes(div.dataset.edit) ) continue;
      FD.set(div.dataset.edit, div.innerHTML.trim());
    }

    // Handle MCE editors
    Object.values(this.editors).forEach(ed => {
      if ( ed.mce ) {
        FD.delete(ed.mce.id);
        if ( ed.changed ) FD.set(ed.target, ed.mce.getContent());
      }
    });

    // Record target data types for casting
    FD._dtypes = dtypes;
    return FD;
  }

	/* -------------------------------------------- */
	/*  Event Listeners and Handlers                */
	/* -------------------------------------------- */

  /**
   * Activate the default set of listeners for the Entity sheet
   * These listeners handle basic stuff like form submission or updating images
   *
   * @param html {JQuery}     The rendered template ready to have listeners attached
   */
	activateListeners(html) {
	  super.activateListeners(html);

    // Disable input fields if the form is not editable
    if ( !this.isEditable ) {
      this._disableFields(this.form);
      return
    }

    // Process form submission
    this.form.onsubmit = this._onSubmit.bind(this);

    // Maybe process unfocus events
    if ( this.options.submitOnUnfocus ) {
      console.warn("The submitOnUnfocus option for FormApplication subclass has been deprecated in favor of submitOnChange")
    }

    // Maybe process input change events
    if ( this.options.submitOnChange ) {
      html.find("input,select").change(this._onChangeInput.bind(this));
    }

    // Detect and activate TinyMCE rich text editors
    html.find('.editor-content[data-edit]').each((i, div) => this._activateEditor(div));

    // Detect and activate file-picker buttons
    html.find('button.file-picker').each((i, button) => this._activateFilePicker(button));

    // Color change inputs
    html.find('input[type="color"][data-edit]').change(this._onColorPickerChange.bind(this));

    // Range change inputs
    html.find('input[type="range"]').change(this._onChangeRange.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * If the form is not editable, disable its input fields
   * @param form {HTMLElement}
   * @private
   */
  _disableFields(form) {
    const inputs = ["INPUT", "SELECT", "TEXTAREA", "BUTTON"];
    for ( let i of inputs ) {
      for ( let el of form.getElementsByTagName(i) ) el.setAttribute("disabled", "");
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle the change of a color picker input which enters it's chosen value into a related input field
   * @private
   */
  _onColorPickerChange(event) {
    event.preventDefault();
    let input = event.target,
        form = input.form;
    form[input.dataset.edit].value = input.value;
  }

  /* -------------------------------------------- */

  /**
   * Handle standard form submission steps
   * @param {Event} event             The submit event which triggered this handler
   * @param {Object|null} updateData  Additional specific data keys/values which override or extend the contents of
   *                                  the parsed form. This can be used to update other flags or data fields at the
   *                                  same time as processing a form submission to avoid multiple database operations.
   * @param {Boolean} preventClose    Override the standard behavior of whether to close the form on submit
   * @returns {Promise}               A promise which resolves to the validated update data
   * @private
   */
  async _onSubmit(event, {updateData=null, preventClose=false}={}) {
    event.preventDefault();
    if ( !this.rendered || !this.options.editable || this._submitting ) return false;
    this._submitting = true;

    // Acquire and validate Form Data
    const form = this.element.find("form").first()[0];
    const FD = this._getFormData(form);
    const dtypes = FD._dtypes;

    // Construct update data object by casting form data
    let formData = Array.from(FD).reduce((obj, [k, v]) => {
      let dt = dtypes[k];
      if ( dt === "Number" ) obj[k] = v !== "" ? Number(v) : null;
      else if ( dt === "Boolean" ) obj[k] = v === "true";
      else if ( dt === "Radio" ) obj[k] = JSON.parse(v);
      else obj[k] = v;
      return obj;
    }, {});

    // Incorporate any additional provided updateData
    if ( updateData && (typeof updateData === "object") ) {
      formData = mergeObject(formData, updateData);
    }

    // Trigger the object update
    await this._updateObject(event, formData);
    if ( this.options.closeOnSubmit && !preventClose ) this.close();
    this._submitting = false;
    return formData;
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to an input element, submitting the form if options.submitOnChange is true
   * @param {Event} event  The initial change event
   * @private
   */
  _onChangeInput(event) {
    this._onSubmit(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to a range type input by propagating those changes to the sibling range-value element
   * @param {Event} event  The initial change event
   * @private
   */
  _onChangeRange(event) {
	  event.preventDefault();
	  const field = event.target.parentElement.querySelector(".range-value");
	  if ( field ) {
	    if ( field.tagName === "INPUT" ) field.value = event.target.value;
	    else field.innerHTML = event.target.value;
    }
  }

  /* -------------------------------------------- */

  /**
   * This method is called upon form submission after form data is validated
   * @param event {Event}       The initial triggering submission event
   * @param formData {Object}   The object of validated form data with which to update the object
   * @returns {Promise}         A Promise which resolves once the update operation has completed 
   * @abstract
   */
  async _updateObject(event, formData) {
    throw new Error("A subclass of the FormApplication must implement the _updateObject method.");
  }

  /* -------------------------------------------- */
  /*  TinyMCE Editor
  /* -------------------------------------------- */

  /**
   * Activate a TinyMCE editor instance present within the form
   * @param div {HTMLElement}
   * @private
   */
  _activateEditor(div) {

    // Get the editor content div
    let target = div.getAttribute("data-edit"),
        button = div.nextElementSibling,
        hasButton = button && button.classList.contains("editor-edit"),
        wrap = div.parentElement.parentElement,
        wc = $(div).parents(".window-content")[0];

    // Determine the preferred editor height
    let heights = [wrap.offsetHeight, wc ? wc.offsetHeight : null];
    if ( div.offsetHeight > 0 ) heights.push(div.offsetHeight);
    let height = Math.min(...heights.filter(h => Number.isFinite(h)));

    // Get initial content
    const data = this.object instanceof Entity ? this.object.data : this.object,
          initialContent = getProperty(data, target);

    // Add record to editors registry
    this.editors[target] = {
      target: target,
      button: button,
      hasButton: hasButton,
      mce: null,
      active: !hasButton,
      changed: false
    };

    // Define editor options
    let editorOpts = {
      target: div,
      height: height,
      setup: mce => this.editors[target].mce = mce,
      save_onsavecallback: mce => {
        this._onEditorSave(target, mce.getElement(), mce.getContent());
        if (hasButton) {
          mce.remove();
          button.style.display = "block";
        }
      }
    };

    // If we are using a toggle button, delay activation until it is clicked
    if (hasButton) button.onclick = event => {
      this.editors[target].changed = false;
      this.editors[target].active = true;
      button.style.display = "none";
      editorOpts["height"] = div.offsetHeight;
      this._createEditor(target, editorOpts, initialContent);

    };
    else this._createEditor(target, editorOpts, initialContent);
  }

  /* -------------------------------------------- */

  /**
   * Encapsulate TinyMCE text editor creation for instances of this FormApplication.
   * This method allows a targeted way for subclasses to implement a custom TinyMCE editor instead of the default.
   * @private
   */
  _createEditor(target, editorOptions, initialContent) {
    TextEditor.create(editorOptions, initialContent).then(mce => {
      const editor = mce[0];
      editor.focus();
      editor.on('change', ev => this.editors[target].changed = true);
    });
  }

  /* -------------------------------------------- */

  /**
   * By default, when the editor is saved treat it as a form submission event
   * @private
   */
  _onEditorSave(target, element, content) {
    element.innerHTML = content;
    const formData = validateForm(this.form);
    let event = new Event("mcesave");

    // Remove the MCE from the set of active editors
    this.editors[target].active = false;
    this.editors[target].mce.destroy();
    this.editors[target].mce = null;

    // Update the form object
    this._updateObject(event, formData);
  }

  /* -------------------------------------------- */
  /*  FilePicker UI
  /* -------------------------------------------- */

  /**
   * Activate a FilePicker instance present within the form
   * @param button {HTMLElement}
   * @private
   */
  _activateFilePicker(button) {
    button.onclick = event => {
      event.preventDefault();
      let target = button.getAttribute("data-target");
      let fp = FilePicker.fromButton(button);
      this.filepickers.push({
        target: target,
        app: fp
      });
      fp.browse();
    }
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Extend the logic applied when the application is closed to destroy any remaining MCE instances
   * This function returns a Promise which resolves once the window closing animation concludes
   *
   * @param {boolean} options.submit      Explicitly specify whether or not to submit the form when closing. Default
   *                                      behavior uses the value of FormApplication.options.submitOnClose.
   * @return {Promise}
   */
  async close(options={}) {
    if ( !this.rendered ) return;

    // Optionally trigger a save
    const submit = options.hasOwnProperty("submit") ? options.submit : this.options.submitOnClose;
    if ( submit ) this.submit();

    // Close any open FilePicker instances
    this.filepickers.forEach(fp => {
      if ( fp.app ) fp.app.close();
    });
    this.filepickers = [];

    // Close any open MCE editors
    Object.values(this.editors).forEach(ed => {
      if ( ed.mce ) ed.mce.destroy();
    });
    this.editors = {};

    // Close the application itself
    return super.close();
  }

  /* -------------------------------------------- */

  /**
   * Submit the contents of a Form Application, processing its content as defined by the Application
   * @param {Object|null} updateData   Additional data updates to submit in addition to those parsed from the form
   * @returns {FormApplication}       Return a self-reference for convenient method chaining
   */
  submit({updateData=null}={}) {
    if ( this._submitting ) return; 
    const submitEvent = new Event("submit");
    this._onSubmit(submitEvent, {updateData, preventClose: true});
    return this;
  }
}


/* -------------------------------------------- */


/**
 * Extend the FormApplication pattern to incorporate specific logic for viewing or editing Entity instances.
 * See the FormApplication documentation for more complete description of this interface.
 *
 * @extends {FormApplication}

 * @param {Entity} object                           An Entity which should be managed by this form sheet.
 * @param {Object} [options]                        Optional configuration parameters for how the form behaves.
 */
class BaseEntitySheet extends FormApplication {
  constructor(object, options) {
    super(object, options);

    // Register the sheet as an active Application for the Entity
    this.entity.apps[this.appId] = this;
  }

	/* -------------------------------------------- */

  /**
   * A convenience accessor for the object property, which in the case of a BaseEntitySheet is an Entity instance.
   * @type {Entity}
   */
	get entity() {
	  return this.object;
  }

	/* -------------------------------------------- */

	get isEditable() {
	  return this.options.editable && this.entity.owner;
  }

	/* -------------------------------------------- */

	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    classes: ["sheet"],
      template: `templates/sheets/${this.name.toLowerCase()}.html`
    });
  }

	/* -------------------------------------------- */

  get title() {
    return this.entity.name;
  }

	/* -------------------------------------------- */

  getData() {
    let isOwner = this.entity.owner;
    return {
      entity: duplicate(this.entity.data),
      owner: isOwner,
      limited: this.entity.limited,
      options: this.options,
      editable: this.isEditable,
      cssClass: isOwner ? "editable" : "locked"
    }
  }

	/* -------------------------------------------- */

  _getHeaderButtons() {
    let buttons = super._getHeaderButtons();
    if ( this.entity.compendium ) {
      buttons.unshift({
        label: "Import",
        class: "import",
        icon: "fas fa-download",
        onclick: async ev => {
          await this.close();
          const packName = this.entity.compendium.collection;
          this.entity.collection.importFromCollection(packName, this.entity._id);
        }
      });
    }
    return buttons
  }

	/* -------------------------------------------- */

  _updateObject(event, formData) {
    formData["_id"] = this.object._id;
    this.entity.update(formData);
  }
}

/* -------------------------------------------- */

/**
 * A helper class which assists with localization and string translation
 */
class Localization {
  constructor() {

    /**
     * The target language for localization
     * @type {String}
     */
    this.lang = null;

    /**
     * The translation dictionary for the target language
     * @type {Object}
     */
    this.translations = {};

    /**
     * Fallback translations if the target keys are not found
     * @type {Object}
     */
    this._fallback = {};
  }

	/* -------------------------------------------- */

  /**
   * Initialize the Localization module
   * Discover available language translations and apply the current language setting
   * @return {Promise}
   */
  async initialize() {
    const lang = await game.settings.get("core", "language");
    this._discoverLanguages();
    await this.setLanguage(lang);
  }

	/* -------------------------------------------- */

  /**
   * Discover the available supported languages from the set of packages which are provided
   * @private
   */
  _discoverLanguages() {
    const sl = CONFIG.supportedLanguages;

    // Define packages
    const systems = game.data.systems || [game.data.system.data];
    const modules = game.data.modules;
    const worlds =  game.data.worlds || [game.data.world];
    const packages = systems.concat(modules).concat(worlds).map(p => p.data || p);

    // Discover and register languages
    packages.filter(p => p.languages.length).forEach(p => {
      for ( let l of p.languages ) {
        if ( !sl.hasOwnProperty(l.lang) ) {
          sl[l.lang] = l.name;
        }
      }
    });
  }

	/* -------------------------------------------- */

  /**
   * Prepare the dictionary of translation strings for the requested language
   * @param {String} lang     The language for which to load translations
   * @return {Promise}
   * @private
   */
  async _getTranslations(lang) {
    const translations = {};
    const loadOrder = [];

    // Check for core provided translation
    let coreDict = `lang/${lang}.json`;
    if ( await srcExists(coreDict) ) loadOrder.push(coreDict);

    // Add game system translations
    if ( game.system ) {
      let sl = game.system.data.languages.find(l => l.lang === lang);
      if ( sl ) loadOrder.push(`systems/${game.system.data.name}/${sl.path}`);
    }

    // Add module translations
    if ( game.modules ) {
      for ( let module of game.modules.values() ) {
        let ml = module.data.languages.find(l => l.lang === lang);
        if ( ml ) loadOrder.push(`modules/${module.data.name}/${ml.path}`);
      }
    }

    // Load the translations sequentially, overwriting any previously defined strings
    for ( let src of loadOrder ) {
      let json = await this._loadTranslationFile(src);
      mergeObject(translations, json, {inplace: true});
    }

    // Return the prepared translations
    return translations;
  }

	/* -------------------------------------------- */

  /**
   * Load a single translation file and return its contents as processed JSON
   * @param {String} src    The translation file path to load
   * @private
   */
  async _loadTranslationFile(src) {
    const resp = await fetch(src);
    if ( resp.status !== 200 ) {
      console.warn(`${vtt} | Unable to load requested localization file ${src}`);
      return {};
    }
    return resp.json().then(json => {
      console.log(`${vtt} | Loaded localization file ${src}`);
      return json;
    }).catch(err => {
      console.error(`Unable to parse localization file ${src}: ${err}`);
      return {};
    });
  }

	/* -------------------------------------------- */

  /**
   * Set a language as the active translation source for the session
   * @param {String} lang       A language string in CONFIG.supportedLanguages
   * @return {Promise}          A Promise which resolves once the translations for the requested language are ready
   */
  async setLanguage(lang) {
    if ( !Object.keys(CONFIG.supportedLanguages).includes(lang) ) {
      console.error(`Cannot set language ${lang}, as it is not in the supported set. Falling back to English`);
      lang = "en";
    }

    // Set the language and load translations
    this.lang = lang;
    this.translations = await this._getTranslations(lang);

    // Load English translations as fallback
    if ( lang !== "en" ) this._fallback = await this._getTranslations("en");
  }

	/* -------------------------------------------- */

  /**
   * Localize a string by drawing a translation from the available translations dictionary, if available
   * If a translation is not available, the original string is returned
   * @param {String} stringId     The string ID to translate
   * @return {String}             The translated string
   */
  localize(stringId) {
    return getProperty(this.translations, stringId) || getProperty(this._fallback, stringId) || stringId;
  }
}


// Register Handlebars Extensions
HandlebarsIntl.registerWith(Handlebars);

// Global template cache
_templateCache = {};


/**
 * Get a template from the server by fetch request and caching the retrieved result
 * @param {string} path   The web-accessible HTML template URL
 * @returns {Promise}			A Promise which resolves to the compiled template or null
 */
async function getTemplate(path) {
	if ( !_templateCache.hasOwnProperty(path) ) {
    await new Promise(resolve => {
    	game.socket.emit('template', path, resp => {
	      compiled = Handlebars.compile(resp.html);
	      Handlebars.registerPartial(path, compiled);
	      _templateCache[path] = compiled;
	      console.log(`Foundry VTT | Retrieved and compiled template ${path}`);
	      resolve(compiled);
	    });
    });
	} 
	return _templateCache[path];
}

/* -------------------------------------------- */

/**
 * Load and cache a set of templates by providing an Array of paths
 * @param {Array} paths
 * @return {Promise}
 */
async function loadTemplates(paths) {
  for ( let p of paths ) {
    await getTemplate(p);
  }
}

/* -------------------------------------------- */


/**
 * Get and render a template using provided data and handle the returned HTML
 * Support asynchronous file template file loading with a client-side caching layer
 *
 * Allow resolution of prototype methods and properties since this all occurs within the safety of the client.
 * @see {@link https://handlebarsjs.com/api-reference/runtime-options.html#options-to-control-prototype-access}
 *
 * @param {String} path             The file path to the target HTML template
 * @param {Object} data             A data object against which to compile the template
 *
 * @return {Promise.<HTMLElement>}  Returns the rendered HTML
 */
function renderTemplate(path, data) {
  return getTemplate(path).then(template => {
    return template(data || {}, {
      allowProtoMethodsByDefault: true,
      allowProtoPropertiesByDefault: true
    });
  });
}


/* -------------------------------------------- */


/**
 * A Handlebars helper to set an <option> within a <select> block as selected based on its value
 * Escape the string as handlebars would, then escape any regexp characters in it
 */
Handlebars.registerHelper('select', function (selected, options) {
  const escapedValue = RegExp.escape(Handlebars.escapeExpression(selected));
  const rgx = new RegExp(' value=\"' + escapedValue + '\"');
  const html = options.fn(this);
  return html.replace(rgx, "$& selected");
});

/* -------------------------------------------- */


Handlebars.registerHelper('checked', function(value, options) {
  return Boolean(value) ? "checked" : "";
});


/* -------------------------------------------- */

/**
 * An Handlebars helper to format numbers
 */
Handlebars.registerHelper('numberFormat', function (value, options) {

  // Helper parameters
  let dec = (options.hash['decimals'] !== undefined) ? options.hash['decimals'] : 0,
     sign = options.hash['sign'] || false;

  // Parse to float
  value = parseFloat(value).toFixed(dec);

  // Attach sign
  if (sign ) {
    return ( value >= 0 ) ? "+"+value : value;
  } else {
    return value;
  }
});


/* -------------------------------------------- */

Handlebars.registerHelper('timeSince', function(value, options) {
  return timeSince(value);
});

/* -------------------------------------------- */


/**
 * Render a file-picker button linked to an <input> field
 */
Handlebars.registerHelper('filePicker', function(options) {
  let type = options.hash['type'],
      target = options.hash['target'];
  if ( !target ) throw new Error("You must define the name of the target field.");

  // Construct the HTML
  return new Handlebars.SafeString(`
  <button type="button" class="file-picker" data-type="${type}" data-target="${target}" title="Browse Files" tabindex="-1">
      <i class="fas fa-file-import fa-fw"></i>
  </button>`);
});


/* -------------------------------------------- */


/**
 * Render a MCE editor container with an optional toggle button
 */
Handlebars.registerHelper('editor', function(options) {
  let target = options.hash['target'],
      content = options.hash['content'] || "",
      button = Boolean(options.hash['button']),
      owner = Boolean(options.hash['owner']),
      editable = Boolean(options.hash['editable']);
  if ( !target ) throw new Error("You must define the name of a target field.");

  // Enrich the content
  content = TextEditor.enrichHTML(content, {secrets: owner, entities: true});

  // Construct the HTML
  let editor = $(`<div class="editor"><div class="editor-content" data-edit="${target}">${content}</div></div>`);

  // Append edit button
  if ( button && editable ) editor.append($('<a class="editor-edit"><i class="fas fa-edit"></i></a>'));
  return new Handlebars.SafeString(editor[0].outerHTML);
});



/* -------------------------------------------- */


Handlebars.registerHelper('localize', function(value, options) {
  return game.i18n.localize(value);
});

/* -------------------------------------------- */

/* Global Variables */
let socket = null;
let canvas = null;
let keyboard = null;
let game = {};
const ui = {
  windows: {}
};
const vtt = CONST.vtt;

/**
 * The core Game instance which encapsulates the data, settings, and states relevant for managing the game experience.
 * The singleton instance of the Game class is available as the global variable ``game``.
 *
 * @param {Object} worldData    An object of all the World data vended by the server when the client first connects
 * @param {string} sessionId    The ID of the currently active client session retrieved from the browser cookie
 * @param {Socket} socket       The open web-socket which should be used to transact game-state data
 */
class Game {
  constructor(worldData, sessionId, socket) {

    /**
     * The object of world data passed from the server
     * @type {Object}
     */
    this.data = worldData;

    /**
     * Localization support
     * @type {Localization}
     */
    this.i18n = new Localization();

    /**
     * The Keyboard Manager
     * @type {KeyboardManager}
     */
    this.keyboard = null;

    /**
     * A mapping of installed modules
     * @type {Map}
     */
    this.modules = new Map(worldData.modules.map(m => [m.id, m]));

    /**
     * The user role permissions setting
     * @type {Object}
     */
    this.permissions = null;

    /**
     * The client session id which is currently active
     * @type {string}
     */
    this.sessionId = sessionId;

    /**
     * Client settings which are used to configure application behavior
     * @type {ClientSettings}
     */
    this.settings = new ClientSettings(this.data.settings || []);

    /**
     * A reference to the open Socket.io connection
     * @type {WebSocket}
     */
    this.socket = socket;

    /**
     * The id of the active World user, if any
     * @type {string}
     */
    this.userId = worldData.userId || null;

    /**
     * A singleton instance of the Audio Helper class
     * @type {AudioHelper}
     */
    this.audio = new AudioHelper();

    /**
     * A singleton instance of the Video Helper class
     * @type {VideoHelper}
     */
    this.video = new VideoHelper();

    /**
     * Whether the Game is running in debug mode
     * @type {Boolean}
     */
    this.debug = false;

    /**
     * A flag for whether texture assets for the game canvas are currently loading
     * @type {boolean}
     */
    this.loading = false;

    /**
     * A flag for whether the Game has successfully reached the "ready" hook
     * @type {boolean}
     */
    this.ready = false;
  }

  /* -------------------------------------------- */

  /**
   * Fetch World data and return a Game instance
   * @return {Promise}  A Promise which resolves to the created Game instance
   */
  static async create() {

    // Display ASCII welcome
    console.log(`_______________________________________________________________
 _____ ___  _   _ _   _ ____  ______   __ __     _______ _____ 
|  ___/ _ \\| | | | \\ | |  _ \\|  _ \\ \\ / / \\ \\   / |_   _|_   _|
| |_ | | | | | | |  \\| | | | | |_) \\ V /   \\ \\ / /  | |   | |  
|  _|| |_| | |_| | |\\  | |_| |  _ < | |     \\ V /   | |   | |  
|_|   \\___/ \\___/|_| \\_|____/|_| \\_\\|_|      \\_/    |_|   |_|  
===============================================================`);

    // Retrieve the client session from cookies
    const cookies = Game.getCookies();
    const sessionId = cookies.session;
    if ( !sessionId ) {
      console.error(`No client session ID available, redirecting to login`);
      window.location.href = ROUTE_PREFIX ? `/${ROUTE_PREFIX}/join` : "/join";
    }
    console.log(`${vtt} | Attempting connection using session ${sessionId}`);

    // Connect to the game socket, passing the client session ID to handshake
    const socket = await this.connect(sessionId);
    console.log(`${vtt} | Connected to server socket using session ${sessionId}`);

    // Fetch World data, or Setup data if no world is configured
    let gameData = await this.getWorldData(socket);
    if ( !gameData.world ) gameData = await this.getSetupData(socket);

    // Create the Game instance
    return new Game(gameData, sessionId, socket);
  }

  /* -------------------------------------------- */

  /**
   * Establish a live connection to the game server through the socket.io URL
   * @param {string} sessionId  The client session ID with which to establish the connection
   * @return {Promise}          A promise which resolves to the connected socket, if successful
   */
  static async connect(sessionId) {
    const socketPath = ROUTE_PREFIX ? `/${ROUTE_PREFIX}/socket.io` : "/socket.io";
    return new Promise((resolve, reject) => {
      const socket = io.connect({
        path: socketPath,
        reconnection: true,
        reconnectionDelay: 1000,
        reconnectionAttempts: 3,
        reconnectionDelayMax: 5000,
        query: { session: sessionId }
      });
      socket.on("connect", () => resolve(socket));
      socket.on("connectTimeout", timeout => {
        reject(new Error("Failed to establish a socket connection within allowed timeout."))
      });
      socket.on("connectError", err => reject(err));
    });
  }


  /* -------------------------------------------- */

  /**
   * Retrieve the cookies which are attached to the client session
   * @return {Object}   The session cookies
   */
  static getCookies() {
    const cookies = {};
    for (let cookie of document.cookie.split('; ')) {
      let [name, value] = cookie.split("=");
      cookies[name] = decodeURIComponent(value);
    }
    return cookies;
  }

  /* -------------------------------------------- */

  /**
   * Request World data from server and return it
   * @return {Promise}
   */
  static async getWorldData(socket) {
    return new Promise(resolve => {
      socket.emit("world", resolve);
    })
  }

  /* -------------------------------------------- */

  /**
   * Request setup data from server and return it
   * @return {Promise}
   */
  static async getSetupData(socket) {
    return new Promise(resolve => {
      socket.emit("getSetupData", resolve);
    })
  }

  /* -------------------------------------------- */

  /**
   * Initialize the Game for the current window location
   */
  async initialize() {
    console.log(`${vtt} | Initializing session`);
    this.ready = false;
    Hooks.callAll('init');

    // Begin loading fonts
    loadFont("Signika");
    loadFont("FontAwesome");

    // Register game settings
    this.registerSettings();

    // Initialize language translations
    await this.i18n.initialize();

    // Activate event listeners
    this.activateListeners();

    // Initialize client view
    const url = window.location.pathname;
    if (/\/game/.test(url)) return this._initializeGameView();
    else if (/\/setup/.test(url)) return this._initializeSetupView();
    else if (/\/join/.test(url)) return this._initializeJoinView();
    else if (/\/stream/.test(url)) return this._initializeStreamView();
    else if (/\/players/.test(url)) return this._initializePlayersView();
  }

  /* -------------------------------------------- */

  /**
   * Shut down the currently active Game. Requires GameMaster user permission.
   * @return {Promise}
   */
  async shutDown() {
    const resp = await SetupConfiguration.post({shutdown: true});
    if ( resp.status === 200  && resp.redirected ) window.location.href = resp.url;
  }

  /* -------------------------------------------- */
  /*  Primary Game Initialization
  /* -------------------------------------------- */

  /**
   * Fully set up the game state, initializing Entities, UI applications, and the Canvas
   */
  async setupGame() {
    Hooks.callAll('setup');

    // Store permission settings
    this.permissions = await this.settings.get("core", "permissions");

    // Initialization Steps
    this.initializeEntities();
    await this.initializePacks();
    this.initializeRTC();
    this.initializeUI();
    await this.initializeCanvas();
    this.initializeKeyboard();
    this.openSockets();

    // Register sheet preferences
    EntitySheetConfig.initializeSheets();

    // If the player is not a GM and does not have an impersonated character, prompt for selection
    if (!this.user.isGM && !this.user.character) {
      new PlayerConfig(this.user).render(true);
    }

    // Call all game ready hooks
    this.ready = true;
    Hooks.callAll('ready');
  }

  /* -------------------------------------------- */

  /**
   * Initialize game state data by creating Collections for all Entity types
   */
  initializeEntities() {
    this.users = new Users(this.data.users).initialize();
    this.messages = new Messages(this.data.messages).initialize();
    this.scenes = new Scenes(this.data.scenes).initialize();
    this.actors = new Actors(this.data.actors).initialize();
    this.items = new Items(this.data.items).initialize();
    this.journal = new Journal(this.data.journal).initialize();
    this.macros = new Macros(this.data.macros).initialize();
    this.playlists = new Playlists(this.data.playlists).initialize();
    this.combats = new CombatEncounters(this.data.combat).initialize();
    this.tables = new RollTables(this.data.tables).initialize();
    this.folders = new Folders(this.data.folders).initialize();
  }

  /* -------------------------------------------- */

  /**
   * Initialization actions for compendium packs
   */
  async initializePacks() {
    const visibility = await game.settings.get("core", "compendiumVisibility");
    this.packs = this.data.packs.map(metadata => {
      const pack = new Compendium(metadata);
      if ( visibility[pack.collection] === false ) pack.public = false;
      return pack;
    });
  }


  /* -------------------------------------------- */

  /**
   * Initialize the WebRTC implementation
   */
  initializeRTC() {
    this.webrtc = new WebRTC(new WebRTCSettings());
    this.webrtc.initialize();
  }

  /* -------------------------------------------- */

  /**
   * Initialize core UI elements
   */
  initializeUI() {

    // Initialize all applications
    for ( let [k, cls] of Object.entries(CONFIG.ui) ) {
      ui[k] = new cls();
    }

    // Render some applications
    ui.nav.render(true);
    ui.notifications.render(true);
    ui.sidebar.render(true);
    ui.players.render(true);
    ui.hotbar.render(true);
    ui.webrtc.render(true);
    ui.pause.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Initialize the game Canvas
   */
  async initializeCanvas() {
    if (document.getElementById("board")) {
      try {
        canvas = new Canvas();
        const scene = game.scenes.viewed;
        if ( scene ) await scene.view();
        else await canvas.draw();
      } catch(err) {
        console.error(`Failed to render WebGL canvas! Be sure to read the following error carefully.`);
        console.error(err);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Initialize Keyboard and Mouse controls
   */
  initializeKeyboard() {
    keyboard = this.keyboard = new KeyboardManager();
  }

  /* -------------------------------------------- */

  /**
   * Register core game settings
   */
  registerSettings() {

    // User Role Permissions
    game.settings.register("core", "permissions", {
      name: "Permissions",
      scope: "world",
      default: {},
      type: Object,
      config: false,
      onChange: permissions => {
        game.permissions = permissions;
        ui.controls.initialize();
      }
    });

    // Language preference
    game.settings.register("core", "language", {
      name: "SETTINGS.LangN",
      hint: "SETTINGS.LangL",
      scope: "client",
      config: true,
      default: "en",
      type: String,
      choices: CONFIG.supportedLanguages,
      onChange: lang => window.location.reload()
    });

    // Chat message roll mode
    game.settings.register("core", "rollMode", {
      name: "Default Roll Mode",
      scope: "client",
      config: false,
      default: "roll",
      type: String,
      choices: CONFIG.rollModes
    });

    // Register module configuration settings
    game.settings.register("core", ModuleManagement.CONFIG_SETTING, {
      name: "Module Configuration Settings",
      scope: "world",
      config: false,
      default: {},
      type: Object,
      onChange: settings => window.location.reload()
    });

    // Register compendium visibility setting
    game.settings.register("core", "compendiumVisibility", {
      name: "Compendium Visibility Controls",
      scope: "world",
      config: false,
      default: {},
      type: Object,
      onChange: enabled => {
        game.packs.forEach(p => p.public = enabled.hasOwnProperty(p.collection) ? enabled[p.collection] : true);
        ui.compendium.render();
      }
    });

    // Allow Trusted player uploads
    game.settings.register("core", "allowTrustedUpload", {
      name: "SETTINGS.TrustUpN",
      hint: "SETTINGS.TrustUpL",
      scope: "world",
      config: true,
      default: false,
      type: Boolean
    });

    // Combat Tracker Configuration
    game.settings.register("core", Combat.CONFIG_SETTING, {
      name: "Combat Tracker Configuration",
      scope: "world",
      config: false,
      default: {},
      type: Object,
      onChange: setting => {
        game.combats.settings = setting;
        if (game.combat) {
          game.combat.setupTurns();
          game.combats.render();
        }
      }
    });

    // Entity Sheet Class Configuration
    game.settings.register("core", "sheetClasses", {
      name: "Sheet Class Configuration",
      scope: "world",
      config: false,
      default: {},
      type: Object,
      onChange: setting => EntitySheetConfig._updateDefaultSheets(setting)
    });

    // Register game settings which modify behavior of the walls layer
    game.settings.register("core", "playerDoors", {
      name: "SETTINGS.PDoorN",
      hint: "SETTINGS.PDoorL",
      scope: "world",
      config: true,
      default: true,
      type: Boolean
    });

    // Are Chat Bubbles Enabled?
    game.settings.register("core", "chatBubbles", {
      name: "SETTINGS.CBubN",
      hint: "SETTINGS.CBubL",
      scope: "world",
      config: true,
      default: true,
      type: Boolean
    });

    // Pan to Token Speaker
    game.settings.register("core", "chatBubblesPan", {
      name: "SETTINGS.CBubPN",
      hint: "SETTINGS.CBubPL",
      scope: "world",
      config: true,
      default: true,
      type: Boolean
    });

    // Register game settings which configure Message behavior
    game.settings.register("core", "secretMessages", {
      name: "SETTINGS.SMesgN",
      hint: "SETTINGS.SMesgL",
      scope: "world",
      config: true,
      default: true,
      type: Boolean,
      onChange: enabled => ui.chat.render()
    });

    // Soft Shadows
    game.settings.register("core", "softShadows", {
      name: "SETTINGS.SoftSN",
      hint: "SETTINGS.SoftSL",
      config: true,
      default: true,
      type: Boolean,
      onChange: enabled => {
        if ( canvas.sight ) canvas.sight._onChangeSoftShadows(enabled);
      }
    });

    // Default Drawing Configuration
    game.settings.register("core", DrawingsLayer.DEFAULT_CONFIG_SETTING, {
      name: "Default Drawing Configuration",
      scope: "client",
      config: false,
      default: {},
      type: Object
    });

    // Entity-specific settings
    RollTables.registerSettings();
    Macros.registerSettings();

    // Audio playback settings
    AudioHelper.registerSettings();

    // Register CanvasLayer settings
    NotesLayer.registerSettings();
    TemplateLayer.registerSettings();
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The currently connected User entity, or null if Users is not yet initialized
   * @type {User|null}
   */
  get user() {
    return this.users ? this.users.current : null;
  }

  /* -------------------------------------------- */

  /**
   * Metadata regarding the current game World
   * @type {Object}
   */
  get world() {
    return this.data.world;
  }

  /* -------------------------------------------- */

  /**
   * Metadata regarding the game System which powers this World
   * @type {Object}
   */
  get system() {
    return this.data.system;
  }

  /* -------------------------------------------- */

  /**
   * A convenience accessor for the currently viewed Combat encounter
   * @type {Combat}
   */
  get combat() {
    return this.combats.viewed;
  }

  /* -------------------------------------------- */

  /**
   * A state variable which tracks whether or not the game session is currently paused
   * @type {Boolean}
   */
  get paused() {
    return this.data.paused;
  }

  /* -------------------------------------------- */

  /**
   * A convenient reference to the currently active canvas tool
   * @type {String}
   */
  get activeTool() {
    return ui.controls.activeTool;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Toggle the pause state of the game
   * Trigger the `pauseGame` Hook when the paused state changes
   * @param {Boolean} pause     The new pause state
   * @param {Boolean} [push]    Push the pause state change to other connected clients?
   */
  togglePause(pause, push = false) {
    this.data.paused = pause || !this.data.paused;
    if (push && game.user.isGM) game.socket.emit("pause", this.data.paused);

    // Render the paused UI
    ui.pause.render();

    // Call API hooks
    Hooks.callAll("pauseGame", this.data.paused);
  }

  /* -------------------------------------------- */

  /**
   * Log out of the game session by returning to the Join screen
   */
  logOut() {
    if ( this.socket ) this.socket.disconnect();
    window.location.href = ROUTE_PREFIX ? `/${ROUTE_PREFIX}/join` : "/join";
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /**
   * Open socket listeners which transact game state data
   */
  openSockets() {
    AudioHelper.socketListeners(this.socket);
    Game.socketListeners(this.socket);
    Users.socketListeners(this.socket);
    Scenes.socketListeners(this.socket);
    Actors.socketListeners(this.socket);
    Items.socketListeners(this.socket);
    Journal.socketListeners(this.socket);
    RollTables.socketListeners(this.socket);
    Playlists.socketListeners(this.socket);
    CombatEncounters.socketListeners(this.socket);
    Messages.socketListeners(this.socket);
    Folders.socketListeners(this.socket);
    Macros.socketListeners(this.socket);
    ClientSettings.socketListeners(this.socket);
  }

  /* -------------------------------------------- */

  /**
   * General game-state socket listeners and event handlers
   * @param socket
   */
  static socketListeners(socket) {

    // Disconnection and reconnection attempts
    socket.on('disconnect', (reason) => {
      ui.notifications.error("You have lost connection to the server, attempting to re-establish.");
    });

    // Reconnect failed
    socket.on('reconnect_failed', () => {
      ui.notifications.error("Server connection lost.");
      window.location.href = ROUTE_PREFIX+"/no";
    });

    // Reconnect succeeded
    socket.on('reconnect', (attemptNumber) => {
      ui.notifications.info("Server connection re-established.");
    });

    // Handle pause
    socket.on('pause', pause => {
      game.togglePause(pause, false);
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Activate Event Listeners which apply to every Game View
   */
  activateListeners() {

    // Disable touch zoom
    document.addEventListener("touchmove", ev => {
      if (ev.scale !== 1) ev.preventDefault();
    });

    // Disable right-click
    document.addEventListener("contextmenu", ev => ev.preventDefault());

    // Disable mouse 3, 4, and 5
    document.addEventListener("mousedown", ev =>  {
      if ([3, 4, 5].includes(ev.button)) ev.preventDefault();
    });

    // Handle window resizing
    window.addEventListener("resize", event => this._onResize(event));

    // Prevent dragging and dropping unless a more specific handler allows it
    document.addEventListener("dragstart", this._onPreventDragstart);
    document.addEventListener("dragover", this._onPreventDragover);
    document.addEventListener("drop", this._onPreventDrop);

    // Entity links
    TextEditor.activateListeners();
  
    // Await gestures to begin audio and video playback
    game.audio.awaitFirstGesture();
    game.video.awaitFirstGesture();

    // Handle window shutdown/unload events
    window.onbeforeunload = this._onBeforeUnload;
  }

  /* -------------------------------------------- */

  /**
   * Handle window unload operations to clean up any data which may be pending a final save
   * @param {Event} event     The window unload event which is about to occur
   * @private
   */
  _onBeforeUnload(event) {
    if ( canvas && canvas.ready ) canvas.sight.saveFog();
  }

  /* -------------------------------------------- */

  /**
   * Prevent starting a drag and drop workflow on elements within the document unless the element has the draggable
   * attribute explicitly defined or overrides the dragstart handler.
   * @param {DragEvent} event   The initiating drag start event
   * @private
   */
  _onPreventDragstart(event) {
    if ( event.target.getAttribute("draggable") === "true" ) return;
    event.preventDefault();
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Disallow dragging of external content onto anything but a file input element
   * @param {DragEvent} event   The requested drag event
   * @private
   */
  _onPreventDragover(event) {
    const target = event.target;
    if ( (target.tagName === "INPUT") && (target.type === "file") ) return;
    else event.preventDefault();
  }

  /* -------------------------------------------- */

  /**
   * Disallow dropping of external content onto anything but a file input element
   * @param {DragEvent} event   The requested drag event
   * @private
   */
  _onPreventDrop(event) {
    const target = event.target;
    if ( (target.tagName === "INPUT") && (target.type === "file") ) return;
    else event.preventDefault();
  }

  /* -------------------------------------------- */

  /**
   * Handle resizing of the game window
   * Reposition any active UI windows
   * @private
   */
  _onResize(event) {
    Object.values(ui.windows).forEach(app => app.setPosition());
    if (canvas && canvas.ready) canvas._onResize(event)
  }

  /* -------------------------------------------- */
  /*  View Initialization Functions
  /* -------------------------------------------- */

  /**
   * Initialization steps for the primary Game view
   * @private
   */
  async _initializeGameView() {

    // Require a valid user cookie and EULA acceptance
    if ( !SIGNED_EULA ) window.location.href = ROUTE_PREFIX+"/no";
    if (!this.userId) {
      console.error("Invalid user session provided - returning to login screen.");
      this.logOut();
    }

    // Setup the game
    this.setupGame();

    // Set a timeout of 10 minutes before kicking the user off
    setTimeout(() => {
      if (this.user.isGM || !this.data.demo) return;
      console.log(`${vtt} | Ending demo session after 10 minutes. Thanks for testing!`);
      this.logOut();
    }, 1000 * 60 * 10);

    // Context menu listeners
    ContextMenu.eventListeners();

    // Force hyperlinks to a separate window/tab
    $(document).on('click', 'a[href]', ev => {
      if ( ev.target.href === "javascript:void(0)" ) return;
      ev.preventDefault();
      window.open(ev.target.href, "_blank");
    });
  };

  /* -------------------------------------------- */

  /**
   * Initialization steps for the game setup view
   * @private
   */
  async _initializeSetupView() {
    ui.notifications = new Notifications().render(true);

    // No action is required if we are authenticating the admin key
    if ( document.body.classList.contains("auth") ) return;

    // Otherwise display setup apps
    if ( !SIGNED_EULA ) new EULA().render(true);
    ui.setup = new SetupConfigurationForm(game.data).render(true);
  };

  /* -------------------------------------------- */

  /**
   * Initialization steps for the game setup view
   * @private
   */
  async _initializeJoinView() {
    ui.notifications = new Notifications().render(true);
    const form = document.getElementById("join-form");
    form.addEventListener("submit", this._onJoinFormSubmit.bind(this));
  };

  /* -------------------------------------------- */

  /**
   * Handle submission of the Join Game form.
   * Submit a POST request to the server, and either redirect or notify depending on the response.
   * @param {Event} event     The form submission event
   * @return {Promise}
   * @private
   */
  async _onJoinFormSubmit(event) {
    event.preventDefault();

    // Disable the button and collect form data
    const form = event.target;
    form.submit.disabled = true;
    const formData = new FormData(form);

    // Submit a POST request to the server
    const response = await fetch(window.location.pathname, {
      method: "POST",
      body: formData
    }).then(r => r.json());

    // Redirect on success
    if ( response.status === "success" ) {
      ui.notifications.info("Login successful, joining game!");
      setTimeout(() => window.location.href = response.redirect, 500 );
    }

    // Notify on failure
    else if ( response.status === "failed" ) {
      ui.notifications.error(response.error);
      form.submit.disabled = false;
    }
  }

  /* -------------------------------------------- */

  /**
   * Initialization steps for the Stream helper view
   * @private
   */
  async _initializeStreamView() {
    this.users = new Users(this.data.users).initialize();
    this.messages = new Messages(this.data.messages).initialize();
    this.actors = new Actors(this.data.actors).initialize();
    ui.chat = new ChatLog({stream: true}).render(true);
    Messages.socketListeners(this.socket);
  }

  /* -------------------------------------------- */

  /**
   * Initialize the Player Management View
   * @private
   */
  async _initializePlayersView() {
    if ( !SIGNED_EULA ) window.location.href = ROUTE_PREFIX+"/no";
    this.users = new Users(this.data.users).initialize();
    this.players = new UserManagement(this.users);
    this.players.render(true);
    ui.notifications = new Notifications().render(true);
    Users.socketListeners(this.socket);
  };
}

/* -------------------------------------------- */

/**
 * Once the Window has loaded, created and initialize the Game object
 */
window.addEventListener("DOMContentLoaded", async function() {
  game = window.game = await Game.create();
  game.initialize();
}, {once: true, passive: true});

/**
 * The base Die class.
 *
 * Each Die instance represents a distinct term in a roll equation which transacts rolls of an die with some number
 * of faces. The Die instance provides controls for rerolling, exploding, counting, or modifying the set of results
 * from the Die.
 *
 * @param {Number} faces    The number of faces for this Die
 *
 * @example
 * // Define a 6-sided die
 * let die = new Die(6);
 *
 * // Roll the die 4 times
 * die.roll(4);
 *
 * // Roll another 2 times, adding the new results to the existing set
 * die.roll(2);
 *
 * // For all 6 of the initial rolls, reroll if any result was a 1
 * die.reroll([1]);
 *
 * // For set of remaining results, roll a bonus die if any result was a 6
 * die.explode([6]);
 *
 * // Count the total number of rolls which was greater than 3
 * die.countSuccess(3, ">");
 *
 * // Display the total number of successes
 * console.log(die.total);
 */
class Die {
  constructor(faces, options={}) {

    /**
     * The number of faces for this Die
     * @type {Number}
     *
     * @example
     * let die = new Die(6);    // A 6-sided die has six faces
     * console.log(die.faces)   // 6
     */
    this.faces = this._getFaces(faces);

    /**
     * An Array representing the faces of the die
     * @type {Array}
      *
     * @example
     * let die = new Die(6);    // One side for each of the possible faces
     * console.log(die.sides)   // [1,2,3,4,5,6]
     */
    this.sides = (faces instanceof Array) ?
      faces.map(s => parseInt(s)) :
      Array.from(new Array(parseInt(faces))).map((e, i) => i+1);

    /**
     * Track all dice which have ever been rolled
     * @type {Array}
     *
     * @example
     * let die = new Die(4);
     * die.roll(4);             // Roll 4d4
     * console.log(die.rolls);  // [{...}, {...}, {...}, {...}]
     */
    this.rolls = [];

    /**
     * Any additional options which may be required by the Die
     */
    this.options = options;
  }

  /* -------------------------------------------- */

  /**
   * Track the set of kept results out of all rolls
   * @type {Array}
   *
   * @example
   * let die = new Die(6);
   * die.roll(6);               // Roll 6d6
   * console.log(die.results);  // [6,4,1,2,3,4]
   * die.keepHighest(2);        // Keep the 2 best results
   * console.log(die.results);  // [6,4]
   */
  get results() {
    return this.rolls.filter(r => !r.rerolled && !r.discarded).map(r => {
      if ( r.success === true ) return 1;
      else if ( r.success === false ) return 0;
      return r.roll;
    });
  }

  /* -------------------------------------------- */

  /**
   * The sum of all kept results
   * @type {Number}
   *
   * @example
   * let die = new Die(20);
   * die.roll(2);               // Roll 2d20
   * console.log(die.results)   // [6,17]
   * console.log(die.total)     // 23
   */
  get total() {
    const total = this.results.reduce((t, n) => t + n);
    if ( this.options.marginSuccess ) return total - parseInt(this.options.marginSuccess);
    else if ( this.options.marginFailure ) return parseInt(this.options.marginFailure) - total;
    return total;
  }

  /* -------------------------------------------- */

  _getFaces(f) {
    if ( Number.isFinite(f) && f > 0 ) return f;
    else throw new Error(`Invalid number of faces ${f} for Die class`);
  }

  /* -------------------------------------------- */

  /**
   * Roll this Die once
   * @return {Number}
   * @private
   */
  _roll() {
    let res = Math.floor(twist.random() * this.sides.length);
    return {
      roll: this.sides[res]
    };
  }

  /* -------------------------------------------- */

  /**
   * Roll the initial set of results for the Die
   * @param {Number} nd     The number of times to roll the die
   * @return {Die}          The updated die containing new rolls
   *
   * @example
   * let die = new Die(6);
   * die.roll(6);               // Roll 6d6
   * console.log(die.results);  // [5,2,4,4,1,6]
   * console.log(die.total);    // 22
   */
  roll(nd) {
    nd = nd || 1;
    let rolls = [];
    for (let n=1; n <= nd; n++) {
      rolls.push(this._roll());
    }
    this.rolls = this.rolls.concat(rolls);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Re-roll any results with results in the provided target set
   * Dice which have already been re-rolled will not be re-rolled again
   * @param {Array} targets       Target results which would trigger a reroll
   * @return {Die}                The updated die containing new rolls
   *
   * @example
   * let die = new Die(4);
   * die.roll(3);               // Roll 3d4
   * console.log(die.results);  // [1,3,4]
   * die.reroll([1,2]);         // Re-roll 1s or 2s
   * console.log(die.results);  // [3,4,2]
   */
  reroll(targets) {
    if ( !targets || !targets.length ) return this.rolls;

    // Flag dice which are eligible for re-roll
    let eligible = this.rolls.filter(r => {
      if ( r.rerolled || r.discarded ) return false;
      else if ( targets.includes(r.roll) ) return r.rerolled = true;
      return false;
    });

    // Roll any eligible dice
    let rolls = eligible.map(r => this._roll());
    this.rolls = this.rolls.concat(rolls);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Explode the rolls in this set by rolling additional dice for each roll which achieved a certain result
   * Dice which have been re-rolled or have already exploded cannot explode
   * @param {Array} range         The range of target results which would trigger an explode
   * @return {Die}                The updated die containing new rolls
   *
   * @example
   * let die = new Die(8);
   * die.roll(6);               // Roll 6d8
   * console.log(die.results);  // [8,3,6,4,2,7]
   * die.explode([7,8]);        // Explode on 7s and 8s, rolling additional dice
   * console.log(die.results);  // [8,3,6,4,2,7,7,2,3]
   */
  explode(range) {
    if ( !range || !range.length || (range.length === this.faces) ) return this;

    // Explode until there are no valid results left to explode
    let exploding = true,
        rolls = this.rolls;
    while ( exploding ) {

      // Get the dice which are eligible to explode
      let eligible = rolls.filter((r, i) => {
        if (r.rerolled || r.discarded || r.exploded) return false;
        if (range.includes(r.roll)) return r.exploded = true;
        return false;
      });

      // Roll any eligible dice
      rolls = eligible.map(r => this._roll());
      exploding = rolls.length > 0;
      this.rolls = this.rolls.concat(rolls);
    }
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Filter the result set, keeping the highest n results in order
   * @param {Number} n    The number of results to keep
   * @return {Die}        The updated die containing new rolls
   *
   * @example
   * let die = new Die(6);
   * die.roll(4);               // Roll 4d6
   * console.log(die.results);  // [6,2,1,5]
   * die.keepHighest(2);        // Keep the best 2 results
   * console.log(die.results);  // [6,5]
   */
  keepHighest(n) {
    let cut = this.results.sort((a, b) => b - a)[n - 1],
        kept = 0;
    let rolls = this.rolls.filter(r => !r.rerolled && !r.discarded);

    // First drop any results that are strictly lower than the cut
    rolls.forEach(r => {
      if ( r.roll > cut ) ++kept;
      else if ( r.roll < cut ) r.discarded = true;
    });

    // Next keep ties until we have reached the target
    rolls.filter(r => r.roll === cut).forEach(r => {
      if ( kept < n ) ++kept;
      else r.discarded = true;
    });
    return this
  }

  /* -------------------------------------------- */

  /**
   * Filter the result set, keeping the lowest n results in order
   * @param {Number} n    The number of results to keep
   * @return {Array}      The filtered results
   *
   * @example
   * let die = new Die(6);
   * die.roll(4);               // Roll 4d6
   * console.log(die.results);  // [6,2,1,5]
   * die.keepLowest(3);         // Kepe the lowest 3 results
   * console.log(die.results);  // [2,1,5]
   */
  keepLowest(n) {
    let cut = this.results.sort((a, b) => a - b)[n - 1],
        kept = 0;
    let rolls = this.rolls.filter(r => !r.rerolled && !r.discarded);

    // First drop any results that are strictly higher than the cut
    rolls.forEach(r => {
      if ( r.roll < cut ) ++kept;
      else if ( r.roll > cut ) r.discarded = true;
    });

    // Next keep ties until we have reached the target
    rolls.filter(r => r.roll === cut).forEach(r => {
      if ( kept < n ) ++kept;
      else r.discarded = true;
    });
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Map results to 0 or 1 depending on whether they match a success condition
   * @param {Number} target     The target result to test against
   * @param {String} operator   The comparison operator against which to test. Default is '>='
   *
   * @example
   * let die = new Die(3);
   * die.roll(6);               // Roll 6d3
   * console.log(die.results);  // [1,3,1,2,2,3]
   * die.countSuccess(3);       // Count the results where a 3 was rolled
   * console.log(die.results);  // [0,1,0,0,0,1]
   * console.log(die.total);    // 2
   */
  countSuccess(target, operator) {
    operator = operator || ">=";
    this.rolls.forEach(r => {
      if ( r.rerolled || r.discarded ) return;
      if ( (operator === '>=') && Number(r.roll >= target)) r.success = true;
      else if ( (operator === '>') && Number(r.roll > target)) r.success = true;
      else if ( (operator === '=') && Number(r.roll === target)) r.success = true;
      else if ( (operator === '<') && Number(r.roll < target)) r.success = true;
      else if ( (operator === '<=') && Number(r.roll <= target)) r.success = true;
      else r.success = false;
    });
  }

  /* -------------------------------------------- */

  /**
   * Special Die types may optionally define a tooltip used in lieu of the numeric result
   * @param {Number} result   The rolled die result
   * @private
   */
  _getTooltip(result) {
    return result;
  }

  /* -------------------------------------------- */
  /*  Factory Method                              */
  /* -------------------------------------------- */

  /**
   * Given a string formula, create and return a rolled Die object
   * @param {string} formula    The string formula to parse
   * @return {Die|null}         The rolled Die object if the formula was valid, null otherwise
   */
  static fromFormula(formula) {
    const rgx = formula.match(this.rgx.die);
    if ( !rgx ) return null;
    let [number, sides, modifiers] = rgx.slice(1);

    // Get die sides
    let cls = this;
    if ( /f|F/.test(sides) ) cls = FateDie;
    else sides = parseInt(sides);
    if (sides > 10000) throw new Error("You may not roll dice with more than 10000 sides");

    // Get number of dice
    number = parseInt(number || 1);
    if ( !Number.isFinite(number) || number <= 0 ) throw new Error("Invalid number of rolled dice.");
    if ( number > 100 ) throw new Error("You may not roll more than 100 dice at a time");

    // Create the Die and roll it
    let die = new cls(sides);
    die.roll(number);

    // Apply modifiers
    modifiers = modifiers || "";
    die.applyModifiers(modifiers);
    die.formula = `${number}d${sides}${modifiers.toLowerCase()}`;

    // Return the created and rolled Die
    return die;
  }

  /* -------------------------------------------- */
  /*  Roll Modifiers                              */
  /* -------------------------------------------- */

  /**
   * Apply suffix options and modifiers to the result of this Die roll
   * @param {string} query
   */
  applyModifiers(query) {

    // Step 1 - parse query to an Array of modifiers
    let mods = [];
    if ( query ) {
      for ( let r of Object.values(Die.rgx) ) {
        query = query.replace(RegExp(r, "g"), match => match+";");
      }
      mods = query.split(";").filter(o => o !== "");
    }

    // Step 2 - apply modifiers
    for ( let mod of mods ) {
      this._applyReroll(mod);
      this._applyExplode(mod);
      this._applyKeepDrop(mod);
      this._applySuccess(mod);
    }

    // Return the modified Die
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Reroll a single die by parsing the option string
   * @private
   */
  _applyReroll(option) {
    let rr = option.match(Die.rgx.reroll);
    if ( !rr ) return;

    // Determine the reroll range
    let target, nrr = parseInt(rr[2] || 1);
    if ( rr[1] ) {
      if ( rr[1] === "<" )        target = Array.fromRange(nrr);
      else if ( rr[1] === "<=" )  target = Array.fromRange(nrr).map(n => n + 1);
      else if ( rr[1] === ">" )   target = Array.fromRange(this.faces - nrr).map(n => n + nrr + 1);
      else if ( rr[1] === ">=" )  target = Array.fromRange(this.faces - nrr + 1).map(n => n + nrr);
    }
    else target = [nrr];

    // Reroll the die
    this.reroll(target);
  }

  /* -------------------------------------------- */

  /**
   * Explode a single die by parsing the option string
   * @private
   */
  _applyExplode(option) {
    let ex = option.match(Die.rgx.explode);
    if ( !ex ) return;
    let operator = ex[1];
    let target = parseInt(ex[2] || this.faces);

    // Define target arrays
    let range = this.sides.filter(s => {
      if ( operator === "<" ) return s < target;
      else if ( operator === "<=" ) return s <= target;
      else if ( operator === ">" ) return s > target;
      else if ( operator === ">=" ) return s >= target;
      return s === target;
    });

    // Explode the die
    this.explode(range);
  }

  /* -------------------------------------------- */

  /**
   * Keep or drop die by parsing the option string
   * @private
   */
  _applyKeepDrop(option) {
    let kd = option.match(Die.rgx.keep);
    if ( !kd ) return;
    let nr = this.results.length,
        mode = kd[1],
        num = parseInt(kd[2] || 1);

    // Highest
    if ( ["kh", "dl"].includes(mode) ) {
      if ( mode === "dl" ) num = Math.max(nr - num, 1);
      this.keepHighest(num);
    }

    // Lowest
    else if ( ["kl", "dh"].includes(mode) ) {
      if ( mode === "dh" ) num = Math.min(nr - num);
      this.keepLowest(num);
    }
  }

  /* -------------------------------------------- */

  /**
   * Count successes or margin of success
   * @private
   */
  _applySuccess(option) {
    let cs = option.match(Die.rgx.success);
    if ( !cs ) return;
    let mode = cs[1],
        operator = cs[2],
        target = parseInt(cs[3]);

    // Count successes or failures
    if ( ["cs", "cf"].includes(mode) ) {

      // Flip the operator for counting failures
      if (mode === "cf") {
        operator = {
          ">=": "<",
          ">": "<=",
          "<": ">=",
          "<=": ">"
        }[operator];
      }

      // Apply the die function
      this.countSuccess(target, operator);
    }

    // Margin of success or failure
    else if ( mode === "ms" ) {
      if ([">", ">=", "=", undefined].includes(operator) ) this.options["marginSuccess"] = target;
      else if (["<", "<="].includes(operator)) this.options["marginFailure"] = target;
    }
  }
}


/**
 * Define regular expression option matches for the Die class
 * @type {Object}
 */
Die.rgx = {
  die: new RegExp('([0-9]+)?[dD]([0-9fF]+)([a-z][a-z0-9<=>]+)?'),
  reroll: /r(<=|>=|<|>)?([0-9]+)?/,
  explode: /x(<=|>=|<|>)?([0-9]+)?/,
  keep: /(kh|kl|dh|dl)([0-9]+)?/,
  success: /(cs|cf|ms)(<=?|>=?|=)?([0-9]+)?/,
};
/**
 * A special die used by Fate/Fudge systems
 * Mathematically behaves like 1d3-2
 * @type {Die}
 */
class FateDie extends Die {
  constructor() {
    super(3);
    this.sides = [-1, 0, 1];
  }

  /* -------------------------------------------- */

  /**
   * Special Die types may optionally define a tooltip used in lieu of the numeric result
   * @param {Number} result   The rolled die result
   * @private
   */
  _getTooltip(result) {
    return {
      "-1": "-",
      "0": "&nbsp;",
      "1": "+"
    }[result];
  }
}

/**
 * A dice pool represents a set of Roll expressions which are collectively modified to compute an effective total
 * across all Rolls in the pool. The final total for the pool is defined as the sum over kept rolls, relative to any
 * success count or margin.
 *
 * @example
 * // Consider 3 rolls
 * let r1 = new Roll("4d6");
 * let r2 = new Roll("3d8");
 * let r3 = new Roll("2d10");
 * // Keep the highest of the 3 roll expressions
 * let pool = new DicePool([r1,r2,r3], "kh");
 *
 * @example
 * // Construct a DicePool from a string formula
 * let pool = DicePool.fromFormula("{4d6,3d8,2d10}kh");
 */
class DicePool {
  constructor(rolls, modifiers) {

    /**
     * The elements of a Dice Pool must be Roll objects
     * @type {Array.<Roll>}
     */
    this.rolls = rolls;

    /**
     * The string modifiers applied to resolve the pool
     * @type {string}
     */
    this.modifiers = modifiers;

    /**
     * An Array of rolled Die instances created through this Pool
     * @type {Array}
     */
    this.dice = [];

    /**
     * The final numeric total resulting from the rolled DicePool
     * @type {number|null}
     */
    this.total = null;
  }

  /* -------------------------------------------- */

  /**
   * For now, for testing purposes, choose the maximum result always
   */
  roll() {

    // Roll everything in the DicePool
    const dice = [];
    for ( let r of this.rolls ) {
      if ( !r._rolled ) r.roll();
      dice.push(...r.dice);
    }

    // Identify and sort results in ascending order of total
    const results = this.rolls.map(r => {
      return {
        roll: r,
        total: r.total,
        keep: true
      };
    });
    results.sort((a, b) => a.total - b.total);

    // Parse modifiers
    const mods = this._parseModifiers(this.modifiers);
    for ( let mod of mods ) {
      this._keepOrDrop(results, mod);
      this._countSuccess(results, mod);
    }

    // The total for the pool is defined as the sum over kept rolls
    const total = results.reduce((total, r) => {
      if ( r.keep ) total += r.total;
      return total;
    }, 0);

    // Flag the total and return
    this.results = results;
    this.dice = dice;
    this.total = total;
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Parse a modifier query string into an ordered Array of modifiers to apply.
   * @param {string} modifiers
   * @return {Array.<string>}
   * @private
   */
  _parseModifiers(modifiers) {
    let patterns = [DicePool.rgx.keep, DicePool.rgx.success];
    for ( let p of patterns ) {
      modifiers = modifiers.replace(RegExp(p, "g"), "$&;");
    }
    return modifiers.split(";").filter(m => m !== "");
  }

  /* -------------------------------------------- */

  /**
   * Iterate over the results Array and apply a keep-or-drop modifier
   * @param {Array} results
   * @param {string} mod
   * @private
   */
  _keepOrDrop(results, mod) {
    const kd = mod.match(DicePool.rgx.keep);
    if ( !kd ) return;

    // Determine the number to keep or drop
    let mode = kd[1];
    let n = parseInt(kd[2] || 1);

    // Highest
    if ( ["kh", "dl"].includes(mode) ) {
      n = (mode === "kh") ? Math.clamped(n, 1, results.length) : Math.clamped(n, 0, results.length - 1);
      results.forEach((r, i) => r.keep = i >= (results.length - n));
    }

    // Lowest
    else {
      n = (mode === "kl") ? Math.clamped(n, 1, results.length) : Math.clamped(n, 0, results.length - 1);
      results.forEach((r, i) => r.keep = i < (results.length - n));
    }
  }

  /* -------------------------------------------- */

  /**
   * Iterate over the results Array and count successes or compute margin of success
   * @param {Array} results
   * @param {string} mod
   * @private
   */
  _countSuccess(results, mod) {
    const cs = mod.match(DicePool.rgx.success);
    if ( !cs ) return;

    // Determine the threshold for flagging
    let mode = cs[1];
    let operator = cs[2];
    let tgt = parseInt(cs[3]);

    // Count successes
    if ( ["cs", "cf"].includes(mode) ) {
      for ( let r of results ) {
        if ( operator === ">" ) r.total = Number(r.total > tgt);
        else if ( operator === ">=" ) r.total = Number(r.total >= tgt);
        else if ( operator === "<" ) r.total = Number(r.total < tgt);
        else if ( operator === "<=" ) r.total = Number(r.total <= tgt);
        if ( mode === "cf" ) r.total = 1 - r.total;
      }
    }

    // Margin of success
    else if ( mode === "ms" ) {
      for ( let r of results ) {
        if ( [">", ">=", "="].includes(operator) ) r.total = r.total - tgt;
        else r.total = tgt - r.total;
      }
    }
  }

  /* -------------------------------------------- */
  /*  Factory Method                              */
  /* -------------------------------------------- */

  /**
   * Given a string formula, create and return an evaluated DicePool object
   * @param {string} formula    The string formula to parse
   * @return {DicePool|null}    The evaluated DicePool object or null if the formula is invalid
   */
  static fromFormula(formula) {
    const rgx = formula.match(this.rgx.pool);
    if ( !rgx ) return null;
    let [terms, modifiers] = rgx.slice(1);

    // Transform each term of the pool into a Roll instance
    const rolls = terms.split(',').reduce((arr, t) => {
      t = t.trim();
      arr.push(new Roll(t));
      return arr;
    }, []);

    // Create the Pool object
    modifiers = modifiers || "";
    const pool = new this(rolls, modifiers);
    pool.roll();
    return pool;
  }

  /* -------------------------------------------- */
  /*  Serialization and Storage                   */
  /* -------------------------------------------- */

  /**
   * Convert the DicePool instance into an Object which can be serialized to JSON
   * @return {Object}     The converted data
   */
  toJSON() {
      return {
      class: "DicePool",
      rolls: this.rolls.map(r => r.toJSON()),
      total: this.total,
      modifiers: this.modifiers,
    }
  }

  /* -------------------------------------------- */

  /**
   * Reconstruct a DicePool instance from a provided data Object
   * @param {Object} data   The provided data
   * @return {DicePool}     The constructed Dice Pool
   */
  static fromData(data) {

    // Reconstitute inner rolls
    const rolls = data.rolls.map(r => Roll.fromData(r));

    // Reconstitute the pool itself
    const pool = new this(rolls, data.modifiers);

    // Restore additional data
    pool.total = data.total;
    pool.dice = rolls.reduce((dice, r) => {
      dice.push(...r.dice);
      return dice;
    }, []);

    // Return the restored pool
    return pool;
  }

  /* -------------------------------------------- */

  /**
   * Reconstruct a DicePool instance from a provided data Object
   * @param {string} json   The serialized JSON string
   * @return {DicePool}     The constructed Dice Pool
   */
  static fromJSON(json) {
    return this.fromData(JSON.parse(json))
  }
}

DicePool.rgx = {
  pool: RegExp('{([^}]+)}([A-z]{1}[A-z0-9<=>]+)?'),
  keep: /(kh|kl|dh|dl)([0-9]+)?/,
  success: /(cs|cf|ms)(<=?|>=?|=)?([0-9]+)?/
};

/**
 * This class provides an interface and API for conducting dice rolls.
 * The basic structure for a dice roll is a string formula and an object of data against which to parse it.
 *
 * @param formula {String}    The string formula to parse
 * @param data {Object}       The data object against which to parse attributes within the formula
 *
 * @see {link Die}
 * @see {link DicePool}
 *
 * @example
 * // Attack with advantage!
 * let r = new Roll("2d20kh + @prof + @strMod", {prof: 2, strMod: 4});
 *
 * // The parsed components of the roll formula
 * console.log(r.parts);    // [Die, +, 2, +, 4]
 * 
 * // Execute the roll
 * r.roll();
 *
 * // The resulting equation after it was rolled
 * console.log(r.result);   // 16 + 2 + 4
 *
 * // The total resulting from the roll
 * console.log(r.total);    // 22
 */
class Roll {
  constructor(formula, data={}) {

    /**
     * The original provided data
     * @type {Object}
     */
    this.data = data;

    /**
     * The original "raw" formula before any substitutions or evaluation
     * @type {string}
     */
    this._formula = formula;

    /**
     * The processed formula resulting from substitution and evaluation
     * @type {string}
     */
    this.formula = this._replaceData(formula);

    /**
     * An array of evaluate Roll parts
     * @type {Array}
     */
    this.parts = [];

    /**
     * An Array of Die instance which were included as part of this Roll
     * @type {Array.<Die>}
     * @private
     */
    this._dice = [];

    /**
     * An internal flag for whether the Roll object has been rolled
     * @private
     */
    this._rolled = false;

    /**
     * Cache the rolled total to avoid re-evaluating it multiple times
     */
    this._result = null;

    /**
     * Cache the evaluated total to avoid re-evaluating it
     */
    this._total = null;

    /**
     * Regular expression patterns
     */
    this.rgx = {
      dice: new RegExp(this.constructor.rgx.dice),
      pool: new RegExp(this.constructor.rgx.pool),
      reroll: /r(<=|>=|<|>)?([0-9]+)?/,
      explode: /x(<=|>=|<|>)?([0-9]+)?/,
      keep: this.constructor.rgx.keep,
      success: this.constructor.rgx.success,
      parenthetical: /^\((.*)\)$/
    };
  }

  /* -------------------------------------------- */

  /**
   * Replace referenced data attributes in the roll formula with the syntax `@attr` with the corresponding key from
   * the provided `data` object.
   * @param {String} formula    The original formula within which to replace
   * @private
   */
  _replaceData(formula) {
    let dataRgx = new RegExp(/@([a-z.0-9]+)/gi);
    return formula.replace(dataRgx, (match, term) => {
      let value = getProperty(this.data, term);
      return value ? String(value).trim() : "0";
    });
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The resulting arithmetic expression after rolls have been evaluated
   * @return {String}
   */
  get result() {
    return this._result;
  }

  /**
   * Express the total result of the roll and cache the result to avoid re-evaluating
   * @return {Number}
   */
  get total() {
    if ( !this._rolled ) return null;
    return this._total;
  }

  /**
   * Get an Array of any Die objects which were rolled as part of the evaluation of this roll
   * @type {Array.<Die>}
   */
  get dice() {
    if ( !this._rolled ) return null;
    return this._dice;
  }

  /**
   * The regular expression used to identify a Die component of a Roll
   * @private
   * @type {String}
   */
  static get diceRgx() {
    return '([0-9]+)?[dD]([0-9fF]+)([a-z][a-z0-9<=>]+)?';
  }

  static get rgx() {
    return {
      dice: '([0-9]+)?[dD]([0-9fF]+)([a-z][a-z0-9<=>]+)?',
      pool: '{([A-z0-9 ,]+)}([A-z]{1}[A-z0-9<=>]+)'
    }
  }

  /**
   * Record supported arithmetic operators for Roll instances
   * @private
   * @type {Array.<String>}
   */
  static get arithmeticOperators() {
    return ["+", "-", "*", "/"];
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Execute the Roll, replacing dice and evaluating the total result
   * @returns {Roll}    The rolled Roll object, able to be chained into other methods
   *
   * @example
   * let r = new Roll("2d6 + 4 + 1d4");
   * r.roll();
   * > 12
   */
  roll() {
    if ( this._rolled ) throw new Error("This Roll object has already been rolled.");
    const dice = [];

    // Step 1 - first evaluate parenthetical terms as inner Roll instances
    let terms = this._evalParentheticalTerms(this.formula).map(t => {
      if ( t instanceof Roll ) {
        t.roll();
        dice.push(...t.dice);
        return t.total;
      }
      return t;
    });
    this.formula = this.constructor.cleanFormula(terms.join(""));

    // Step 2 - evaluate dice pools
    terms = this._evalPoolTerms(this.formula);

    // Step 3 - separate arithmetic terms
    terms = this._expandArithmeticTerms(terms);

    // Step 4 - evaluate remaining Die terms
    const results = terms.reduce((arr, t) => {

      // Dice Pools
      if ( t instanceof DicePool ) {
        dice.push(...t.dice);
        arr[0].push(t);
        arr[1].push(t.total);
        return arr;
      }

      // Single die
      let die = Die.fromFormula(t);
      if ( die ) {
        dice.push(die);
        arr[0].push(die);
        arr[1].push(die.total);
        return arr;
      }

      // Arithmetic terms
      arr[0].push(t);
      arr[1].push(t);
      return arr;
    }, [[], []]);
    terms = results[0];
    let result = this._validateResult(results[1].join(" "));

    // Step 5 - safely evaluate the final formula
    const total = this._safeEval(result);

    // Step 6 - Store outputs
    this.parts = terms;
    this._dice = dice;
    this._result = result;
    this._total = total;
    this._rolled = true;
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Create a new Roll object using the original provided formula and data
   * Each roll is immutable, so this method returns a new Roll instance using the same data.
   * @returns {Roll}    A new Roll object, rolled using the same formula and data
   */
  reroll() {
    let r = new this.constructor(this.formula, this.data);
    return r.roll();
  }

  /* -------------------------------------------- */
  /*  Helpers
  /* -------------------------------------------- */

  /**
   * Separate a dice roll formula into parenthetical terms to be evaluated first
   * @param {string} formula
   * @return {Array.<string>}
   * @private
   */
  _evalParentheticalTerms(formula) {

    // Replace parentheses with semicolons to use for splitting
    let toSplit = formula.replace(/([A-z]+)?\(/g, (match, prefix) => {
      return prefix in Math ? `;${prefix};(` : ";(";
    }).replace(/\)/g, ");");
    let terms = toSplit.split(";");

    // Match parenthetical groups
    let nOpen = 0;
    terms = terms.reduce((arr, t) => {

      // Handle cases where the prior term is a math function
      const beginMathFn = (t[0] === "(") && (arr[arr.length-1] in Math);

      // Add terms to the array
      if ( (nOpen > 0) || beginMathFn ) arr[arr.length - 1] += t;
      else arr.push(t);

      // Increment the number of open parentheses
      if ( !beginMathFn && (t === "(") ) nOpen++;
      if ( (nOpen > 0) && (t === ")") ) nOpen--;
      return arr;
    }, []);

    // Convert parenthetical groups to inner Roll objects
    return terms.reduce((arr, t) => {
      if ( t === "" ) return arr;
      let pt = t.match(this.rgx.parenthetical);
      arr.push(pt ? new Roll(pt[1], this.data) : t);
      return arr;
    }, []);
  }

  /* -------------------------------------------- */

  /**
   * Isolate any Dice Pool terms within a formula and evaluate them
   * @param {string} formula
   * @return {Array.<string>}
   * @private
   */
  _evalPoolTerms(formula) {
    let terms = formula.replace(/{/g, ';{').replace(/}([A-z0-9<=>]+)?/g, '$&;').split(";");
    let nOpen = 0;

    // Match outer-bracketed groups
    terms = terms.reduce((arr, t) => {
      if ( nOpen > 0 ) arr[arr.length - 1] += t;
      else arr.push(t);
      if ( t === "{" ) nOpen = Math.max(1, nOpen + 1);
      if ( t === "}" ) nOpen = Math.max(0, nOpen - 1);
      return arr;
    }, []);

    // Convert bracketed groups to inner DicePool objects
    return terms.reduce((arr, t) => {
      if ( t === "" ) return arr;
      const pool = DicePool.fromFormula(t);
      arr.push(pool ? pool : t);
      return arr;
    }, []);
  }

  /* -------------------------------------------- */

  /**
   * Expand and reallocate an array of terms, separating them based on arithmetic operators
   * @private
   */
  _expandArithmeticTerms(terms) {
    const arith = this.constructor.arithmeticOperators.concat(["(", ")"]);
    let split = new RegExp(arith.map(t => "\\" + t).join("|"), "g");
    return terms.reduce((arr, t) => {
      if ( t === "" ) return arr;
      if ( t instanceof DicePool ) arr.push(t);
      else {
        let ts = t.replace(split, ';$&;').split(';');
        for ( let s of ts ) {
          s = s.trim();
          if ( s !== "" ) arr.push(s);
        }
      }
      return arr;
    }, []);
  }

  /* -------------------------------------------- */

  /**
   * Replace a dice roll term enclosed in {brackets} with a DicePool instance
   * @param {string} term           The string term being replaced
   * @param {RegExpMatchArray} rgx  The regexp match for the term
   * @return {DicePool}             The replaced DicePool
   * @private
   */
  _replacePool(term, rgx) {
    const pool = new DicePool(rgx[1], rgx[2]);
    pool.roll();
    return pool;
  }

  /* -------------------------------------------- */

  _validateResult(result) {
    const unsafeMath = /([a-zA-Z_{1}][a-zA-Z0-9_]+)(?=[\s+]?\()/g;
    let valid = true;
    result.replace(unsafeMath, fn => {
      if ( Math.hasOwnProperty(fn) ) return "Math."+fn;
      else valid = false;
    });
    if ( !valid ) throw new Error("Invalid arithmetic expression!");
    return result;
  }

  /* -------------------------------------------- */

  /**
   * Safely evaluate a formulaic expression using a Proxy environment which is allowed access to Math commands
   * @param {String} expression     The formula expression to evaluate
   * @return {Number}               The returned numeric result
   * @private
   */
  _safeEval(expression) {
    const src = 'with (sandbox) { return ' + expression + '}';
    const evl = new Function('sandbox', src);
    return evl(CONFIG.Roll.mathProxy);
  }

  /* -------------------------------------------- */
  /*  HTML Rendering
  /* -------------------------------------------- */

  /**
   * Render a Roll instance to HTML
   * @param chatOptions {Object}      An object configuring the behavior of the resulting chat message.
   * @return {Promise.<HTMLElement>}  A Promise which resolves to the rendered HTML
   */
  async render(chatOptions={}) {
    chatOptions = mergeObject({
      user: game.user._id,
      flavor: null,
      template: CONFIG.Roll.template,
      blind: false
    }, chatOptions);

    // Execute the roll, if needed
    if ( !this._rolled ) this.roll();

    // Define chat data
    const chatData = {
      user: chatOptions.user,
      flavor: chatOptions.flavor
    };

    // Blind results
    if ( chatOptions.isPrivate ) {
      chatData.formula = "???";
      chatData.tooltip = "";
      chatData.total = "?";
    }

    // Regular results
    else {
      chatData.formula = this.formula;
      chatData.tooltip = await this.getTooltip();
      chatData.total = Math.round(this.total * 100) / 100;
    }

    // Render the roll display template
    return renderTemplate(chatOptions.template, chatData);
  }

  /* -------------------------------------------- */

  /**
   * Render the tooltip HTML for a Roll instance
   * @return {Promise.<HTMLElement>}
   */
  getTooltip() {
    const data = {
      formula: this.formula,
      total: this.total
    };

    // Prepare dice parts
	  data["parts"] = this.dice.map(d => {
	    let minRoll = Math.min(...d.sides),
          maxRoll = Math.max(...d.sides);

	    // Generate tooltip data
      return {
        formula: d.formula,
        total: d.total,
        faces: d.faces,
        rolls: d.rolls.map(r => {
          return {
            result: d._getTooltip(r.roll),
            classes: [
              "d"+d.faces,
              r.rerolled ? "rerolled" : null,
              r.exploded ? "exploded" : null,
              r.discarded ? "discarded": null,
              (r.roll === minRoll) ? "min" : null,
              (r.roll === maxRoll) ? "max" : null
            ].filter(c => c).join(" ")
          }
        })
      };
    });

	  // Render the tooltip template
    return renderTemplate(CONFIG.Roll.tooltip, data);
  }

  /* -------------------------------------------- */

  /**
   * Transform a Roll instance into a ChatMessage, displaying the roll result.
   * This function can either create the ChatMessage directly, or return the data object that will be used to create.
   *
   * @param {Object} chatData             The data object to use when creating the message
   * @param {string|null} [rollMode=null] The template roll mode to use for the message from CONFIG.rollModes
   * @param {boolean} [create=true]       Whether to automatically create the chat message, or only return the prepared
   *                                      chatData object.
   * @return {Promise|Object}             A promise which resolves to the created ChatMessage entity, if create is true
   *                                      or the Object of prepared chatData otherwise.
   */
  toMessage(chatData={}, {rollMode=null, create=true}={}) {

    // Perform the roll, if it has not yet been rolled
    if ( !this._rolled ) this.roll();

    // Prepare chat data
    chatData = mergeObject({
      user: game.user._id,
      type: CONST.CHAT_MESSAGE_TYPES.ROLL,
      content: this.total,
      sound: CONFIG.sounds.dice,
    }, chatData);
    chatData.roll = JSON.stringify(this);

    // Handle different roll modes
    rollMode = rollMode || game.settings.get("core", "rollMode");
    switch (rollMode) {
      case "gmroll":
        chatData["whisper"] = game.users.entities.filter(u => u.isGM).map(u => u._id);
        break;
      case "selfroll":
        chatData["whisper"] = [game.user._id];
        break;
      case "blindroll":
        chatData["whisper"] = game.users.entities.filter(u => u.isGM).map(u => u._id);
        chatData["blind"] = true;
    }

    // Either create the message or just return the chat data
    return create ? ChatMessage.create(chatData) : chatData;
  }

  /* -------------------------------------------- */
  /*  Methods
  /* -------------------------------------------- */

  /**
   * Alter the Roll formula by adding or multiplying the number of dice included in each roll term
   *
   * @param add {Number}      A number of dice to add to each Die term
   * @param multiply {Number} A multiplier for the number of dice in each Die term
   *
   * @example
   * let r = new Roll("4d8 + 4 + 2d4");
   * r.alter(1, 2);
   * r.formula;
   * > 9d8 + 4 + 5d4
   */
  alter(add, multiply) {
    if ( this._rolled ) throw new Error("You may not alter a Roll which has already been rolled");
    const rgx = new RegExp(Die.rgx.die, "g");
    this.formula = this.formula.replace(rgx, (match, nd, d, mods) => {
      nd = (nd * (multiply || 1)) + (add || 0);
      mods = mods || "";
      return nd + "d" + d + mods;
    });
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Clean a dice roll formula, returning the formatted string with proper spacing
   * @param formula
   * @return {*}
   */
  static cleanFormula(formula) {

    // Replace multiple white spaces
    formula = formula.replace(/\s{2,}/, " ");

    // Clean arithmetic
    const arith = this.arithmeticOperators;
    const split = new RegExp(arith.map(t => "\\"+t).join("|"), "g");
    const terms = formula.replace(split, ";$&;").split(";");
    const cleaned = terms.reduce((arr, t) => {
      let ix = arr.length - 1;
      t = t.trim();
      if ( t === "" ) return arr; // Exclude white space
      let prior = arr[ix];

      // De-dupe addition and multiplication
      if ( ["+", "*"].includes(t) && prior === t ) return arr;

      // Negate double subtraction
      if ( (t === "-") && (prior === "-" ) ) {
        arr[ix] = "+";
        return arr;
      }

      // Negate double division
      if ( (t === "/") && (prior === "/") ) {
        arr[ix] = "*";
        return arr;
      }

      // Subtract a negative value
      if ( ["-+", "+-"].includes(t+prior) ) {
        arr[ix] = "-";
        return arr;
      }

      arr.push(t);
      return arr;
    }, []);
    return cleaned.join(" ");
  }

  /* -------------------------------------------- */

  /**
   * Return the minimum possible Dice roll which can result from the given formula
   * @param {string} formula      A dice roll formula to minimize
   * @return {Roll}               A Roll instance representing the minimal roll
   */
  static minimize(formula) {
    const rgx = new RegExp(Die.rgx.die, "g");
    formula = formula.replace(rgx, (match, nd, d, mods) => {
      return `(${nd}*1)`;
    });
    return new this(formula).roll();
  }

  /* -------------------------------------------- */

  /**
   * Return the maximum possible Dice roll which can result from the given formula
   * @param {string} formula      A dice roll formula to maximize
   * @return {Roll}               A Roll instance representing the maximal roll
   */
  static maximize(formula) {
    const rgx = new RegExp(Die.rgx.die, "g");
    formula = formula.replace(rgx, (match, nd, d, mods) => {
      return `(${nd}*${d})`;
    });
    return new this(formula).roll();
  }

  /* -------------------------------------------- */

  /**
   * Acquire data object representing the most-likely current actor.
   * This data can be included in the invocation of a Roll instance for evaluating dynamic attributes.
   *
   * @return {Object}     An object of data representing the current Actor (if any)
   */
  static getActorData() {
    let data, actor;

    // If the user is a GM - include the data for the currently selected token
    if ( game.user.isGM && canvas.ready ) {
      let ct = canvas.tokens.controlled;
      if ( ct.length  === 1 ) actor = canvas.tokens.controlled.shift().actor;
    }

    // Otherwise, if the user has an impersonated character, use their own character's data
    else if ( game.user.character ) actor = game.user.character;

    // If no actor was identified, return an empty object
    if ( !actor ) return {};
    data = duplicate(actor.data.data);
    data["name"] = actor.name;
    return data;
  }

  /* -------------------------------------------- */

  static simulate(formula, n) {
    let results = [...Array(n)].reduce((arr, v) => {
      let r = new Roll(formula);
      arr.push(r.roll().total);
      return arr;
    }, []);
    let mean = results.reduce((sum, v) => sum += v, 0) / results.length;
    console.log(`Rolled ${formula} ${n} times. Average result: ${mean}`);
    return results;
  }

  /* -------------------------------------------- */
  /*  Saving and Loading
  /* -------------------------------------------- */

  /**
   * Structure the Roll data as an object suitable for JSON stringification
   * @return {Object}     Structured data which can be serialized into JSON
   */
  toJSON() {

    // Structure rolled dice
    const dice = this.dice.map(d => {
      return {
        class: d.constructor.name,
        faces: d.faces,
        rolls: d.rolls,
        formula: d.formula,
        options: d.options
      };
    });

    // Substitute parts
    const parts = this.parts.map(p => {
      if ( p instanceof Die ) {
        let idx = this.dice.findIndex(d => d === p);
        return "_d"+idx;
      }
      else if ( p instanceof DicePool ) {
        return p.toJSON();
      }
      return p;
    });

    // Serialize roll equation
    return {
      class: this.constructor.name,
      formula: this.formula,
      dice: dice,
      parts: parts,
      result: this.result,
      total: this.total
    };
  }

  /* -------------------------------------------- */


  /**
   * Recreate a Roll instance using a provided JSON string
   * @param {string} json   Serialized JSON data representing the Roll
   * @return {Roll}         A revived Roll instance
   */
  static fromJSON(json) {
    return this.fromData(JSON.parse(json));
  }

  /* -------------------------------------------- */

  /**
   * Recreate a Roll instance using a provided JSON string
   * @param {Object} data   Unpacked data representing the Roll
   * @return {Roll}         A revived Roll instance
   */
  static fromData(data) {
    if ( data.class !== "Roll" ) throw new Error("Unable to recreate Roll instance from provided data");

    // Create the instance and assign data
    let roll = new this(data.formula);
    roll._result = data.result;
    roll._total = data.total;

    /// Rehydrate Die rolls
    roll._dice = data.dice.map(d => {
      let cls = CONFIG.diceTypes.find(t => d.class === t.name);
      if ( !cls ) throw new Error(`Unrecognized die type ${d.class}`);
      let die = new cls(d.faces, d.options);
      die.rolls = d.rolls;
      die.formula = d.formula;
      return die;
    });

    // Re-map dice as parts
    roll.parts = data.parts.map(p => {

      // Dice pools
      if ( p.class === "DicePool" ) {
        return DicePool.fromData(p);
      }

      // Dice rolls
      else if ( (typeof p === "string") && p.startsWith("_d") ) {
        let idx = parseInt(p.slice(2));
        return roll._dice[idx];
      }

      // String parts
      return p;
    });

    roll._rolled = true;
    return roll;
  }
}


const MAX_INT = 4294967296.0,
    N = 624,
    M = 397,
    UPPER_MASK = 0x80000000,
    LOWER_MASK = 0x7fffffff,
    MATRIX_A = 0x9908b0df;

/**
 * A standalone, pure JavaScript implementation of the Mersenne Twister pseudo random number generator. Compatible
 * with Node.js, requirejs and browser environments. Packages are available for npm, Jam and Bower.
 *
 * @author Raphael Pigulla <pigulla@four66.com>
 * @license See the attached LICENSE file.
 * @version 0.2.3
 */
class MersenneTwister {

  /**
   * Instantiates a new Mersenne Twister.
   *
   * @constructor
   * @alias module:MersenneTwister
   * @since 0.1.0
   * @param {number=} seed The initial seed value.
   */
  constructor(seed) {
    if (typeof seed === 'undefined') {
      seed = new Date().getTime();
    }
    this.mt = new Array(N);
    this.mti = N + 1;
    this.seed(seed);
  };

  /**
   * Initializes the state vector by using one unsigned 32-bit integer "seed", which may be zero.
   *
   * @since 0.1.0
   * @param {number} seed The seed value.
   */
  seed(seed) {
    let s;

    this.mt[0] = seed >>> 0;

    for (this.mti = 1; this.mti < N; this.mti++) {
      s = this.mt[this.mti - 1] ^ (this.mt[this.mti - 1] >>> 30);
      this.mt[this.mti] =
        (((((s & 0xffff0000) >>> 16) * 1812433253) << 16) + (s & 0x0000ffff) * 1812433253) + this.mti;
      this.mt[this.mti] >>>= 0;
    }
  };

  /**
   * Initializes the state vector by using an array key[] of unsigned 32-bit integers of the specified length. If
   * length is smaller than 624, then each array of 32-bit integers gives distinct initial state vector. This is
   * useful if you want a larger seed space than 32-bit word.
   *
   * @since 0.1.0
   * @param {array} vector The seed vector.
   */
  seedArray(vector) {
    let i = 1,
      j = 0,
      k = N > vector.length ? N : vector.length,
      s;

    this.seed(19650218);

    for (; k > 0; k--) {
      s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30);

      this.mt[i] = (this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1664525) << 16) + ((s & 0x0000ffff) * 1664525))) +
        vector[j] + j;
      this.mt[i] >>>= 0;
      i++;
      j++;
      if (i >= N) {
        this.mt[0] = this.mt[N - 1];
        i = 1;
      }
      if (j >= vector.length) {
        j = 0;
      }
    }

    for (k = N - 1; k; k--) {
      s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30);
      this.mt[i] =
        (this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1566083941) << 16) + (s & 0x0000ffff) * 1566083941)) - i;
      this.mt[i] >>>= 0;
      i++;
      if (i >= N) {
        this.mt[0] = this.mt[N - 1];
        i = 1;
      }
    }

    this.mt[0] = 0x80000000;
  };

  /**
   * Generates a random unsigned 32-bit integer.
   *
   * @since 0.1.0
   * @returns {number}
   */
  int() {
    let y,
      kk,
      mag01 = new Array(0, MATRIX_A);

    if (this.mti >= N) {
      if (this.mti === N + 1) {
        this.seed(5489);
      }

      for (kk = 0; kk < N - M; kk++) {
        y = (this.mt[kk] & UPPER_MASK) | (this.mt[kk + 1] & LOWER_MASK);
        this.mt[kk] = this.mt[kk + M] ^ (y >>> 1) ^ mag01[y & 1];
      }

      for (; kk < N - 1; kk++) {
        y = (this.mt[kk] & UPPER_MASK) | (this.mt[kk + 1] & LOWER_MASK);
        this.mt[kk] = this.mt[kk + (M - N)] ^ (y >>> 1) ^ mag01[y & 1];
      }

      y = (this.mt[N - 1] & UPPER_MASK) | (this.mt[0] & LOWER_MASK);
      this.mt[N - 1] = this.mt[M - 1] ^ (y >>> 1) ^ mag01[y & 1];
      this.mti = 0;
    }

    y = this.mt[this.mti++];

    y ^= (y >>> 11);
    y ^= (y << 7) & 0x9d2c5680;
    y ^= (y << 15) & 0xefc60000;
    y ^= (y >>> 18);

    return y >>> 0;
  };

  /**
   * Generates a random unsigned 31-bit integer.
   *
   * @since 0.1.0
   * @returns {number}
   */
  int31() {
    return this.int() >>> 1;
  };

  /**
   * Generates a random real in the interval [0;1] with 32-bit resolution.
   *
   * @since 0.1.0
   * @returns {number}
   */
  real() {
    return this.int() * (1.0 / (MAX_INT - 1));
  };

  /**
   * Generates a random real in the interval ]0;1[ with 32-bit resolution.
   *
   * @since 0.1.0
   * @returns {number}
   */
  realx() {
    return (this.int() + 0.5) * (1.0 / MAX_INT);
  };

  /**
   * Generates a random real in the interval [0;1[ with 32-bit resolution.
   *
   * @since 0.1.0
   * @returns {number}
   */
  rnd() {
    return this.int() * (1.0 / MAX_INT);
  };

  /**
   * Generates a random real in the interval [0;1[ with 32-bit resolution.
   *
   * Same as .rnd() method - for consistency with Math.random() interface.
   *
   * @since 0.2.0
   * @returns {number}
   */
  random() {
    return this.rnd();
  };

  /**
   * Generates a random real in the interval [0;1[ with 53-bit resolution.
   *
   * @since 0.1.0
   * @returns {number}
   */
  rndHiRes() {
    const a = this.int() >>> 5,
      b = this.int() >>> 6;
    return (a * 67108864.0 + b) * (1.0 / 9007199254740992.0);
  };
}

const twist = new MersenneTwister();

/**
 * An iterable container of Entity objects within the Foundry Virtual Tabletop framework.
 * Each Entity type has it's own subclass of Collection, which defines the abstract interface.
 * @abstract
 *
 * @param {Array} data      An Array of Entity data from which to create instances
 * @param {Array} apps      An Array of Application instances which the Collection modifies
 */
class Collection {
  constructor(data, apps) {

    /**
     * A reference to the original source data provided by the server
     * @type {Object}
     * @private
     */
    this._source = data;

    /**
     * An Array of all the Entity instances of this type which are contained within the collection
     * @type {Array}
     */
    this.entities = [];

    /**
     * An Array of application references which will be automatically updated when the collection content changes
     * @type {Array}
     */
    this.apps = apps || [];
  }

  /* -------------------------------------------- */

  /**
   * Initialize the Collection instance by preparing all of it's contained Entities
   * @return {this}     A reference to the initialized Collection
   */
  initialize() {
    this.entities = this._source.map(d => new this.object(d));
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Render any Applications associated with this Collection
   * @return {this}     A reference to the rendered Collection
   */
  render(...args) {
    for (let a of this.apps) a.render(...args);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Make the collection iterable
   * @generator
   */
  [Symbol.iterator]() {
    return this.entities.values();
  }

  /* -------------------------------------------- */
  /*  Collection Properties                       */
  /* -------------------------------------------- */

  /**
   * The Collection name
   * @type {string}
   */
  get name() {
    return this.constructor.name;
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the singleton instance of this Collection, or null if it has not yet been created.
   * @type {Collection|null}
   */
  static get instance() {
    return game[this.name.toLowerCase()] || null;
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the SidebarDirectory application for this Collection, or null if it has not yet been created.
   * @type {SidebarDirectory|null}
   */
  get directory() {
    return ui[this.name.toLowerCase()] || null;
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the Entity subclass which should be used when creating elements of this Collection.
   * This should always be an explicit reference to the class which is used in this game to represent the entity,
   * and not the base implementation of that entity type. For example :class:`Actor5e` not :class:`Actor`.
   * @abstract
   * @type {Entity}
   */
  get object() {
    return Entity;
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the base Entity name which is contained within this Collection.
   * @type {string}
   */
  get entity() {
    return this.object.entity;
  }

  /* -------------------------------------------- */
  /*  Collection Management Methods               */
  /* -------------------------------------------- */

  /**
   * Add a new Entity to the Collection, asserting that they are of the correct type.
   * @param entity {Entity}   The entity instance to add to the collection
   */
  insert(entity) {
    if (!(entity instanceof this.object)) {
      throw new Error(`You may only push instances of ${this.object.name} to the ${this.name} collection`);
    }

    // Is the data already in the source?
    let source_index = this._source.findIndex(e => e._id === entity._id);
    if (source_index !== -1) {
      this._source[source_index] = entity.data;
    } else {
      this._source.push(entity.data);
    }

    // Is the entity already in the collection?
    let collection_index = this.entities.findIndex(e => e._id === entity._id);
    if (collection_index !== -1) {
      this.entities[collection_index] = entity;
    } else {
      this.entities.push(entity);
    }
  }

  /* -------------------------------------------- */

  /**
   * Remove an Entity from the Collection by its ID.
   * @param id {string}   The entity ID which should be removed
   */
  remove(id) {
    const find = e => e._id === id;
    this._source.findSplice(find);
    this.entities.findSplice(find);
  }

  /* -------------------------------------------- */

  /**
   * Get an element from the collection by ID.
   * @param {string} id       The entity ID to retrieve from the collection
   * @param {boolean} strict  Throw an Error if the requested id does not exist, otherwise return null. Default false.
   * @return {Entity|null}    The retrieved Entity, if the ID was found, otherwise null;
   */
  get(id, {strict = false} = {}) {
    const entity = this.entities.find(e => e._id === id);
    if (!entity && strict) {
      throw new Error(`The ${this.object.name} ${id} does not exist in the ${this.constructor.name} collection`);
    }
    return entity || null;
  }

  /* -------------------------------------------- */

  /**
   * Import an Entity from a compendium collection, adding it to the current World.
   * @param {string} collection     The name of the pack from which to import
   * @param {string} entryId        The ID of the compendium entry to import
   * @param {Object} [updateData]   Optional additional data used to modify the imported Entity before it is created
   * @param {Object} [options]      Optional arguments passed to the Entity.create method
   * @return {Promise.<Entity>}     A Promise containing the imported Entity
   */
  async importFromCollection(collection, entryId, updateData={}, options={}) {
    const entName = this.object.entity;
    const pack = game.packs.find(p => p.collection === collection);
    if (pack.metadata.entity !== entName) return;

    // Prepare the source data from which to create the Entity
    const source = await pack.getEntity(entryId);
    const createData = mergeObject(source.data, updateData);
    delete createData._id;

    // Create the Entity
    console.log(`${vtt} | Importing ${entName} ${source.name} from ${collection}`);
    this.directory.activate();
    return await this.object.create(createData, options);
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /**
   * Activate socket listeners related to this particular Entity type.
   * @param {Socket} socket   The open game socket
   */
  static socketListeners(socket) {
    let collection = this.instance;
    const name = collection.entity;

    // Define helper registration function
    const handle = (eventName, handler) => {
      socket.on(eventName, response => SocketInterface.handle(response, handler, {
        postHook: eventName,
        context: collection
      }));
    };
    const handleMany = (eventName, handler, postHook) => {
      socket.on(eventName, response => SocketInterface.handleMany(response, handler, {
        postHook: postHook,
        context: collection
      }));
    };

    // Register Entity handlers
    const operations = ["create", "update", "delete"];
    for ( let op of operations ) {
      const eventName = op+name;
      handle(eventName, collection[`_${op}Entity`]);
      handleMany(`${op}Many${name}`, collection[`_${op}ManyEntities`], eventName);
    }

    // Register Embedded Entity handlers
    const embeddedEntities = Object.keys(collection.object.config.embeddedEntities);
    for (let embeddedName of embeddedEntities) {
      for ( let op of operations ) {
        const eventName = op+embeddedName;
        handle(eventName, collection[`_${op}EmbeddedEntity`]);
        handleMany(`${op}Many${embeddedName}`, collection[`_${op}ManyEmbeddedEntities`], eventName);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle Entity creation workflow using the server response from the create<Entity> socket.
   * @private
   *
   * @param {Object} created      The created Entity data
   * @param {Object} options      Additional options which describe the creation request
   * @param {string} userId       The ID of the triggering User
   *
   * @return {Entity}             The created Entity instance
   */
  _createEntity({created, options, userId}) {
    const name = this.entity;

    // Create the Entity and add it to the Collection
    const entity = new this.object(created);
    if ( options.temporary ) return entity;

    // Insert the created entity into the Collection
    console.log(`${vtt} | Creating new ${name} with ID ${entity._id}`);
    if (!options.temporary) this.insert(entity);
    const renderContext = {
      renderContext: 'create' + name,
      renderData: created,
      entity: entity
    };
    this.render(false, renderContext);

    // Call entity creation steps
    entity._onCreate(created, options, userId, renderContext);
    return entity;
  }

  /* -------------------------------------------- */

  /**
   * Handle creation of multiple Entities using data provided from a server response.
   * @private
   *
   * @param {Array.<Object>} data     An Array of created Entity data
   * @param {Object} options          Additional options which describe the creation request
   * @param {string} userId           The ID of the triggering User
   *
   * @return {Array.<Entity>}         The created Entity instances
   */
  _createManyEntities({data, options, userId}) {
    const name = this.entity;

    // Create the Entities and add then to the parent Collection
    const entities = data.map(data => {
      let entity = new this.object(data);
      if (!options.temporary) this.insert(entity);
      entity._onCreate(data, options, userId, {renderContext: 'create'+name, renderData: data});
      return entity;
    });
    if ( options.temporary ) return entities;

    // Render Collection update
    console.log(`${vtt} | Created ${entities.length} new ${name}s`);
    this.render(false, {
      renderContext: 'createMany'+name,
      renderData: entities
    });
    return entities;
  }

  /* -------------------------------------------- */

  /**
   * Handle Entity update workflow using the server response from the update<Entity> socket.
   * @private
   *
   * @param {Object} updated      The updated Entity data
   * @param {Object} options      Additional options which describe the update request
   * @param {String} userId       The ID of the triggering User
   *
   * @return {Entity}             The updated Entity instance
   */
  _updateEntity({updated, options, userId}) {
    const name = this.entity;

    // Retrieve the entity to update
    const entity = this.get(updated._id, {strict: true});

    // Merge new data with the existing data object
    mergeObject(entity.data, updated);
    if (updated.permission && entity.data.permission) entity.data.permission = updated.permission;

    // Define update context
    const renderContext = {
      renderContext: 'update' + name,
      renderData: updated,
      entity: entity
    };

    // Handle entity update steps and re-render the collection
    entity._onUpdate(updated, options, userId, renderContext);
    this.render(false, renderContext);
    return entity;
  }

  /* -------------------------------------------- */

  /**
   * Handle updates to multiple Entities using data provided from a server response.
   * @private
   *
   * @param {Array.<Object>} data     An Array of incremental Entity update data
   * @param {Object} options          Additional options which describe the update request
   * @param {string} userId           The ID of the triggering User
   *
   * @return {Array.<Entity>}         The updated Entity instances
   */
  _updateManyEntities({data, options, userId}) {
    const name = this.entity;

    // Structure the pending updates
    const updateIds = new Set();
    const updates = data.reduce((obj, data) => {
      updateIds.add(data._id);
      obj[data._id] = data;
      return obj;
    }, {});

    // Update data for each Entity
    const entities = [];
    for ( let entity of this.entities ) {
      if ( !updateIds.has(entity.id) ) continue;
      const update = updates[entity.id];
      mergeObject(entity.data, update);
      if ( update.permission && entity.data.permission ) entity.data.permission = update.permission;
      entity._onUpdate(update, options, userId, {renderContext: 'update'+name, renderData: update});
      entities.push(entity);
    }

    // Render Collection update
    this.render(false, {
      renderContext: 'updateMany'+name,
      renderData: data
    });
    return entities;
  }

  /* -------------------------------------------- */

  /**
   * Handle Entity deletion workflow using the server response from the delete<Entity> socket.
   * @private
   *
   * @param {String} deleted      The ID of the deleted Entity
   * @param {Object} options      Additional options which describe the deletion request
   * @param {String} userId       The ID of the triggering User
   *
   * @return {Entity}             The deleted Entity instance
   * */
  _deleteEntity({deleted, options, userId}) {
    const name = this.entity;

    // Remove the entity from it's collection
    const entity = this.get(deleted, {strict: true});
    console.log(`${vtt} | Deleting ${name} with ID ${entity._id}`);
    this.remove(deleted);

    // Call entity deletion steps
    const renderContext = {
      renderContext: 'delete' + name,
      renderData: deleted,
      entity: entity
    };
    entity._onDelete(deleted, options, userId, renderContext);

    // Render Collection update
    this.render(false, renderContext);
    return entity;
  }

  /* -------------------------------------------- */

  /**
   * Handle deletion of multiple Entities using an Array of ids provided from a server response.
   * @private
   *
   * @param {Array.<string>} data     An Array of Entity ids to delete
   * @param {Object} options          Additional options which describe the deletion request
   * @param {string} userId           The ID of the triggering User
   *
   * @return {Array.<Entity>}         The deleted Entity instances
   */
  _deleteManyEntities({data, options, userId}) {
    const name = this.entity;

    // Record the IDs to delete
    const toDelete = new Set(options.deleteAll ? this._source.map(e => e._id) : data);

    // Delete each entity in the target set
    const entities = [];
    this.entities = this.entities.filter(e => {
      if ( toDelete.has(e._id) ) {
        e._onDelete(e._id, options, userId, {renderContext: 'delete'+name, renderData: e._id});
        entities.push(e);
        return false;
      }
      return true;
    });

    // Filter the source data
    for ( let id of toDelete ) {
      this._source.findSplice(s => s._id === id);
    }
    console.log(`${vtt} | Deleted ${data.length} ${name}s`);

    // Render Collection update
    this.render(false, {
      renderContext: 'deleteMany'+name,
      renderData: data
    });
    return entities;
  }

  /* -------------------------------------------- */

  /**
   * Handle the creation of a new Embedded Entity within a parent Entity in response to server-side socket broadcast.
   * @private
   *
   * @param {string} parentId     The parent Entity ID
   * @param {Object} created      The created Embedded Entity data
   * @param {Object} options      Additional options which modify the creation request
   * @param {string} userId       The id of the requesting user
   *
   * @return {Object}             The created Embedded Entity data
   */
  _createEmbeddedEntity({parentId, created, options, userId}) {
    if ( options.temporary ) return created;

    // Get the parent entity and the child collection
    const parent = this.get(parentId, {strict: true});
    const embeddedName = options.embeddedName;
    const collection = parent.getEmbeddedCollection(embeddedName);

    // Push the created data into the child collection
    collection.push(created);
    console.log(`${vtt} | Created ${options.embeddedName} ${created._id} in ${this.object.name} ${parentId}`);

    // Call Entity child creation steps and return the created child
    parent._onCreateEmbeddedEntity({embeddedName, created, options, userId});
    const renderContext = {renderContext: `create${embeddedName}`, renderData: created};
    parent._onModifyEmbeddedEntity({embeddedName, renderContext});
    return created;
  }

  /* -------------------------------------------- */

  /**
   * Handle creation of multiple Embedded Entities within a parent Entity in response to server-side socket broadcast.
   * @private
   *
   * @param {string} parentId     The parent Entity ID
   * @param {Array} data          An Array of created Embedded Entity data
   * @param {Object} options      Additional options which modify the creation request
   * @param {string} userId       The id of the requesting user
   *
   * @return {Array}              The created Embedded Entity Array
   */
  _createManyEmbeddedEntities({parentId, data, options, userId}) {
    if ( options.temporary ) return data;

    // Get the parent entity and current collection
    const cls = this.object;
    const parent = this.get(parentId, {strict: true});
    const embeddedName = options.embeddedName;
    const collectionName = cls.config.embeddedEntities[embeddedName];

    // Add new entities to the collection
    parent.data[collectionName] = parent.data[collectionName].concat(data);
    console.log(`${vtt} | Created ${data.length} new ${embeddedName}s in ${cls.name} ${parentId}`);

    // Call Embedded Entity creation steps
    data.forEach(child => parent._onCreateEmbeddedEntity({embeddedName, created: child, options, userId}));
    const renderContext = {renderContext: `createMany${embeddedName}s`, renderData: data};
    parent._onModifyEmbeddedEntity({embeddedName, renderContext});
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Handle updates to an Embedded Entity within a parent Entity in response to server-side socket broadcast.
   * @private
   *
   * @param {string} parentId     The parent Entity ID
   * @param {Object} data         The updated Embedded Entity data
   * @param {Object} options      Additional options which modify the update request
   * @param {string} userId       The id of the requesting user
   *
   * @return {Object}             The updated Embedded Entity data
   */
  _updateEmbeddedEntity({parentId, data, options, userId}) {

    // Get the parent entity and current child
    const parent = this.get(parentId, {strict: true});
    const embeddedName = options.embeddedName;
    let child = parent.getEmbeddedEntity(embeddedName, data._id, {strict: true});

    // Update the child data
    mergeObject(child, data);

    // Call Embedded Entity update steps
    parent._onUpdateEmbeddedEntity({embeddedName, data, options, userId});
    const renderContext = {renderContext: `update${embeddedName}`, renderData: data};
    parent._onModifyEmbeddedEntity({embeddedName, renderContext});
    return child;
  }

  /* -------------------------------------------- */

  /**
   * Handle updates to a multiple Embedded Entities within a parent Entity in response to server-side socket broadcast.
   * @private
   *
   * @param {string} parentId     The parent Entity ID
   * @param {Array} data          An Array of embedded entity data updates
   * @param {Object} options      Additional options which modify the update request
   * @param {string} userId       The id of the requesting user
   *
   * @return {Array}              The updated Embedded Entity Array
   */
  _updateManyEmbeddedEntities({parentId, data, options, userId}) {

    // Get the parent entity and the child collection
    const parent = this.get(parentId, {strict: true});
    const embeddedName = options.embeddedName;
    const collection = parent.getEmbeddedCollection(embeddedName);

    // Structure the pending updates
    const updateIds = new Set();
    const updates = data.reduce((obj, u) => {
      updateIds.add(u._id);
      obj[u._id] = u;
      return obj;
    }, {});

    // Update the data for each child
    for (let child of collection) {
      if (!updateIds.has(child._id)) continue;
      mergeObject(child, updates[child._id]);
    }

    // Call Embedded Entity update steps
    data.forEach(child => parent._onUpdateEmbeddedEntity({embeddedName, data: child, options, userId}));
    const renderContext = {renderContext: `updateMany${embeddedName}s`, renderData: data};
    parent._onModifyEmbeddedEntity({embeddedName, renderContext});
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Handle deletion of an Embedded Entity within a parent Entity in response to server-side socket broadcast.
   * @private
   *
   * @param {string} parentId     The parent Entity ID
   * @param {string} deleted      The Embedded Entity id to delete from the parent
   * @param {Object} options      Additional options which modify the deletion request
   * @param {string} userId       The id of the requesting user
   *
   * @return {Object}             The deleted Embedded Entity data
   */
  _deleteEmbeddedEntity({parentId, deleted, options, userId}) {

    // Get the parent entity
    const parent = this.get(parentId, {strict: true});
    const embeddedName = options.embeddedName;

    // Remove the current child
    const child = parent.removeEmbeddedEntity(embeddedName, deleted);
    console.log(`${vtt} | Deleted ${options.embeddedName} ${deleted} in ${this.object.name} ${parentId}`);

    // Call Embedded Entity deletion steps
    parent._onDeleteEmbeddedEntity({embeddedName, deleted: child, options, userId});
    const renderContext = {renderContext: `delete${embeddedName}`, renderData: child};
    parent._onModifyEmbeddedEntity({embeddedName, renderContext});
    return child;
  }

  /* -------------------------------------------- */

  /**
   * Handle deletion of multiple Embedded Entities within a parent Entity in response to server-side socket broadcast.
   * @private
   *
   * @param {string} parentId     The parent Entity ID
   * @param {Array} data          An Array of Embedded Entity ids to delete
   * @param {Object} options      Additional options which modify the update request
   * @param {string} userId       The id of the requesting user
   *
   * @return {Array}              The deleted Embedded Entity Array
   */
  _deleteManyEmbeddedEntities({parentId, data, options, userId}) {

    // Get the parent entity and current collection
    const cls = this.object;
    const parent = this.get(parentId, {strict: true});
    const embeddedName = options.embeddedName;
    const collectionName = cls.config.embeddedEntities[embeddedName];

    // Remove deleted IDs from the collection
    const toDelete = new Set(data);
    const [kept, deleted] = parent.data[collectionName].partition(c => toDelete.has(c._id));
    parent.data[collectionName] = kept;
    console.log(`${vtt} | Deleted ${deleted.length} ${embeddedName}s from ${cls.name} ${parentId}`);

    // Call Embedded Entity deletion steps
    deleted.forEach(child => parent._onDeleteEmbeddedEntity({embeddedName, deleted: child, options, userId}));
    const renderContext = {renderContext: `deleteMany${embeddedName}s`, renderData: data};
    parent._onModifyEmbeddedEntity({embeddedName, renderContext});
    return deleted;
  }
}

/**
 * The Compendium class provides an interface for interacting with compendium packs which are 
 * collections of similar Entities which are stored outside of the world database but able to
 * be easily imported into an active session.
 * 
 * When the game session is initialized, each available compendium pack is constructed and 
 * added to the ``game.packs``.
 *
 * Each Compendium is distinctly referenced using its canonical "collection" name which is a 
 * unique string that contains the package name which provides the compendium as well as the
 * name of the pack within that package. For example, in the D&D5e system, the compendium pack
 * which provides the spells available within the SRD has the collection name "dnd5e.spells".
 *
 * @type {Application}
 *
 * @param metadata {Object}   The compendium metadata, an object provided by game.data
 * @param options {Object}    Application rendering options
 *
 * @example
 * // Let's learn the collection names of all the compendium packs available within a game
 * game.packs.map(p => p.collection);
 *
 * // Suppose we are working with a particular pack named "dnd5e.spells"
 * const pack = game.packs.find(p => p.collection === "dnd5e.spells");
 * 
 * // We can load the index of the pack which contains all entity IDs, names, and image icons
 * pack.getIndex().then(index => console.log(index));
 * 
 * // We can find a specific entry in the compendium by its name
 * let entry = pack.index.find(e => e.name === "Acid Splash");
 * 
 * // Given the entity ID of "Acid Splash" we can load the full Entity from the compendium
 * pack.getEntity(entry.id).then(spell => console.log(spell));
 * 
 * @example
 * // We often may want to programmatically create new Compendium content
 * // Let's start by creating a custom spell as an Item instance
 * let itemData = {name: "Custom Death Ray", type: "Spell"};
 * let item = new Item(itemData);
 * 
 * // Once we have an entity for our new Compendium entry we can import it, if the pack is unlocked
 * pack.importEntity(item);
 * 
 * // When the entity is imported into the compendium it will be assigned a new ID, so let's find it
 * pack.getIndex().then(index => {
 *   let entry = index.find(e => e.name === itemData.name));
 *   console.log(entry);
 * });
 *
 * // If we decide to remove an entry from the compendium we can do that by the entry ID
 * pack.removeEntry(entry.id);
 */
class Compendium extends Application {
  constructor(metadata, options) {
    super(options);

    /**
     * The compendium metadata which defines the compendium content and location
     * @type {Object}
     */
    this.metadata = metadata;

    /**
     * Track whether the compendium pack is publicly visible
     * @type {Boolean}
     */
    this.public = true;

    /**
     * The most recently retrieved index of the Compendium content
     * This index is not guaranteed to be current - call getIndex() to reload the index
     * @type {Array}
     */
    this.index = [];

    // Internal flags
    this.searchString = null;
    this._searchTime = 0;
  }

	/* -------------------------------------------- */

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    template: "templates/apps/compendium.html",
      width: 350,
      height: window.innerHeight - 100,
      top: 70,
      left: 120,
      scrollY: [".directory-list"]
    });
  }

  /* ----------------------------------------- */

  /** @override */
  get title() {
    return this.metadata.label;
  }

  /* ----------------------------------------- */

  /**
   * The canonical Compendium name - comprised of the originating package and the pack name
   * @return {string}     The canonical collection name
   */
  get collection() {
    return `${this.metadata.package}.${this.metadata.name}`
  }

  /* ----------------------------------------- */

  /**
   * The Entity type which is allowed to be stored in this collection
   * @type {String}
   */
  get entity() {
    return this.metadata.entity;
  }

  /* ----------------------------------------- */

  /** @override */
  async getData() {
    await this.getIndex();
    return {
      collection: this.collection,
      searchString: this.searchString,
      cssClass: this.entity.toLowerCase(),
      index: this.index.map(i => {
        i.img = i.img || CONST.DEFAULT_TOKEN;
        return i;
      })
    };
  }

  /* ----------------------------------------- */

  /** @override */
  async _render(...args) {
    await super._render(...args);
    if ( this.rendered && this.searchString ) this._onSearch(this.searchString);
  }

  /* ----------------------------------------- */
  /*  Methods
  /* ----------------------------------------- */

  /**
   * Create a new Compendium pack using provided
   * @param {Object} metadata   The compendium metadata used to create the new pack
   * @return {Promise.<Compendium>}
   */
  static create(metadata) {
    if ( !game.user.isGM ) throw new Error("You may not create a Compendium pack");
    return SocketInterface.trigger("createCompendiumPack", {metadata}, {}, {
      context: this,
      success: response => {
        let pack = new Compendium(response.metadata);
        game.packs.push(pack);
        ui.compendium.render();
        return pack;
      }
    });
  }

  /* ----------------------------------------- */

  /**
   * Delete a world Compendium pack
   * This is only allowed for world-level packs by a GM user
   * @return {Promise.<Compendium>}
   */
  async delete() {
    if ( !game.user.isGM ) throw new Error("You may not delete a Compendium pack");
    const eventData = {packName: this.metadata.name};
    return SocketInterface.trigger("deleteCompendiumPack", eventData, {}, {
      preHook: 'preDeleteCompendiumPack',
      context: this,
      success: response => {
        const idx = game.packs.findIndex(p => p.collection === this.collection);
        if ( idx !== -1 ) game.packs.splice(idx, 1);
        ui.compendium.render();
        return this;
      }
    });
  }

  /* ----------------------------------------- */

  /**
   * Get the Compendium index
   * Contains names and IDs of all data in the compendium
   *
   * @return {Promise}    A Promise containing an index of all compendium entries
   */
  getIndex() {
    return new Promise((resolve, reject) => {
      game.socket.emit('getCompendiumIndex', this.collection, resolve);
    }).then(index => this.index = index);
  }

  /* ----------------------------------------- */

  /**
   * Get the complete set of content for this compendium, loading all entries in full
   * Returns a Promise that resolves to an Array of entries
   *
   * @return {Promise.<Array>}
   */
  async getContent() {
    return new Promise((resolve) => {
      game.socket.emit("getCompendiumContent", this.collection, entries => {
        resolve(entries.map(e => this._toEntity(e)));
      });
    })
  }

  /* ----------------------------------------- */

  /**
   * Get a single Compendium entry as an Object
   * @param entryId {String}  The compendium entry ID to retrieve
   *
   * @return {Promise.<Object|null>}  A Promise containing the return entry data, or null
   */
  getEntry(entryId) {
    return new Promise((resolve, reject) => {
      game.socket.emit('getCompendiumEntry', this.collection, entryId, resolve);
    })
  }

  /* ----------------------------------------- */

  /**
   * Get a single Compendium entry as an Entity instance
   * @param {string} entryId          The compendium entry ID to load and instantiate
   * @return {Promise.<Entity|null>}   A Promise containing the returned Entity, if it exists, otherwise null
   */
  async getEntity(entryId) {
    const entry = await this.getEntry(entryId);
    return entry ? this._toEntity(entry) : null;
  }

  /* ----------------------------------------- */

  /**
   * Cast entry data to an Entity class
   * @param {Object} entryData
   * @private
   */
  _toEntity(entryData={}) {
    const cls = CONFIG[this.entity].entityClass;
    return new cls(entryData, {compendium: this});
  }

  /* ----------------------------------------- */

  /**
   * Import a new Entity into a Compendium pack
   * @param {Entity} entity     The Entity instance you wish to import
   * @return {Promise}          A Promise which resolves to the created Entity once the operation is complete
   */
  importEntity(entity) {
    if ( entity.entity !== this.entity ) {
      let err = "You are attempting to import the wrong type of entity into this pack";
      ui.notifications.error(err);
      throw new Error(err);
    }
    return this.createEntity(entity.data);
  }

  /* -------------------------------------------- */

  /**
   * Create a new Entity within this Compendium Pack using provided data
   * @param {Object} data       Data with which to create the entry
   * @return {Promise}          A Promise which resolves to the created Entity once the operation is complete
   */
  createEntity(data) {
    const eventData = {packName: this.collection, data: data};
    return SocketInterface.trigger("createCompendiumEntry", eventData, {}, {
      preHook: "preCreateCompendiumEntry",
      context: this,
      success: response => {
        this.render();
        return this._toEntity(response.created);
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Update a single Compendium entry programmatically by providing new data with which to update
   * @param {Object} data       The incremental update with which to update the Entity. Must contain the _id
   * @param {Object} options    Additional options which modify the update request
   * @return {Promise}          A Promise which resolves with the updated Entity once the operation is complete
   */
  updateEntity(data, options={}) {
    if ( !data._id ) throw new Error("You must specify the _id attribute for the data you wish to update");
    const eventData = {packName: this.collection, data: data};
    return SocketInterface.trigger("updateCompendiumEntry", eventData, options, {
      preHook: "preUpdateCompendiumEntry",
      context: this,
      success: response => {
        this.render(false);
        return this.getEntity(data._id);
      }
    });
  }

  /* ----------------------------------------- */

  /**
   * Delete a single Compendium entry by its provided _id
   * @param {String} id         The entry ID to delete
   * @return {Promise}          A Promise which resolves to the deleted entry ID once the operation is complete
   */
  deleteEntity(id) {
    const eventData = {packName: this.collection, id: id};
    return SocketInterface.trigger("deleteCompendiumEntry", eventData, {}, {
      preHook: "preDeleteCompendiumEntry",
      context: this,
      success: response => {
        this.render(false);
        return response.deleted;
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Request that a Compendium pack be migrated to the latest System data template
   * @return {Promise.<Compendium>}
   */
  migrate(options) {
    if ( !game.user.isGM ) throw new Error("You may not migrate a Compendium pack");
    ui.notifications.info(`Beginning migration for Compendium pack ${this.collection}, please be patient.`);
    return SocketInterface.trigger("migrateCompendiumPack", {packName: this.collection}, options, {
      success: response => {
        ui.notifications.info(`Successfully migrated Compendium pack ${this.collection}.`);
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Customize Compendium closing behavior to toggle the sidebar folder status icon
   */
  close() {
    super.close();
    let li = $(`.compendium-pack[data-pack="${this.collection}"]`);
    li.attr("data-open", "0");
    li.find("i.folder").removeClass("fa-folder-open").addClass("fa-folder");
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Register event listeners for Compendium directories
   * @private
   */
  activateListeners(html) { 

    // Search filtering
    html.find('input[name="search"]').keyup(ev => {
      let input = ev.currentTarget;
      this._searchTime = new Date();
      setTimeout(() => {
        if ( new Date() - this._searchTime > 250) this._onSearch(input.value);
      }, 251);
    });

    // Open sheets
    html.find('.entry-name').click(ev => {
      let li = ev.currentTarget.parentElement;
      this._onEntry(li.getAttribute("data-entry-id"));
    });

    // Make compendium entries draggable
    if ( game.user.isGM || this.entity === "Item" ) {
      html.find('.directory-item').each((i, li) => {
        li.setAttribute("draggable", true);
        li.addEventListener('dragstart', this._onDragStart, false);
      });
    }

    // GM only actions below here
    if ( !game.user.isGM ) return;

    // Make the compendium droppable
    html[0].ondragover = this._onDragOver;
    html[0].ondrop = this._onDrop;

    // Context menu
    this._contextMenu(html);
  }

  /* -------------------------------------------- */

  /**
   * Handle compendium filtering through search field
   * Toggle the visibility of indexed compendium entries by name (for now) match
   * @private
   */
  _onSearch(searchString) {
    let rgx = new RegExp(RegExp.escape(searchString), "i");
    this.element.find('li.directory-item').each((i, el) => {
      let name = el.getElementsByClassName('entry-name')[0].textContent;
      el.style.display = name.match(rgx) ? "flex" : "none";
    });
    this.searchString = searchString;
  }

  /* -------------------------------------------- */

  /**
   * Handle opening a single compendium entry by invoking the configured entity class and its sheet
   * @private
   */
  async _onEntry(entryId) {
    const entity = await this.getEntity(entryId);
    entity.sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle a new drag event from the compendium, create a placeholder token for dropping the item
   * @private
   */
  _onDragStart(event) {
    const li = this,
          packName = li.parentElement.parentElement.getAttribute("data-pack"),
          pack = game.packs.find(p => p.collection === packName);

    // Get the pack
    if ( !pack ) {
      event.preventDefault();
      return false;
    }

    // Set the transfer data
    event.dataTransfer.setData("text/plain", JSON.stringify({
      type: pack.entity,
      pack: pack.collection,
      id: li.getAttribute("data-entry-id")
    }));
  }

	/* -------------------------------------------- */

  /**
   * Allow data transfer events to be dragged over this as a drop zone
   * @private
   */
  _onDragOver(event) {
    event.preventDefault();
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle data being dropped into a Compendium pack
   * @private
   */
  async _onDrop(event) {
    event.preventDefault();
    const packName = this.getAttribute("data-pack"),
          pack = game.packs.find(p => p.collection === packName);

    // Try to extract the data
    let data;
    try {
      data = JSON.parse(event.dataTransfer.getData('text/plain'));
    }
    catch (err) {
      return false;
    }

    // Ensure an entity type was indicated
    if ( !data.type ) throw new Error("You must define the type of entity data being dropped");
    const cls = CONFIG[data.type].entityClass;
    let ent = null;

    // Case 1 - Data explicitly provided
    if ( data.data ) ent = new cls(data.data);

    // Case 2 - Import from other Compendium
    else if ( data.pack ) {
      if ( data.pack === packName ) return false;
      const source = game.packs.find(p => p.collection === data.pack);
      ent = await source.getEntity(data.id);
    }

    // Case 3 - Import from World data
    else ent = cls.collection.get(data.id);

    // Create the new Compendium entry
    pack.importEntity(ent);
  }

  /* -------------------------------------------- */

  /**
   * Render the ContextMenu which applies to each compendium entry
   * @private
   */
  _contextMenu(html) {
    new ContextMenu(html, ".directory-item", [
      {
        name: "Import",
        icon: '<i class="fas fa-download"></i>',
        callback: li => {
          const entryId = li.attr('data-entry-id');
          const entities = CONFIG[this.entity].entityClass.collection;
          return entities.importFromCollection(this.collection, entryId, {}, {renderSheet: true});
        }
      },
      {
        name: "Delete",
        icon: '<i class="fas fa-trash"></i>',
        callback: li => {
          let entryId = li.attr('data-entry-id');
          this.getEntity(entryId).then(entry => {
            new Dialog({
              title: `Delete ${entry.name}`,
              content: "<h3>Are you sure?</h3>" +
                       "<p>This compendium entry and its data will be deleted.</p>" +
                       "<p>If you do not own this compendium, your change could be reverted by future updates.</p>",
              buttons: {
                yes: {
                  icon: '<i class="fas fa-trash"></i>',
                  label: "Delete",
                  callback: () => this.deleteEntity(entryId)
                },
                no: {
                  icon: '<i class="fas fa-times"></i>',
                  label: "Cancel"
                }
              },
              default: 'yes'
            }).render(true);
          })
        }
      }
    ]);
  }
}

/**
 * An abstract class pattern for all primary data entities within the Foundry VTT Framework. An entity represents a
 * primary data concept, for example: Actor, Item, Scene, or ChatMessage. Each Entity type in Foundry Virtual
 * Tabletop extends this base Entity class which ensures similar behavior and workflow across all entity types.
 *
 * Documentation for this class is provided for reference, but developers should not extend this class directly,
 * instead work with or extend the Entity implementations that are referenced in this section of the API documentation.
 *
 * Entities are instantiated by providing their base data, and an optional Array of Application instances which should
 * be automatically refreshed when the Entity experiences an update.
 * @abstract
 *
 * @see {@link Collection} The Collection abstract class which contains Entity instances.
 * @see {@link Actor} The Actor Entity.
 * @see {@link Combat} The Combat Encounter Entity.
 * @see {@link Folder} The Folder Entity.
 * @see {@link Item} The Item Entity.
 * @see {@link JournalEntry} The Journal Entry Entity.
 * @see {@link ChatMessage} The Chat Message Entity.
 * @see {@link Playlist} The Audio Playlist Entity.
 * @see {@link Scene} The Scene Entity.
 * @see {@link RollTable} The Rollable Table Entity.
 * @see {@link User} The User Entity.
 * @see {@link Compendium} The Compendium which may contain Entities in a compendium pack.
 *
 * @param {Object} data       The data Object with which to create the Entity
 * @param {Object} options    Additional options which modify the created Entity behavior
 * @param {Compendium} [options.compendium] A reference to the Compendium pack from which this Entity was drawn.
 *
 * @example
 * let actorData = {name: "John Doe", type: "character", img: "icons/mystery-man.png"};
 * let actor = new Actor(actorData);
 */
class Entity {
  constructor(data, options) {

    /**
     * The original source data for the object provided at initialization.
     * @type {Object}
     */
    this.data = data || {};

    /**
     * The options object that was used to configure the Entity upon initialization.
     * @type {Object}
     */
    this.options = options || {};

    /**
     * A collection of Application instances which should be re-rendered whenever this Entity experiences an update to
     * its data. The keys of this object are the application ids and the values are Application instances. Each
     * Application in this object will have its render method called by @{link Entity#render}.
     * @type {Object.<Application>}
     * @see {Entity#render}
     */
    this.apps = {};

    /**
     * The Entity may optionally belong to a parent Compendium pack. If so this attribute will contain a reference
     * to that Compendium object. Otherwise null.
     * @type {Compendium|null}
     */
    this.compendium = this.options.compendium || null;

    // Initialize Entity data
    this.initialize();
  }

	/* -------------------------------------------- */

  /**
   * Configure the attributes of this Entity class
   * @type {Object}
   * @property {Entity} baseEntity       The parent class which directly inherits from the Entity interface.
   * @property {Collection} collection   The Collection instance to which Entities of this type belong.
   * @property {Array} embeddedEntities  The names of any Embedded Entities within the Entity data structure.
   */
  static get config() {
    throw new Error(`The ${this.name} subclass must define the Entity.config object`);
  }

	/* -------------------------------------------- */

  /**
   * A Universally Unique Identifier (uuid) for this Entity instance
   * @type {string}
   */
  get uuid() {
    if ( this.compendium ) return `Compendium.${this.compendium.collection}.${this.id}`;
    return `${this.entity}.${this.id}`;
  }

	/* -------------------------------------------- */

  /**
   * Initialize data structure for the Entity.
   * First initialize any Embedded Entities and prepare their data.
   * Next prepare data for the Entity itself, which may depend on Embedded Entities.
   */
  initialize() {
    try {
      this.prepareData(); // TODO - I should try and improve this, but chicken-egg problem for now
      this.prepareEmbeddedEntities();
      this.prepareData();
    } catch(err) {
      console.error(`Failed to initialize data for ${this.constructor.name} ${this.id}:`);
      console.error(err);
    }
  }

	/* -------------------------------------------- */

  /**
   * Prepare data for the Entity whenever the instance is first created or later updated.
   * This method can be used to derive any internal attributes which are computed in a formulaic manner.
   * For example, in a d20 system - computing an ability modifier based on the value of that ability score.
   */
	prepareData() {
    const data = this.data;
	  if ( data.hasOwnProperty("name") && !data.name ) {
	    data.name = "New " + this.entity;
    }
	  return data;
  }

	/* -------------------------------------------- */

  /**
   * Prepare Embedded Entities which exist within this parent Entity.
   * For example, in the case of an Actor, this method is responsible for preparing the Owned Items the Actor contains.
   */
	prepareEmbeddedEntities() {
    for ( let [name, collection] of Object.entries(this.constructor.config.embeddedEntities) ) {
      this[collection] = this.data[collection].map(d => this._constructEmbeddedEntity(name, d));
    }
  }

	/* -------------------------------------------- */

  /**
   * Prepare data for a single Embedded Entity which exists within the parent Entity.
   * @private
   * @param {string} embeddedName   The name of the Embedded Entity type
   * @param {Object} data           The data used to initialize it
   * @returns                       The Embedded Entity object
   */
  _constructEmbeddedEntity(embeddedName, data) {
	  throw new Error(`The ${this.constructor.name} subclass must define the _constructEmbeddedEntity() method`);
  }

	/* -------------------------------------------- */

  /**
   * Obtain a reference to the Array of source data within the data object for a certain Embedded Entity name
   * @param {string} embeddedName   The name of the Embedded Entity type
   * @return {Array}                The Array of source data where Embedded Entities of this type are stored
   */
  getEmbeddedCollection(embeddedName) {
    const collection = this.constructor.config.embeddedEntities[embeddedName];
    if ( !collection ) {
      throw new Error(`${embeddedName} is not a valid Embedded Entity in a ${this.constructor.name}`);
    }
    return this.data[collection];
  }

	/* -------------------------------------------- */
	/*  Properties
	/* -------------------------------------------- */

  /**
   * Return a reference to the Collection instance which stores Entity instances of this type. This property is
   * available as both a static and instance method and should be overridden by subclass Entity implementations.
   * @type {Collection}
   * @static
   */
	static get collection() {
	  if ( !this.config.collection ) throw new Error(`An Entity subclass must configure the Collection it belongs to.`);
	  return this.config.collection;
  }

	/* -------------------------------------------- */

  /** @alias {Entity.collection} */
  get collection() {
	  return this.constructor.collection;
  }

	/* -------------------------------------------- */

  /**
   * The class name of the base Entity type, for example "Actor". This is useful in cases where there is an inheritance
   * chain. Many places throughout the framework rely upon the canonical entity name which may not always be equal
   * to the class name. This property is available as both a static and instance method.
   * @type {string}
   *
   * @example
   * class Actor2ndGen extends Actor {...}
   * Actor2ndGen.entity // "Actor"
   */
	static get entity() {
	  if ( !this.config.baseEntity ) throw new Error(`An Entity subclass must configure the baseEntity it represents.`);
	  return this.config.baseEntity.name;
  }

	/* -------------------------------------------- */

  /** @alias {Entity.entity} */
	get entity() {
	  return this.constructor.entity;
  }

	/* -------------------------------------------- */

  /**
   * A convenience accessor for the _id attribute of the Entity data object.
   * @type {string}
   */
  get id() {
	  return this.data._id;
  }

	/* -------------------------------------------- */

  /** @alias {Entity#id} */
	get _id() {
	  return this.data._id;
  }

	/* -------------------------------------------- */

  /**
   * A convenience accessor for the name attribute of the Entity data object
   * @type {string}
   */
  get name() {
	  return this.data.name;
  }

	/* -------------------------------------------- */

  /**
   * A property which gets or creates a singleton instance of the sheet class used to render and edit data for this
   * particular entity type.
   * @type {BaseEntitySheet}
   *
   * @example <caption>A subclass of the Actor entity</caption>
   * let actor = game.entities.actors[0];
   * actor.sheet; // ActorSheet
   */
	get sheet() {
	  const cls = this._sheetClass;
	  if ( !cls ) return null;
	  let sheet = Object.values(this.apps).find(app => app.constructor === cls);
	  if ( !sheet ) sheet = new cls(this, {editable: this.owner});
	  return sheet;
	}

	/* -------------------------------------------- */

  /**
   * Obtain a reference to the BaseEntitySheet implementation which should be used to render the Entity instance
   * configuration sheet.
   * @private
   */
  get _sheetClass() {
    const cfg = CONFIG[this.entity];
    let cls = null;
    if ( !cfg ) return null;

    // Case 1 - Dynamic Sheet Classes are supported
    if ( cfg.sheetClasses ) {
      const type = this.data.type || CONST.BASE_ENTITY_TYPE;
      const sheets = cfg.sheetClasses[type] || {};
      const override = this.getFlag("core", "sheetClass");
      if ( sheets[override] ) cls = sheets[override].cls;
      else {
        let classes = Object.values(sheets);
        let def = classes.find(s => s.default) || classes.pop();
        if ( def ) cls = def.cls;
      }
      if ( !cls ) throw new Error(`No valid ${this.entity} sheet found for type ${type}`);
    }

    // Case 2 - Static sheets only
    else cls = cfg.sheetClass;
    return cls;
  }

	/* -------------------------------------------- */

  /**
   * Return a reference to the Folder which this Entity belongs to, if any.
   * @type {Folder|null}
   *
   * @example <caption>Entities may belong to Folders</caption>
   * let folder = game.folders.entities[0];
   * let actor = await Actor.create({name: "New Actor", folder: folder.id});
   * console.log(actor.data.folder); // folder.id;
   * console.log(actor.folder); // folder;
   */
  get folder() {
    if ( !this.data.folder ) return null;
    return game.folders.get(this.data.folder);
  }

	/* -------------------------------------------- */

  /**
   * Return the permission level that the current game User has over this Entity.
   * See the CONST.ENTITY_PERMISSIONS object for an enumeration of these levels.
   * @type {Number}
   *
   * @example
   * game.user.id; // "dkasjkkj23kjf"
   * entity.data.permission; // {default: 1, "dkasjkkj23kjf": 2};
   * entity.permission; // 2
   */
	get permission() {

	  // Game-masters and Assistants are always owners
    if ( game.user.isGM ) return CONST.ENTITY_PERMISSIONS.OWNER;

    // User-specific permission
    let userPerm = this.data.permission[game.user._id];
    return userPerm ? userPerm : this.data.permission["default"];
  }

  /* -------------------------------------------- */

  /**
   * A boolean indicator for whether or not the current game User has ownership rights for this Entity.
   * This property has a setter which allows for ownership rights to be temporarily overridden on a per-instance basis.
   * @type {boolean}
   */
  get owner() {
    return !this.compendium && this.hasPerm(game.user, "OWNER");
  }

  /* -------------------------------------------- */

  /**
   * A boolean indicator for whether or not the current game User has at least limited visibility for this Entity.
   * @type {boolean}
   */
  get visible() {
    return this.hasPerm(game.user, "LIMITED", false);
  }

  /* -------------------------------------------- */

  /**
   * A boolean indicator for whether the current game user has ONLY limited visibility for this Entity.
   * Note that a GM user's perspective of an Entity is never limited.
   * @type {boolean}
   */
  get limited() {
    if ( game.user.isGM ) return false;
    return this.hasPerm(game.user, "LIMITED", true);
  }

	/* -------------------------------------------- */
	/* Methods
	/* -------------------------------------------- */

  /**
   * Render all of the Application instances which are connected to this Entity by calling their respective
   * {@link Application#render} methods.
   * @param {...*} args      Variable arguments which are forwarded to each Application's render call
   */
  render(...args) {
    for ( let app of Object.values(this.apps) ) {
      app.render(...args);
    }
  }

  /* -------------------------------------------- */

  /**
   * Test whether a provided User a specific permission level (or greater) over the Entity instance
   * @param {User} user                   The user to test for permission
   * @param {string|number} permission    The permission level or level name to test
   * @param {boolean} exact               Tests for an exact permission level match, by default this method tests for
   *                                      an equal or greater permission level.
   * @return {boolean}                    Whether or not the user has the permission for this Entity.
   *
   * @example <caption>Test whether a specific user has a certain permission</caption>
   * // These two are equivalent
   * entity.hasPerm(game.user, "OWNER");
   * entity.owner;
   * // These two are also equivalent
   * entity.hasPerm(game.user, "LIMITED", true);
   * entity.limited;
   */
  hasPerm(user, permission, exact=false) {

    // If the entity does not have a permission object (e.g. Folder), only GM has any permission
    if ( !this.data.permission ) return user.isGM;

    // Get the user's permission level
    let level = this.data.permission[user._id];
    level = Number.isInteger(level) ? level : this.data.permission["default"];
    const perm = CONST.ENTITY_PERMISSIONS[permission];

    // Test permission against the target level
    if ( exact ) return level === perm;
    else if ( user.isGM ) return true;
    return level >= perm;
  }

	/* -------------------------------------------- */
	/*  Entity Management Methods                   */
	/* -------------------------------------------- */

  /**
   * Create a new entity using provided input data
   * The data for entity creation is typically provided from the server through the 'create<Entity>' socket
   * Alternatively, the creation event may originate locally and the new entity can be pushed back to the server.
   * @static
   *
   * @param {Object} data         The data with which to create the entity
   * @param {Object} options      Additional options which customize the creation workflow
   * @param {boolean} options.temporary     Create a temporary entity which is not saved to the world database.
   *                                        Default is false.
   * @param {boolean} options.renderSheet   Display the sheet for the created entity once it is created. Default is false.
   * 
   * @return {Promise}            A Promise which resolves to contain the created Entity
   *
   * @example
   * const createData = {name: "New Entity", img: "path/to/profile.jpg"};
   * const created = await Entity.create(createData); // Saved to the database
   * const temp = await Entity.create(createData, {temporary: true}); // Not saved to the database
   */
  static async create(data, options={}) {
    const collection = this.collection;
    const name = this.entity;
    options = mergeObject({temporary: false, renderSheet: false}, options);
    return SocketInterface.trigger('create'+name, {data}, options, {
      preHook: 'preCreate'+name,
      context: collection,
      success: collection._createEntity,
      postHook: 'create'+name
    });
  }

	/* -------------------------------------------- */

  /**
   * Create multiple new Entities using provided input data Array, containing one Object per created Entity.
   * @static
   *
   * @param {Array.<Object>} data           The data with which to create the entity
   * @param {Object} options                Additional options which customize the creation workflow
   * @param {Boolean} options.temporary     Created entities are temporary and not saved to the database. Default false.
   * @param {Boolean} options.renderSheet   Display sheets for each created entities. Default false.
   *
   * @return {Promise}                      A Promise which resolves to contain the created Entities
   *
   * @example
   * const dataArray = [{name: "Entry 1"}, {name: "Entry 2"}, {name: "Entry 3"}];
   * const entries = await Entity.createMany(dataArray); // Saved to the database
   * const temps = await Entity.createMany(dataArray, {temporary: true}); // Not saved to the database
   */
  static async createMany(data, options={}) {
    const collection = this.collection;
    const name = this.entity;
    options = mergeObject({temporary: false, renderSheet: false}, options);
    return SocketInterface.triggerMany('createMany'+name, {data}, options, {
      preHook: 'preCreate'+name,
      context: collection,
      success: collection._createManyEntities,
      postHook: 'create'+name
    })
  }

	/* -------------------------------------------- */

  /**
   * Update the current entity using new data
   * This new data is typically provided from the server through the 'update<Entity>' socket
   * Alternatively, the update may originate locally, in which case it can be pushed back to the server
   *
   * @param {Object} data     The data with which to update the entity
   * @param {Object} options  Additional options which customize the update workflow
   * @param {Boolean} options.diff    Diff the provided data against existing entity data, only submitting the
   *                                  difference to the server. Default is true.
   *
   * @return {Promise}        A Promise which resolves to the updated Entity
   *
   * @example
   * const updateData = {name: "New Name"};
   * const updated = await entity.update(updateData);
   */
  async update(data, options={}) {
    const collection = this.collection;
    const name = this.entity;

    // Diff the provided update data against current values
    let updateData = data;
    if ( options.diff !== false ) {
      const changed = diffObject(this.data, expandObject(data));
      if (!Object.keys(changed).length) return this;
      updateData = changed;
    }
    updateData._id = this._id;

    // Trigger the socket event and handle response
    return SocketInterface.trigger('update'+name, {data: updateData}, options, {
      preHook: 'preUpdate'+name,
      context: this,
      success: collection._updateEntity.bind(collection),
      postHook: 'update'+name
    });
  }

	/* -------------------------------------------- */

  /**
   * Update multiple Entities using an Array of provided update Objects which define incremental data for each Entity.
   * @static
   *
   * @param {Array.<Object>} data           Data with which to update each Entity. Each Object must include the _id
   * @param {Object} options                Additional options which customize the update workflow
   *
   * @return {Promise}                      A Promise which resolves to contain the updated Entities
   *
   * @example
   * const updateArray = [{_id: "dgfkjt34kjdgfkjt34", name: "Name 1"}, {_id: "dfskjkj2r3kjdvkj2", name: "Name 2"}];
   * const updated = await Entity.updateMany(updateArray);
   */
  static async updateMany(data, options={}) {
    const collection = this.collection;
    const name = this.entity;

    // Structure the update data
    const updateIds = new Set();
    const updates = data.reduce((obj, d) => {
      if ( !d._id ) throw new Error(`You must provide an _id for every ${name} in the data Array.`);
      updateIds.add(d._id);
      obj[d._id] = { changed: d };
      return obj;
    }, {});

    // Difference each update against the existing data
    const changes = [];
    for ( let e of collection.entities ) {
      if ( !updateIds.has(e.id) ) continue;
      let changed = diffObject(e.data, expandObject(updates[e.id].changed));
      if ( !isObjectEmpty(changed) ) {
        changed["_id"] = e.id;
        changes.push(changed);
      }
    }

    // Dispatch the update request and return the resolution
    return SocketInterface.triggerMany('updateMany'+name, {data: changes}, options, {
      preHook: 'preUpdate'+name,
      context: collection,
      success: collection._updateManyEntities,
      postHook: 'update'+name
    });
  }

	/* -------------------------------------------- */

  /**
   * Delete the entity, removing it from its collection and deleting its data record
   * @param {Object} options    Additional options which customize the deletion workflow
   * @return {Promise}          A Promise which resolves to the ID of the deleted Entity once handled by the server
   *
   * @example
   * const deleted = await entity.delete();
   */
  async delete(options={}) {
    const collection = this.collection;
    const name = this.entity;
    return SocketInterface.trigger('delete'+name, {id: this._id}, options, {
      preHook: 'preDelete'+name,
      context: this,
      success: collection._deleteEntity.bind(collection),
      postHook: 'delete'+name
    });
  }

  /* -------------------------------------------- */

  /**
   * Delete multiple Entities using a provided Array of ids, one per Entity.
   * @static
   *
   * @param {Array.<string>} ids            The data with which to create the entity
   * @param {Object} options                Additional options which customize the deletion workflow
   * @param {boolean} options.deleteAll     An optional flag which specifies that all Entities should be deleted
   * @return {Promise}                      A Promise which resolves to contain the created Entities
   *
   * @example
   * const deleteIds = ["dskjfk23jf23kdjs", "g90klju9yujl9hj2", "23hjdfewh23rgf3"];
   * const deleted = await Entity.deleteMany(deleteIds);
   */
  static async deleteMany(ids, options={}) {
    const collection = this.collection;
    const name = this.entity;
    return SocketInterface.triggerMany('deleteMany'+name, {data: ids}, options, {
      preHook: 'preDelete'+name,
      context: collection,
      success: collection._deleteManyEntities,
      postHook: 'delete'+name
    })
  }

  /* -------------------------------------------- */

  /**
   * Entity-specific actions that should occur when the Entity is first created
   * @private
   */
	_onCreate(data, options, userId, context) {
    if ( options.renderSheet && (userId === game.user._id) ) {
      if ( this.sheet ) this.sheet.render(true, context);
    }
  }

	/* -------------------------------------------- */

  /**
   * Entity-specific actions that should occur when the Entity is updated
   * @private
   */
	_onUpdate(data, options, userId, context) {
    this.prepareData();
    this.render(false, context);
  }

  /* -------------------------------------------- */

  /**
   * Entity-specific actions that should occur when the Entity is deleted
   * @private
   */
  _onDelete(id, options, userId, context) {
    Object.values(this.apps).forEach(a => a.close({submit: false}));
  }

	/* -------------------------------------------- */
  /*  Embedded Entity Management                  */
	/* -------------------------------------------- */

  /**
   * Get an Embedded Entity by it's id from a named collection in the parent Entity.
   *
   * @param {string} embeddedName   The name of the Embedded Entity type to retrieve
   * @param {string} id             The numeric ID of the child to retrieve
   * @param {boolean} strict        Throw an Error if the requested id does not exist, otherwise return null. Default false.
   * @return {Object|null}          Retrieved data for the requested child, or null
   */
  getEmbeddedEntity(embeddedName, id, {strict=false}={}) {
    const collection = this.getEmbeddedCollection(embeddedName);
    const child = collection.find(c => c._id === id);
    if ( !child && strict ) {
      throw new Error(`The ${embeddedName} ${id} does not exist in ${this.constructor.name} ${this._id}`);
    }
    return child || null;
  }

  /* -------------------------------------------- */

  /**
   * Remove an Embedded Entity from the parent Entity data by it's id.
   *
   * @param {string} embeddedName   The name of the Embedded Entity type to retrieve
   * @param {number} id             The numeric ID of the child to retrieve
   * @return {Object|null}          The embedded entity data that was removed
   */
  removeEmbeddedEntity(embeddedName, id) {
    const collection = this.getEmbeddedCollection(embeddedName);
    return collection.findSplice(c => c._id === id);
  }

	/* -------------------------------------------- */

  /**
   * Create one EmbeddedEntity within this parent Entity.
   * Dispatch the creation request to the server for handling.
   * The result will be acknowledged to this client, and broadcast to other connected clients.
   *
   * @param {string} embeddedName   The name of the Embedded Entity class to create
   * @param {Object} createData     An object of initial data from which to create the Embedded Entity
   * @param {Object} options        Additional creation options which modify the request
   * @return {Promise}              A Promise which resolves to this Entity once the creation request is successful
   */
  async createEmbeddedEntity(embeddedName, createData, options={}) {

    // Validate inputs
    const collection = this.getEmbeddedCollection(embeddedName);
    delete createData._id;

    // Prepare submission data
    options["embeddedName"] = embeddedName;
    const eventName = `create${embeddedName}`;
    const eventData = {parentId: this._id, data: createData};

    // Dispatch the update request and return the resolution
    return SocketInterface.trigger(eventName, eventData, options, {
      preHook: `preCreate${embeddedName}`,
      context: this,
      success: this.collection._createEmbeddedEntity.bind(this.collection),
      postHook: eventName
    });
  }

	/* -------------------------------------------- */

  /**
   * Create multiple Embedded Entities within this parent Entity using an Array of creation data.
   * Dispatch the update request to the server for handling.
   * The result will be acknowledged to this client, and broadcast to other connected clients.
   *
   * @param {string} embeddedName   The name of the Embedded Entity class to update
   * @param {Array} createData      An Array of initial data objects from which to create the Embedded Entities.
   * @param {Object} options        Additional update options which modify the request
   * @return {Promise}              A Promise which resolves to this Entity once the creation request is successful
   */
  async createManyEmbeddedEntities(embeddedName, createData, options={}) {

    // Validate inputs
    options["embeddedName"] = embeddedName;
    for ( let d of createData ) {
      delete d._id;
    }

    // Dispatch the update request and return the resolution
    const eventData = {parentId: this._id, data: createData};
    return SocketInterface.triggerMany(`createMany${embeddedName}`, eventData, options, {
      preHook: `preCreate${embeddedName}`,
      context: this,
      success: this.collection._createManyEmbeddedEntities.bind(this.collection),
      postHook: `create${embeddedName}`
    });
  }

	/* -------------------------------------------- */

  /**
   * Update one EmbeddedEntity within this parent Entity using incremental data.
   * Dispatch the update request to the server for handling.
   * The result will be acknowledged to this client, and broadcast to other connected clients.
   *
   * @param {string} embeddedName   The name of the Embedded Entity class to update
   * @param {Object} updateData     An object of incremental data from which to update the Embedded Entity
   * @param {Object} options        Additional update options which modify the request
   * @return {Promise}              A Promise which resolves to this Entity once the update request is successful
   */
  async updateEmbeddedEntity(embeddedName, updateData, options={}) {

    // Validate inputs
    this.getEmbeddedCollection(embeddedName);
    if ( !options.currentData && !updateData._id ) {
      throw new Error(`You must provide the child object or it's id in order to update a ${embeddedName}`);
    }
    const currentData = options.currentData || this.getEmbeddedEntity(embeddedName, updateData._id);
    options["embeddedName"] = embeddedName;

    // Diff the provided update data against current values
    const changedData = diffObject(currentData, expandObject(updateData));
    if ( !Object.keys(changedData).length ) return this;
    changedData["_id"] = currentData._id;

    // Dispatch the update request and return the resolution
    const eventName = `update${embeddedName}`;
    const eventData = {parentId: this._id, data: changedData};
    return SocketInterface.trigger(eventName, eventData, options, {
      preHook: `preUpdate${embeddedName}`,
      context: this,
      success: this.collection._updateEmbeddedEntity.bind(this.collection),
      postHook: eventName
    });
  }

	/* -------------------------------------------- */

  /**
   * Update multiple Embedded Entities within this parent Entity using incremental data.
   * Dispatch the update request to the server for handling.
   * The result will be acknowledged to this client, and broadcast to other connected clients.
   *
   * @param {string} embeddedName   The name of the Embedded Entity class to update
   * @param {Array} updateData      An Array of incremental data, one per Embedded Entity, with which to update the Entity
   * @param {Object} options        Additional update options which modify the request
   * @return {Promise}              A Promise which resolves to this Entity once the update request is successful
   */
  async updateManyEmbeddedEntities(embeddedName, updateData, options={}) {

    // Validate inputs
    const collection = this.getEmbeddedCollection(embeddedName);
    options["embeddedName"] = embeddedName;

    // Structure the update data
    const updateIds = new Set();
    const updates = updateData.reduce((obj, d) => {
      if ( !d._id ) throw new Error("You must provide an id for every Embedded Entity in an updateMany operation");
      updateIds.add(d._id);
      obj[d._id] = { changed: d };
      return obj;
    }, {});

    // Difference each update against existing data
    const changes = [];
    for ( let e of collection ) {
      if ( !updateIds.has(e._id) ) continue;
      let changed = diffObject(e, expandObject(updates[e._id].changed));
      if ( !isObjectEmpty(changed) ) {
        changed["_id"] = e._id;
        changes.push(changed);
      }
    }

    // Dispatch the update request and return the resolution
    const eventData = {parentId: this._id, data: changes};
    return SocketInterface.triggerMany(`updateMany${embeddedName}`, eventData, options, {
      preHook: `preUpdate${embeddedName}`,
      context: this,
      success: this.collection._updateManyEmbeddedEntities.bind(this.collection),
      postHook: `update${embeddedName}`
    });
  }

	/* -------------------------------------------- */

  /**
   * Delete one EmbeddedEntity within this parent Entity.
   * Dispatch the deletion request to the server for handling.
   * The result will be acknowledged to this client, and broadcast to other connected clients.
   *
   * @param {string} embeddedName   The name of the Embedded Entity class to delete
   * @param {string} childId        The id of the existing Embedded Entity child to delete
   * @param {Object} options        Additional deletion options which modify the request
   * @return {Promise}              A Promise which resolves to this Entity once the deletion request is successful
   */
  async deleteEmbeddedEntity(embeddedName, childId, options={}) {

    // Validate inputs
    this.getEmbeddedCollection(embeddedName);
    options["embeddedName"] = embeddedName;

    // Dispatch the update request and return the resolution
    const eventName = `delete${embeddedName}`;
    const eventData = {parentId: this._id, childId: childId};
    return SocketInterface.trigger(eventName, eventData, options, {
      preHook: `preDelete${embeddedName}`,
      context: this,
      success: this.collection._deleteEmbeddedEntity.bind(this.collection),
      postHook: eventName
    });
  }

	/* -------------------------------------------- */

  /**
   * Delete multiple Embedded Entities within this parent Entity by an Array of child ids.
   * Dispatch the update request to the server for handling.
   * The result will be acknowledged to this client, and broadcast to other connected clients.
   *
   * @param {string} embeddedName   The name of the Embedded Entity class to update
   * @param {Array} deleteIds       An Array of Embedded Entity ids to delete from the parent Entity
   * @param {Object} options        Additional update options which modify the request
   * @return {Promise}              A Promise which resolves to this Entity once the update request is successful
   */
  async deleteManyEmbeddedEntities(embeddedName, deleteIds, options={}) {

    // Validate inputs
    const collection = this.getEmbeddedCollection(embeddedName);
    deleteIds = new Set(deleteIds);
    options["embeddedName"] = embeddedName;

    // Validate requested IDs
    const toDelete = [];
    for ( let e of collection ) {
      if ( !deleteIds.has(e._id) ) continue;
      toDelete.push(e._id);
    }

    // Dispatch the update request and return the resolution
    const eventData = {parentId: this._id, data: toDelete};
    return SocketInterface.triggerMany(`deleteMany${embeddedName}`, eventData, options, {
      preHook: `preDelete${embeddedName}`,
      context: this,
      success: this.collection._deleteManyEmbeddedEntities.bind(this.collection),
      postHook: `delete${embeddedName}`
    });
  }

	/* -------------------------------------------- */

  /**
   * Handle Embedded Entity creation within this Entity with specific callback steps.
   * This callback function is triggered by Collection._createEmbeddedEntity once the source data is updated.
   * @private
   */
  _onCreateEmbeddedEntity(response) {}

	/* -------------------------------------------- */

  /**
   * Handle Embedded Entity update within this Entity with specific callback steps.
   * This callback function is triggered by Collection._updateEmbeddedEntity once the source data is updated.
   * @private
   */
  _onUpdateEmbeddedEntity(response) {}

	/* -------------------------------------------- */

  /**
   * Handle Embedded Entity deletion within this Entity with specific callback steps.
   * This callback function is triggered by Collection._deleteEmbeddedEntity once the source data is updated.
   * @private
   */
  _onDeleteEmbeddedEntity(response) {}

	/* -------------------------------------------- */

  /**
   * A generic helper since we take the same actions for every type of Embedded Entity update
   * @private
   */
  _onModifyEmbeddedEntity({embeddedName, renderContext}={}) {
    this.prepareData();
    this.render(false, renderContext);
  }

  /* -------------------------------------------- */
  /*  Data Flags                                  */
  /* -------------------------------------------- */

  /**
   * Get the value of a "flag" for this Entity
   * See the setFlag method for more details on flags
   *
   * @param {String} scope    The flag scope which namespaces the key
   * @param {String} key      The flag key
   * @return {*}              The flag value
   */
  getFlag(scope, key) {
    const scopes = SetupConfiguration.getPackageScopes();
    if ( !scopes.includes(scope) ) throw new Error(`Invalid scope for flag ${key}`);
    key = `${scope}.${key}`;
    return getProperty(this.data.flags, key);
  }

  /* -------------------------------------------- */

  /**
   * Assign a "flag" to this Entity.
   * Flags represent key-value type data which can be used to store flexible or arbitrary data required by either
   * the core software, game systems, or user-created modules.
   *
   * Each flag should be set using a scope which provides a namespace for the flag to help prevent collisions.
   *
   * Flags set by the core software use the "core" scope.
   * Flags set by game systems or modules should use the canonical name attribute for the module
   * Flags set by an individual world should "world" as the scope.
   *
   * Flag values can assume almost any data type. Setting a flag value to null will delete that flag.
   *
   * @param {String} scope    The flag scope which namespaces the key
   * @param {String} key      The flag key
   * @param {*} value         The flag value
   *
   * @return {Promise.<Entity>} A Promise resolving to the updated Entity
   */
  async setFlag(scope, key, value) {
    const scopes = SetupConfiguration.getPackageScopes();
    if ( !scopes.includes(scope) ) throw new Error(`Invalid scope for flag ${key}`);
    key = `flags.${scope}.${key}`;
    return this.update({[key]: value});
  }

  /* -------------------------------------------- */

  /**
   * Remove a flag assigned to the Entity
   * @param {string} scope    The flag scope which namespaces the key
   * @param {string} key      The flag key
   * @return {Promise}        A Promise resolving to the updated Entity
   */
  async unsetFlag(scope, key) {
    const scopes = SetupConfiguration.getPackageScopes();
    if ( !scopes.includes(scope) ) throw new Error(`Invalid scope for flag ${key}`);
    key = `flags.${scope}.-=${key}`;
    return this.update({[key]: null});
  }

  /* -------------------------------------------- */
  /*  Sorting                                     */
  /* -------------------------------------------- */

  /**
   * Sort this Entity relative a target by providing the target, an Array of siblings and other options.
   * If the Entity has an rendered sheet, record the sort change as part of a form submission
   * See SortingHelper.performIntegerSort for more details
   */
  async sortRelative({target=null, siblings=[], sortKey="sort", sortBefore=true, updateData={}}={}) {
    const updates = SortingHelpers.performIntegerSort(this, {target, siblings, sortKey, sortBefore});
    for ( let u of updates ) {
      const ent = u.target;
      const update = mergeObject(updateData, u.update, {inplace: false});
      if ( ent.sheet && ent.sheet.rendered ) await ent.sheet.submit({updateData: update});
      else await ent.update(update);
    }
  }

  /* -------------------------------------------- */
  /*  Saving and Loading
  /* -------------------------------------------- */

  /**
   * Clone an Entity, creating a new Entity using the current data as well as provided creation overrides.
   *
   * @param {Object} createData     Additional data which overrides current Entity data at the time of creation
   * @param {Object} options        Additional creation options passed to the Entity.create method
   * @returns {Promise.<Entity>}    A Promise which resolves to the created clone Entity
   */
  async clone(createData={}, options={}) {
    createData = mergeObject(this.data, createData, {inplace: false});
    await this.constructor.create(createData, options);
  }

  /* -------------------------------------------- */

  /**
   * Export entity data to a JSON file which can be saved by the client and later imported into a different session
   */
  exportToJSON() {

    // Prepare export data
    const data = duplicate(this.data);
    delete data.folder;
    delete data.permission;

    // Flag some metadata about where the entity was exported some - in case migration is needed later
    data.flags["exportSource"] = {
      world: game.world.id,
      system: game.system.id,
      coreVersion: game.data.version,
      systemVersion: game.system.data.version
    };

    // Trigger file save procedure
    const filename = `fvtt-${this.entity}-${this.name.replace(/\s/g, "_")}.json`;
    saveDataToFile(JSON.stringify(data, null, 2), "text/json", filename);
  }

  /* -------------------------------------------- */

  /**
   * Import data and update this entity
   * @param {String} json         JSON data string
   * @return {Promise.<Entity>}   The updated Entity
   */
  async importFromJSON(json) {
    const data = JSON.parse(json);
    delete data._id;
    return this.update(data);
  }

  /* -------------------------------------------- */

  /**
   * Render an import dialog for updating the data related to this Entity through an exported JSON file
   * @return {Promise.<void>}
   */
  async importFromJSONDialog() {
    new Dialog({
      title: `Import Data: ${this.name}`,
      content: await renderTemplate("templates/apps/import-data.html", {entity: this.entity, name: this.name}),
      buttons: {
        import: {
          icon: '<i class="fas fa-file-import"></i>',
          label: "Import",
          callback: html => {
            const form = html.find("form")[0];
            if ( !form.data.files.length ) return ui.notifications.error("You did not upload a data file!");
            readTextFromFile(form.data.files[0]).then(json => this.importFromJSON(json));
          }
        },
        no: {
          icon: '<i class="fas fa-times"></i>',
          label: "Cancel"
        }
      },
      default: "import"
    }, {
      width: 400
    }).render(true);
  }

  /* -------------------------------------------- */

  /**
   * Serializing an Entity should simply serialize it's inner data, not the entire instance
   * @return {Object}
   */
  toJSON() {
    return this.data;
  }
}

/**
 * The virtual tabletop environment is implemented using a WebGL powered HTML 5 canvas using the powerful PIXI.js
 * library. The canvas is comprised of an ordered sequence of layers which define rendering groups and collections of
 * objects that are drawn on the canvas itself.
 *
 * @see {link CanvasLayer} An abstract class for all Canvas layers.
 * @see {link PlaceablesLayer} An abstract class for Canvas Layers which contain Placeable Objects.
 * @see {link PlaceableObject} An abstract class for objects which are placed into the Scene and drawn on the canvas.
 *
 * @example <caption>Example Canvas commands</caption>
 * canvas.ready; // Is the canvas ready for use?
 * canvas.scene; // The currently viewed Scene entity.
 * canvas.dimensions; // The dimensions of the current Scene.
 * canvas.draw(); // Completely re-draw the game canvas (this is usually unnecessary).
 * canvas.pan(x, y, zoom); // Pan the canvas to new coordinates and scale.
 * canvas.recenter(); // Re-center the canvas on the currently controlled Token.
 */
class Canvas {
  constructor() {

    // Draw the canvas
    const canvas = document.createElement("canvas");
    canvas.id = "board";
    $("#board").replaceWith(canvas);
    canvas.ondragover = this._onDragOver;
    canvas.ondrop = this._onDrop;

    // Create PIXI Application
    this.app = new PIXI.Application({
      view: canvas,
      width: window.innerWidth,
      height: window.innerHeight,
      antialias: true,
      transparent: false,
      resolution: 1,
      backgroundColor: null
    });

    // Confirm that WebGL is available
    if ( this.app.renderer.type !== PIXI.RENDERER_TYPE.WEBGL ) {
      Hooks.once("renderNotifications", app => {
        app.error("WebGL support not detected, ensure you have hardware rendering support enabled.");
      });
      throw new Error("No WebGL Support!");
    }

    // Create the primary canvas layers
    this.stage = this.app.stage;
    this.hud = new HeadsUpDisplay();
    this._createLayers(this.stage);

    // Record the active scene and its dimensions
    this.id = null;
    this.scene = null;
    this.dimensions = null;

    /**
     * Track the timestamp of the last stage zoom operation
     * @type {Number}
     * @private
     */
    this._zoomTime = 0;

    /**
     * Throttle mousemove events to only fire once per 20ms
     * @type {number}
     * @private
     */
    this._mouseMoveTime = 0;

    /**
     * An object of data which is temporarily cached to be reloaded after the canvas is drawn
     * @type {Object}
     * @private
     */
    this._reload = { layer: "TokenLayer" };

    /**
     * A flag for whether the game Canvas is ready to be used. False if the canvas is not yet drawn, true otherwise.
     * @type {boolean}
     */
    this.ready = false;

    /**
     * An Array of pending canvas operations which should trigger on the next re-paint
     * @type {Array}
     */
    this.pendingOperations = [];

    /**
     * A Set of unique pending operation names to ensure operations are only performed once
     * @type {Set.<string>}
     */
    this._pendingOperationNames = new Set();
  }

  /* -------------------------------------------- */

  /**
   * Create the layers of the game Canvas
   * @param {PIXI.Container} stage    The primary canvas stage
   * @private
   */
  _createLayers(stage) {
    this.background = stage.addChild(new BackgroundLayer());
    this.tiles = stage.addChild(new TilesLayer());
    this.drawings = stage.addChild(new DrawingsLayer());
    this.grid = stage.addChild(new GridLayer());
    this.templates = stage.addChild(new TemplateLayer());
    this.walls = stage.addChild(new WallsLayer());
    this.notes = stage.addChild(new NotesLayer());
    this.tokens = stage.addChild(new TokenLayer());
    this.lighting = stage.addChild(new LightingLayer());
    this.sight = stage.addChild(new SightLayer());
    this.sounds = stage.addChild(new SoundsLayer());
    this.effects = stage.addChild(new EffectsLayer());
    this.controls = stage.addChild(new ControlsLayer());
  }

  /* -------------------------------------------- */
  /*  Properties and Attributes
  /* -------------------------------------------- */

  get layers() {
    return this.stage.children.filter(l => l instanceof CanvasLayer);
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the active Canvas Layer
   * @type {CanvasLayer}
   */
  get activeLayer() {
    return this.layers.find(l => l._active);
  }

  /* -------------------------------------------- */
  /*  Rendering
  /* -------------------------------------------- */

  /**
   * When re-drawing the canvas, first tear down or discontinue some existing processes
   */
  async tearDown() {
    const layer = this.activeLayer;
    const layers = this.layers;

    // Track current data which should be restored on draw
    this._reload = {
      scene: this.scene._id,
      layer: layer.name,
      controlledTokens: Object.keys(this.tokens._controlled),
      targetedTokens: Array.from(game.user.targets).map(t => t.id)
    };

    // Perform layer-specific tear-down actions
    for ( let l of layers.reverse() ) {
      await l.tearDown();
    }
  }

  /* -------------------------------------------- */

  /**
   * Draw the game canvas.
   */
  async draw(scene) {
    scene = (scene === undefined ? game.scenes.viewed : scene) || null;
    const wasReady = this.ready;
    this.ready = false;

    // Tear down any existing scene
    if ( wasReady ) await this.tearDown();

    // Confirm there is an active scene
    if ( scene === null ) {
      canvas.app.view.style.display = "none";
      console.log(`${vtt} | Skipping game canvas - no active scene.`);
      return;
    }
    else if ( !(scene instanceof Scene) ) {
      throw new Error("You must provide a Scene entity to draw the VTT canvas.")
    }

    // Configure Scene to draw
    this.id = scene._id;
    this.scene = scene;
    this.dimensions = this.getDimensions(scene.data);
    canvas.app.view.style.display = "block";
    document.documentElement.style.setProperty("--gridSize", this.dimensions.size+"px");
    console.log(`${vtt} | Drawing game canvas for scene ${this.scene.name}`);
    Hooks.callAll('canvasInit', this);

    // Configure primary canvas stage
    this.stage.visible = false;
    this.stage.position.set(window.innerWidth/2, window.innerHeight/2);
    this.stage.hitArea = new PIXI.Rectangle(0, 0, this.dimensions.width, this.dimensions.height);
    this.stage.interactive = true;
    if ( this.scene.data.backgroundColor ) {
      this.app.renderer.backgroundColor = colorStringToHex(this.scene.data.backgroundColor);
    }

    // Load required textures
    await loadSceneTextures(this.scene);

    // Draw layers
    for ( let l of this.layers ) {
      await l.draw();
    }

    // Initialize starting conditions
    this._initialize();

    // Add interactivity
    this._addListeners();

    // Check if the window was re-sized before the draw operation concluded
    this._onResize(new Event("resize"));

    // Mark the canvas as ready and call hooks
    this.stage.visible = this.ready = true;
    Hooks.call("canvasReady", this);
    this._reload = {};
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Get the canvas active dimensions based on the size of the scene's map
   * We expand the image size by a factor of 1.5 and round to the nearest 200 pixels
   * This guarantees that walls and tokens remain positioned in the same location if the grid size changes
   *
   * @param {Object} data     The scene dimensions data being established
   */
  getDimensions({width, height, grid, gridDistance, shiftX, shiftY}={}) {
    const gx2 = grid * 2;
    const w = width || (grid * 30);
    const h = height || (grid * 20);

    // Assign dimensions
    const dims = {
      width: w + Math.ceil(0.5 * w / gx2) * gx2,
      sceneWidth: w,
      height: h + Math.ceil(0.5 * h / gx2) * gx2,
      sceneHeight: h,
      size: parseInt(grid),
      distance: parseFloat(gridDistance),
      shiftX: parseInt(shiftX),
      shiftY: parseInt(shiftY),
      ratio: w / h
    };

    // Determine the padding offset as a multiple of the grid size
    dims.paddingX = (dims.width - w) * 0.5;
    dims.paddingY = (dims.height - h) * 0.5;
    return dims;
  }

  /* -------------------------------------------- */

  /**
   * Once the canvas is drawn, initialize control, visibility, and audio states
   */
  _initialize() {

    // Clear the set of targeted Tokens for the current user
    game.user.targets.clear();

    // Render the HUD layer
    this.hud.render(true);

    // Initialize canvas conditions
    this._initializeCanvasPosition();
    this._initializeCanvasLayer();

    // Initialize Token control
    this._initializeTokenControl();

    // Initialize starting layer conditions
    this.sight.initialize();
    this.lighting.initialize();
    this.sounds.initialize();

    // Broadcast user presence in the Scene
    game.user.broadcastActivity({sceneId: this.scene.id});
  }

  /* -------------------------------------------- */

  /**
   * Initialize the starting view of the canvas stage
   * If we are re-drawing a scene which was previously rendered, restore the prior view position
   * Otherwise set the view to the top-left corner of the scene at standard scale
   * @private
   */
  _initializeCanvasPosition() {

    // If we are re-drawing a Scene that was already visited, use it's cached view position
    const position = this.scene._viewPosition;
    if ( !isObjectEmpty(position) ) return this.pan(position);

    // Otherwise use a saved initial position
    const initial = this.scene.data.initial;
    if ( initial ) return this.pan({
      x: initial.x,
      y: initial.y,
      scale: initial.scale
    });

    // Otherwise determine a starting default based on the scene size
    this.pan({
      x: this.dimensions.paddingX + this.stage.position.x,
      y: this.dimensions.paddingY + this.stage.position.y,
      scale: 1
    });
  }

  /* -------------------------------------------- */

  /**
   * Initialize a CanvasLayer in the activation state
   * @private
   */
  _initializeCanvasLayer() {
    let activeLayer = this._reload.layer || ui.controls.control.layer || "TokenLayer";
    this.getLayer(activeLayer).activate();
  }

  /* -------------------------------------------- */

  /**
   * Initialize a token or set of tokens which should be controlled.
   * Restore controlled and targeted tokens from before the re-draw.
   * @private
   */
  _initializeTokenControl() {
    let isReload = this._reload.scene === this.scene._id;

    // Determine controlled token set (if any)
    const controlledTokens = isReload ? this._reload.controlledTokens : [];
    if ( !isReload && !game.user.isGM ) {
      let token = game.user.character ? game.user.character.getActiveTokens().shift() : null;
      if ( !token ) {
        token = canvas.tokens.placeables.filter(t => t.actor && t.actor.hasPerm(game.user, "OBSERVER")).shift();
      }
      if ( token ) controlledTokens.push(token.id);
    }
    const targetedTokens = isReload ? this._reload.targetedTokens : [];

    // Iterate over tokens
    let panToken = null;
    for ( let t of canvas.tokens.placeables ) {
      if ( controlledTokens.includes(t.id) ) {
        if ( !panToken ) panToken = t;
        t.control({updateSight: false, releaseOthers: false});
      }
      if ( targetedTokens.includes(t.id) ) t.setTarget(true, {releaseOthers: false, groupSelection: true});
    }

    // Pan camera to controlled token
    if ( panToken && !isReload ) this.pan({x: panToken.x, y: panToken.y, duration: 250});
  }

  /* -------------------------------------------- */

  /**
   * Get a reference to the a specific CanvasLayer by it's name
   * @param {string} layerName    The name of the canvas layer to get
   * @return {CanvasLayer}
   */
  getLayer(layerName) {
    return this.stage.getChildByName(layerName);
  }

  /* -------------------------------------------- */

  /**
   * Given an embedded object name, get the canvas layer for that object
   * @param {string} embeddedName
   * @returns {PlaceablesLayer|null}
   * @private
   */
  getLayerByEmbeddedName(embeddedName) {
    return {
      AmbientLight: this.lighting,
      AmbientSound: this.sounds,
      Drawing: this.drawings,
      Note: this.notes,
      MeasuredTemplate: this.templates,
      Tile: this.tiles,
      Token: this.tokens,
      Wall: this.walls
    }[embeddedName] || null;
  }

  /* -------------------------------------------- */
  /*  Methods
  /* -------------------------------------------- */

  /**
   * Pan the canvas to a certain {x,y} coordinate and a certain zoom level
   * @param {number|null} x      The x-coordinate of the pan destination
   * @param {number|null} y      The y-coordinate of the pan destination
   * @param {number|null} scale  The zoom level (max of CONFIG.Canvas.maxZoom) of the action
   */
  pan({x=null, y=null, scale=null}={}) {

    // Pan the canvas to the new destination
    x = Number(x) || this.stage.pivot.x;
    y = Number(y) || this.stage.pivot.y;
    this.stage.pivot.set(x, y);

    // Zoom the canvas to the new level
    if ( Number.isNumeric(scale) && scale !== this.stage.scale.x ) {
      scale = this._constrainScale(scale);
      this.stage.scale.set(scale, scale);
    } else scale = this.stage.scale.x;

    // Update the scene tracked position
    canvas.scene._viewPosition = { x:x , y:y, scale:scale };

    // Call canvasPan Hook
    Hooks.callAll("canvasPan", this, {x, y, scale});

    // Align the HUD
    this.hud.align();

    // Adjust the level of blur as we zoom out
    if ( scale ) {
      canvas.sight.blurDistance = 20 / (CONFIG.Canvas.maxZoom - Math.round(scale) + 1)
    }
  }

  /* -------------------------------------------- */

  /**
   * Animate panning the canvas to a certain destination coordinate and zoom scale
   * Customize the animation speed with additional options
   * Returns a Promise which is resolved once the animation has completed
   *
   * @param {Number} x            The destination x-coordinate
   * @param {Number} y            The destination y-coordinate
   * @param {Number} scale        The destination zoom scale
   * @param {Number} duration     The total duration of the animation in milliseconds; used if speed is not set
   * @param {Number} speed        The speed of animation in pixels per second; overrides duration if set
   * @returns {Promise}           A Promise which resolves once the animation has been completed
   */
  async animatePan({x, y, scale, duration=250, speed}) {

    // Determine the animation duration to reach the target
    if ( speed ) {
      let ray = new Ray(this.stage.pivot, {x, y});
      duration = Math.round(ray.distance * 1000 / speed);
    }

    // Construct the animation attributes
    const attributes = [
      { parent: this.stage.pivot, attribute: 'x', to: x },
      { parent: this.stage.pivot, attribute: 'y', to: y },
      { parent: this.stage.scale, attribute: 'x', to: scale },
      { parent: this.stage.scale, attribute: 'y', to: scale },
    ].filter(a => a.to !== undefined);

    // Trigger the animation function
    await CanvasAnimation.animateLinear(attributes, {
      name: "canvas.animatePan",
      duration: duration,
      ontick: (dt, attributes) => {
        this.hud.align();
        const stage = this.stage;
        Hooks.callAll("canvasPan", this, {x: stage.pivot.x, y: stage.pivot.y, scale: stage.scale.x});
      }
    });

    // Update the scene tracked position
    canvas.scene._viewPosition = { x:x , y:y, scale:scale };
  }

  /* -------------------------------------------- */

  /**
   * Get the constrained zoom scale parameter which is allowed by the maxZoom parameter
   * @param {Number} scale    The requested scale
   * @return {Number}         The allowed scale
   * @private
   */
  _constrainScale(scale) {
    const d = canvas.dimensions;
    const max = CONFIG.Canvas.maxZoom;
    const ratio = Math.max(d.width / window.innerWidth, d.height / window.innerHeight, max);
    return Math.round(Math.clamped(scale, 1/ratio, max) * 100) / 100;
  }

  /* -------------------------------------------- */

  /**
   * Recenter the canvas
   * Otherwise, pan the stage to put the top-left corner of the map in the top-left corner of the window
   */
  recenter(coordinates) {
    if ( coordinates ) this.pan(coordinates);
    this.animatePan({
      x: this.dimensions.paddingX + (window.innerWidth / 2),
      y: this.dimensions.paddingY + (window.innerHeight / 2),
      duration: 250
    });
  }

  /* -------------------------------------------- */
  /* Event Handlers
  /* -------------------------------------------- */

  /**
   * Attach event listeners to the game canvas to handle click and interaction events
   * @private
   */
  _addListeners() {
    this.stage.removeAllListeners();
    this.stage.on("mousedown", this._onMouseDown)
              .on("rightdown", this._onRightDown)
              .on('mousemove', this._onMouseMove.bind(this))
              .on("mouseup", this._onMouseUp)
              .on("mouseupoutside", this._onMouseUp)
              .on("rightup", this._onRightUp)
              .on("rightupoutside", this._onRightUp);
  }

  /* -------------------------------------------- */

  /**
   * Handle left mouse-click events occuring on the Canvas stage or it's active layer
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onMouseDown(event) {

    // Extract event data
    const oe = event.data.originalEvent,
          layer = canvas.activeLayer,
          isRuler = game.activeTool === "ruler",
          isCtrlRuler = (oe.ctrlKey || oe.metaKey) && (layer.name === "TokenLayer"),
          isSelect = ["select", "target"].includes(game.activeTool);

    // First delegate the click to the active layer unless a special tool is in use
    if (layer._onMouseDown) layer._onMouseDown(event, {isRuler, isCtrlRuler, isSelect});
    if (event.stopped) return;
    event.stopPropagation();

    // Record initial event position
    event.data.origin = event.data.getLocalPosition(this);
    event.data.tool = game.activeTool;

    // Handle ruler measurement
    if ( isRuler || isCtrlRuler ) canvas.controls.ruler._onMouseDown(event);

    // Begin a new selection
    else if ( isSelect ) {
      event.data._selectState = 1;
      event.data.coords = [];
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle right mouse-click events occuring on the Canvas stage or it's active layer
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onRightDown(event) {

    // First delegate the click to the active layer
    const layer = canvas.activeLayer;
    if ( layer._onRightDown ) layer._onRightDown(event);
    if ( event.stopped ) return;
    event.stopPropagation();

    // Handle ruler measurement
    let ruler = canvas.controls.ruler;
    if ( ruler.active ) ruler._onCancelWaypoint(event);

    // Begin a new canvas pan workflow
    else {
      event.data._panStart = {x: canvas.stage.pivot.x, y: canvas.stage.pivot.y};
      event.data.origin = {x: event.data.global.x, y: event.data.global.y};
      event.data._dragState = 1;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse movement events occuring on the Canvas stage or it's active layer
   * Throttle mousemove time at 20ms (~50 frames per second)
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onMouseMove(event) {
    const now = Date.now();
    const throttle = 20;

    // If it has been less than 20ms wait a bit before handling
    if ( (now - this._mouseMoveTime) < throttle ) {
      event.stopPropagation();
      return false;
    }
    this._mouseMoveTime = now;

    // First delegate the click to the active layer
    const layer = this.activeLayer;
    if ( layer._onMouseMove ) layer._onMouseMove(event);
    if ( event.stopped ) return;

    // Extract event data
    const { origin, cursorTime, _selectState, _dragState } = event.data;
    let p0 = origin,
        p1 = event.data.getLocalPosition(this.stage);
    event.data.destination = p1;

    // Update the client's cursor position every 100ms
    let ct = cursorTime || 0;
    if ( (now - ct) > 100 ) {
      if ( this.controls ) canvas.controls._onMoveCursor(event, p1);
      event.data.cursorTime = now;
    }

    // Continue a measurement event if we have moved at least half a grid unit
    const ruler = this.controls.ruler;
    if ( ruler._state > 0 ) ruler._onMouseMove(event);

    // Continue a select event
    else if ( _selectState > 0 ) this._onMoveSelect(event, _selectState, p0, p1);

    // Continue a drag event
    else if ( _dragState > 0 ) {
      const DRAG_SPEED_MODIFIER = 0.8;
      let dx = ( event.data.global.x - p0.x ) / (this.stage.scale.x * DRAG_SPEED_MODIFIER),
          dy = ( event.data.global.y - p0.y ) / (this.stage.scale.y * DRAG_SPEED_MODIFIER);
      this.pan({
        x: event.data._panStart.x - dx,
        y: event.data._panStart.y - dy
      });
      this.tokens._tabCycle = false;
    }
  }

  /* -------------------------------------------- */

  /**
   * Determine selection coordinate rectangle during a mouse-drag workflow
   * @param {PIXI.interaction.InteractionEvent} event
   * @param {Number} state    The selection workflow state
   * @param {Object} p0       The origin coordinate
   * @param {Object} p1       The destination coordinate
   * @private
   */
  _onMoveSelect(event, state, p0, p1) {

    // Determine rectangle coordinates
    let coords = {
      x: Math.min(p0.x, p1.x),
      y: Math.min(p0.y, p1.y),
      width: Math.abs(p1.x - p0.x),
      height: Math.abs(p1.y - p0.y)
    };

    // Only select if we have moved further than the minimum drag distance in some direction
    let distance = Math.hypot(coords.width, coords.height);
    if ( (state === 2) || (distance >= canvas.dimensions.size / 2) ) {
      canvas.controls.drawSelect(coords);
      event.data._selectState = 2;
      event.data.coords = coords;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle left-mouse up events occuring on the Canvas stage or it's active layer
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onMouseUp(event) {

    // First delegate the click to the active layer
    const layer = canvas.activeLayer;
    if ( layer._onMouseUp ) layer._onMouseUp(event);
    if ( event.stopped ) return;
    event.stopPropagation();

    // Extract event data
    const {coords, originalEvent, tool, _selectState} = event.data;
    const isCtrl = originalEvent.ctrlKey || originalEvent.metaKey;
    const ruler = canvas.controls.ruler;

    // Conclude a measurement event if we aren't holding the CTRL key
    if ( ruler.active ) {
      if ( isCtrl ) return;
      ruler._onEndMeasurement(event);
    }

    // Conclude a select event
    else if ( _selectState === 2 ) {
      if ( tool === "select" ) layer.selectObjects(coords);
      else if ( tool === "target" ) layer.targetObjects(coords, {releaseOthers: !originalEvent.shiftKey});
      canvas.controls.select.clear();
    }
    event.data._selectState = 0;
  }

  /* -------------------------------------------- */

  /**
   * Handle right-mouse up events occuring on the Canvas stage or it's active layer
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onRightUp(event) {

    // First delegate the click to the active layer
    const layer = canvas.activeLayer;
    if ( layer._onRightUp ) layer._onRightUp(event);

    // Conclude drag event on the stage
    if ( event.stopped ) return;
    event.stopPropagation();
    event.data._dragState = 0;
  }

  /* -------------------------------------------- */

  /**
   * Handle window resizing with the dimensions of the window viewport change
   * @param {Event} event   The Window resize event
   * @private
   */
  async _onResize(event) {
    if ( !this.ready ) return false;

    // Record the original width
    const w = window.innerWidth;
    const h = window.innerHeight;

    // Resize the renderer
    this.app.renderer.view.style.width = w + "px";
    this.app.renderer.view.style.height = h + "px";
    this.app.renderer.resize(w, h);

    // Adjust the stage position and pivot
    this.stage.position.set(w/2, h/2);
    const dx = (window.innerWidth - w) / 2;
    const dy = (window.innerHeight - h) / 2;
    this.pan({x: this.stage.pivot.x + dx, y: this.stage.pivot.y + dy});
  }

  /* -------------------------------------------- */
  /* Zooming (Mouse Wheel)
  /* -------------------------------------------- */

  _onMouseWheel(event) {
    let dz = ( event.deltaY < 0 ) ? 1.05 : 0.95;
    this.pan({scale: dz * canvas.stage.scale.x});
  }

  /* -------------------------------------------- */
  /*  Drag/Drop Events
  /* -------------------------------------------- */

  /**
   * The ondragover listener is required to make the canvas a valid drop-zone
   * @private
   */
  _onDragOver(event) {
    event.preventDefault();
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Event handler for the drop portion of a drag-and-drop event.
   * @private
   */
  _onDrop(event) {
    event.preventDefault();

    // Try to extract the data
    let data;
    try {
      data = JSON.parse(event.dataTransfer.getData('text/plain'));
    }
    catch (err) {
      return false;
    }

    // Dropped Actor
    if ( data.type === "Actor" ) canvas.tokens._onDropActorData(event, data);
    
    // Dropped Journal Entry
    else if ( data.type === "JournalEntry" ) canvas.notes._onDropData(event, data);

    // Dropped Macro (clear slot)
    else if ( data.type === "Macro" ) {
      game.user.assignHotbarMacro(null, data.slot);
    }
  }

  /* -------------------------------------------- */
  /*  Pending Operations                          */
  /* -------------------------------------------- */

  /**
   * Add a pending canvas operation that should fire once the socket handling workflow concludes.
   * This registers operations by a unique string name into a queue - avoiding repeating the same work multiple times.
   * This is especially helpful for multi-object updates to avoid costly and redundant refresh operations.
   * @param {string} name     A unique name for the pending operation, conventionally Class.method
   * @param {Function} fn     The unbound function to execute later
   * @param {*} scope         The scope to which the method should be bound when called
   * @param {...*} args       Arbitrary arguments to pass to the method when called
   */
  addPendingOperation(name, fn, scope, args) {
    if ( this._pendingOperationNames.has(name) ) return;
    this._pendingOperationNames.add(name);
    this.pendingOperations.push([fn, scope, args]);
  }

  /* -------------------------------------------- */

  /**
   * Fire all pending functions that are registered in the pending operations queue and empty it.
   */
  triggerPendingOperations() {
    for ( let [fn, scope, args] of this.pendingOperations ) {
      args = args || [];
      fn = fn.call(scope, ...args);
    }
    this.pendingOperations = [];
    this._pendingOperationNames.clear();
  }
}

/**
 * An abstract pattern for primary layers of the game canvas to implement
 * @type {PIXI.Container}
 */
class CanvasLayer extends PIXI.Container {
  constructor() {
    super();

    /**
     * Track whether the canvas layer is currently active for interaction
     * @type {Boolean}
     */
    this._active = false;
  }

  /* -------------------------------------------- */
  /*  Properties and Attributes
  /* -------------------------------------------- */

  get name() {
    return this.constructor.name;
  }

  /* -------------------------------------------- */
  /*  Rendering
  /* -------------------------------------------- */

  /**
   * Deconstruct data used in the current layer in preparation to re-draw the canvas
   */
  tearDown() {
    this.renderable = false;
    this.removeChildren().forEach(c => {
      c.destroy({children: true, texture: true, baseTexture: false});
    });
    this.renderable = true;
  }

  /* -------------------------------------------- */

  /**
   * Draw the canvas layer, rendering its internal components and returning a Promise
   * The Promise resolves to the drawn layer once its contents are successfully rendered.
   * @return {Promise.<CanvasLayer>}
   */
  async draw() {
    const d = canvas.dimensions;
    this.width = d.width;
    this.height = d.height;
    this.hitArea = new PIXI.Rectangle(0, 0, d.width, d.height);
    return this;
  }

  /* -------------------------------------------- */
  /*  Methods
  /* -------------------------------------------- */

  activate() {
    canvas.stage.children.forEach(layer => layer.deactivate());
    this.interactive = false;
    this.interactiveChildren = true;
    this._active = true;
    if ( ui.controls ) ui.controls.initialize({layer: this.constructor.name});
  }

  /* -------------------------------------------- */

  deactivate() {
    this.interactive = false;
    this.interactiveChildren = false;
    this._active = false;
  }
}
/**
 * A PlaceableObject base container class
 * @type {PIXI.Container}
 */
class PlaceableObject extends PIXI.Container {
  constructor(data, scene) {
    super();

    /**
     * The underlying data object which provides the basis for this placeable object
     * @type {Object}
     */
    this.data = data;

    /**
     * Retain a reference to the Scene within which this Placeable Object resides
     * @type {Scene}
     */
    this.scene = scene;

    /**
     * Track the field of vision for the placeable object.
     * This is necessary to determine whether a player has line-of-sight towards a placeable object or vice-versa
     * @type {PIXI.Polygon}
     */
    this.fov = null;

    /**
     * An indicator for whether the object is currently a hover target
     * @type {Boolean}
     * @private
     */
    this._hover = false;

    /**
     * An indicator for whether the object is currently controlled
     * @type {Boolean}
     * @private
     */
    this._controlled = false;

    /**
     * A control icon for interacting with the object
     * @type {ControlIcon}
     */
    this.controlIcon = null;
  }

  /* -------------------------------------------- */
  /* Properties
  /* -------------------------------------------- */

  /**
   * The EmbeddedDocument id of the underlying data object
   * @type {string}
   */
  get id() {
    return this.data._id;
  }

  /* -------------------------------------------- */

  /**
   * A Universally Unique Identifier (uuid) for this Embedded Entity instance
   * @type {string}
   */
  get uuid() {
    return `${this.scene.uuid}.${this.constructor.name}.${this.id}`;
  }

  /* -------------------------------------------- */

  /**
   * Provide a reference to the canvas layer which contains placeable objects of this type
   * @type {PlaceablesLayer}
   */
  static get layer() {
    throw new Error("A PlaceableObject subclass must provide a reference to the canvas layer which contains it.");
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the singleton layer instance which contains placeables of this type
   * @type {PlaceablesLayer}
   */
  get layer() {
    return canvas.stage.children.find(l => l.constructor.name === this.constructor.layer.name);
  }

  /* -------------------------------------------- */

  /**
   * The [x,y] coordinates of the placeable object within the Scene container
   * @type {Array}
   */
  get coords() {
    return [this.data.x, this.data.y];
  }

  /* -------------------------------------------- */

  /**
   * The central coordinate pair of the placeable object based on it's own width and height
   * @type {Object}
   */
  get center() {
    return {
      x: this.data.x,
      y: this.data.y
    }
  }

  /**
   * A Boolean flag for whether the current game User has permission to control this token
   * @type {Boolean}
   */
  get owner() {
    return game.user.isGM;
  }

  /**
   * A placeable object should define the logic to create
   * @type {Application}
   */
  get sheet() {
    throw new Error("A PlaceableObject subclass may optionally define a configuration sheet application.");
  }

  /* -------------------------------------------- */
  /* Methods
  /* -------------------------------------------- */

  /**
   * Clear the display of the existing object
   */
  clear() {
    this.removeChildren().forEach(c => c.destroy({children: true}));
  }

  /* -------------------------------------------- */

  /**
   * Assume control over a PlaceableObject, flagging it as controlled and enabling downstream behaviors
   * @param {Object} options                  Additional options which modify the control request
   * @param {boolean} options.releaseOthers   Release any other controlled objects first
   * @return {boolean}                        A flag denoting whether or not control was successful
   */
  control(options={}) {
    if (this._controlled) return true;
    if ( options.releaseOthers !== false ) this.layer.releaseAll();

    // Prevent control if the user is not an object owner
    if (!this.owner) return false;

    // Toggle control status
    this._controlled = true;
    this.layer._controlled[this.id] = this;

    // Trigger follow-up events
    this._onControl(options);

    // Fire an on-control Hook
    Hooks.callAll("control"+this.constructor.name, this, this._controlled);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Additional events which trigger once control of the object is established
   * @param {Object} options    Optional parameters which apply for specific implementations
   * @private
   */
  _onControl(options) {
    this.refresh();
  }

  /* -------------------------------------------- */

  /**
   * Release control over a PlaceableObject, removing it from the controlled set
   * @param {Object} options          Options which modify the releasing workflow
   * @return {Boolean}                A Boolean flag confirming the object was released.
   */
  release(options={}) {
    delete this.layer._controlled[this.id];
    if (!this._controlled) return true;
    this._controlled = false;

    // Trigger follow-up events
    this._onRelease(options);

    // Fire an on-release Hook
    Hooks.callAll("control"+this.constructor.name, this, this._controlled);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Additional events which trigger once control of the object is released
   * @param {Object} options          Options which modify the releasing workflow
   * @private
   */
  _onRelease(options) {
    if ( this.layer.hud && (this.layer.hud.object === this) ) this.layer.hud.clear();
    this.refresh();
  }

  /* -------------------------------------------- */

  /**
   * Draw the placeable object into its parent container
   * @return {PlaceableObject}    A reference to the drawn object
   */
  draw() {
    throw new Error("A PlaceableObject subclass must define initial drawing procedure.");
  }

  /* -------------------------------------------- */

  /**
   * Refresh the visual representation of the Placeable Object
   * @return {PlaceableObject}    A reference to the refreshed object
   */
  refresh() {
    throw new Error("A PlaceableObject subclass must define an refresh drawing procedure.");
  }

  /* -------------------------------------------- */

  /**
   * Sort the PlaceableObject to the front of the rendering stack, above all other siblings.
   * Update the database with the new maximal Z-index
   */
  sortToFront() {
    const siblings = this.parent.children.filter(obj => Number.isFinite(obj.data.z) && obj.id !== this.id);
    let z = siblings.length ? Math.max(...siblings.map(o => o.data.z)) + 1 : 1;
    this.update({z: z});
  }

  /* -------------------------------------------- */

  /**
   * Sort the PlaceableObject to the back of the rendering stack, behind all other siblings.
   * Update the database with the new minimal Z-index
   */
  sortToBack() {
    const siblings = this.parent.children.filter(obj => Number.isFinite(obj.data.z) && obj.id !== this.id);
    let z = siblings.length ? Math.min(...siblings.map(o => o.data.z)) - 1 : -1;
    this.update({z: z});
  }

  /* -------------------------------------------- */

  /**
   * Shift the display of the PlaceableObject to the top of the rendering stack, above all other siblings
   */
  displayToFront() {
    this.parent.addChild(this.parent.removeChild(this));
  }

  /* -------------------------------------------- */

  /**
   * Clone the placeable object, returning a new object with identical attributes
   * The returned object is non-interactive, and has no assigned ID
   * If you plan to use it permanently you should call the create method
   *
   * @return {PlaceableObject}  A new object with identical data
   */
  clone() {
    let data = duplicate(this.data);
    data._id = null;
    let clone = new this.constructor(data);
    clone.interactive = false;
    return clone;
  }

  /* -------------------------------------------- */

  /**
   * Rotate the PlaceableObject to a certain angle of facing
   * @param {number} angle    The desired angle of rotation
   * @param {number} snap     Snap the angle of rotation to a certain target degree increment
   * @return {Promise}        A Promise which resolves once the rotation has completed
   */
  async rotate(angle, snap) {
    if ( this.data.rotation === undefined ) return;

    // Determine the new rotation angle
    angle = angle % 360;
    if ( Number.isFinite(snap) ) angle = Math.round(angle / snap) * snap;

    // Conceal any active hud
    const hud = this.layer.hud;
    if ( hud ) hud.clear();

    // Update the object
    return this.update({rotation: angle});
  }

  /* -------------------------------------------- */

  /**
   * Get the value of a "flag" for this PlaceableObject
   * See the setFlag method for more details on flags
   *
   * @param {String} scope    The flag scope which namespaces the key
   * @param {String} key      The flag key
   * @return {*}              The flag value
   */
  getFlag(scope, key) {
    const scopes = SetupConfiguration.getPackageScopes();
    if ( !scopes.includes(scope) ) throw new Error(`Invalid scope for flag ${key}`);
    key = `${scope}.${key}`;
    return getProperty(this.data.flags, key);
  }

  /* -------------------------------------------- */

  /**
   * Assign a "flag" to this Entity.
   * Flags represent key-value type data which can be used to store flexible or arbitrary data required by either
   * the core software, game systems, or user-created modules.
   *
   * Each flag should be set using a scope which provides a namespace for the flag to help prevent collisions.
   *
   * Flags set by the core software use the "core" scope.
   * Flags set by game systems or modules should use the canonical name attribute for the module
   * Flags set by an individual world should "world" as the scope.
   *
   * Flag values can assume almost any data type. Setting a flag value to null will delete that flag.
   *
   * @param {String} scope    The flag scope which namespaces the key
   * @param {String} key      The flag key
   * @param {*} value         The flag value
   *
   * @return {Promise}        A Promise resolving to the updated PlaceableObject
   */
  async setFlag(scope, key, value) {
    const scopes = SetupConfiguration.getPackageScopes();
    if ( !scopes.includes(scope) ) throw new Error(`Invalid scope for flag ${key}`);
    key = `flags.${scope}.${key}`;
    return this.update({[key]: value});
  }

  /* -------------------------------------------- */

  /**
   * Remove a flag assigned to the Entity
   * @param {string} scope    The flag scope which namespaces the key
   * @param {string} key      The flag key
   * @return {Promise}        A Promise resolving to the updated Entity
   */
  async unsetFlag(scope, key) {
    const scopes = SetupConfiguration.getPackageScopes();
    if ( !scopes.includes(scope) ) throw new Error(`Invalid scope for flag ${key}`);
    key = `flags.${scope}.-=${key}`;
    return this.update({[key]: null});
  }

  /* -------------------------------------------- */

  /**
   * Obtain the shifted position for the Object
   * @param {Number} dx         The number of grid units to shift along the X-axis
   * @param {Number} dy         The number of grid units to shift along the Y-axis
   * @return {{x, y}}           The target movement coordinates subject to some offset
   * @private
   */
  _getShiftedPosition(dx, dy) {
    let [x, y] = canvas.grid.grid.shiftPosition(this.data.x, this.data.y, dx, dy);
    return {x, y}
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /** @extends {Entity.createEmbeddedEntity} */
  static async create(...args) {
    return canvas.scene.createEmbeddedEntity(this.name, ...args);
  }

  /* -------------------------------------------- */

  /** @extends {Entity.updateEmbeddedEntity} */
  async update(...args) {
    const data = args[0] || {};
    data._id = this.id;
    const options = args[1] || {};
    options.currentData = this.data;
    return this.scene.updateEmbeddedEntity(this.constructor.name, data, options);
  }

  /* -------------------------------------------- */

  /** @extends {Entity.deleteEmbeddedEntity} */
  async delete(...args) {
    const options = args.pop() || {};
    return this.scene.deleteEmbeddedEntity(this.constructor.name, this.id, options);
  }

  /* -------------------------------------------- */

  /**
   * Register pending canvas operations which should occur after a new PlaceableObject of this type is created
   * @private
   */
  _onCreate() {
    const layer = this.layer;
    canvas.addPendingOperation(`${layer.constructor.name}.sortObjects`, layer.sortObjects, layer);
  }

  /* -------------------------------------------- */

  /**
   * Define additional steps taken when an existing placeable object of this type is updated with new data
   * @private
   */
  _onUpdate(data) {
    if ( Object.keys(data).includes("z") ) {
      const layer = this.layer;
      canvas.addPendingOperation(`${layer.constructor.name}.sortObjects`, layer.sortObjects, layer);
    }
  }

  /* -------------------------------------------- */

  /**
   * Define additional steps taken when an existing placeable object of this type is deleted
   * @private
   */
  _onDelete() {
    this.release();
    const layer = this.layer;
    if ( layer._hover === this ) layer._hover = null;
    canvas.addPendingOperation(`${layer.constructor.name}.sortObjects`, layer.sortObjects, layer);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle mouse-over events which trigger a hover
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onMouseOver(event) {
    if ( this._hover === true ) return false;
    const layer = this.layer;

    // Register hover state, and strip that state from sibling objects
    const oe = event instanceof PIXI.interaction.InteractionEvent ? event.data.originalEvent : event;
    if ( !oe.altKey ) layer.placeables.filter(o => (o !== this) && o._hover).forEach(o => o._onMouseOut(event));
    this._hover = true;
    layer._hover = this;

    // Refresh object display
    if ( this.controlIcon ) this.controlIcon.border.visible = true;
    if ( this.refresh ) this.refresh();

    // Fire an on-hover Hook
    Hooks.callAll("hover"+this.constructor.name, this, this._hover);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-out events after a hover
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onMouseOut(event) {
    if ( this._hover !== true ) return false;

    // Deregister the hover state from this object
    this._hover = false;
    this.layer._hover = null;

    // Refresh object display
    if ( this.controlIcon ) this.controlIcon.border.visible = false;
    if ( this.refresh ) this.refresh();

    // Fire an off-hover Hook
    Hooks.callAll("hover"+this.constructor.name, this, this._hover);
  }

  /* -------------------------------------------- */

  /**
   * Default handling for mouse-move event during a PlaceableObject drag workflow
   * @param {PIXI.InteractionEvent} event   The mousemove event being handled
   * @private
   */
  async _onMouseMove(event) {
    const layer = this.layer;
    const dims = canvas.dimensions;

    // Extract event data
    let {handleState, origin} = event.data;
    const dest = event.data.getLocalPosition(layer);
    const dx = dest.x - origin.x;
    const dy = dest.y - origin.y;

    // Determine whether this represents a drag start
    const isStart = (handleState === 0) && (Math.hypot(dx, dy) >= dims.size / (layer.gridPrecision * 2));
    if (isStart) await this._onDragStart(event);

    // Update the clone position
    const clones = event.data.clones || [];
    if ( (event.data.handleState > 0) && clones.length ) {
      for ( let o of clones ) {
        o.data.x = o._original.data.x + dx;
        o.data.y = o._original.data.y + dy;
        o.refresh();
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Default handling for Placeable mouse-up event concluding a drag workflow
   * @private
   */
  async _onMouseUp(event) {
    const {clones, handleState, originalEvent} = event.data;
    if ( (handleState === 0) || !clones.length ) return;
    const layer = this.layer;

    // Update the final coordinate for each drag clone
    const updates = [];
    for ( let o of clones ) {
      try {
        const d = layer.getDragDestination(o, {x: o.data.x, y: o.data.y}, !originalEvent.shiftKey);
        d._id = o._original.id;
        updates.push(d);
      } catch(err) {
        ui.notifications.error(err.message);
      }
    }

    // Update multiple dragged tokens
    await layer.updateMany(updates);
    return this._onDragCancel(event);
  }

  /* -------------------------------------------- */

  /**
   * Default handling for Placeable double left-click event
   * @private
   */
  _onDoubleLeft(event) {
    const sheet = this.sheet;
    if ( sheet ) sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Default handling for Placeable drag cancel through right-click
   * @private
   */
  _onDragCancel(event) {
    this.layer._onDragCancel(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle the beginning of a drag workflow by creating clones of all controlled objects
   * @param {MouseEvent} event
   * @private
   */
  async _onDragStart(event) {
    const layer = this.layer;
    event.data.handleState = 1;
    event.data.clones = [];

    // Determine the Array of target objects to move
    const targetObjects = layer.options.controllableObjects ? layer.controlled : [this];
    if ( !targetObjects.length ) return;

    // Create clones of each target object
    for ( let o of targetObjects ) {
      o.data.locked = true;
      const clone = await o.clone().draw();
      clone._original = o;
      clone.alpha = 0.8;
      o.alpha = 0.4;
      layer.preview.addChild(clone);
      event.data.clones.push(clone);
    }
  }

  /* -------------------------------------------- */

  /**
   * Default event-handling logic for a left-mouse click event on the PlaceableObject container
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onMouseDown(event) {

    // Remove active HUD controls
    const hud = this.layer.hud;
    if ( hud ) hud.clear();

    // Attempt to control the object
    this.control();
  }

  /* -------------------------------------------- */

  /**
   * Default event-handling logic for a right-mouse click event on the PlaceableObject container
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onRightDown(event) {

    // Display the HUD controls
    const hud = this.layer.hud;
    if ( hud ) {
      const state = hud._displayState;
      if ( hud.object === this && state !== hud.constructor.DISPLAY_STATES.NONE ) hud.clear();
      else if ( this.owner ) hud.bind(this);
    }
  }
}

/**
 * The base PlaceablesLayer subclass of CanvasLayer
 * @type {CanvasLayer}
 */
class PlaceablesLayer extends CanvasLayer {
  constructor() {
    super();

    /**
     * Placeable Layer Objects
     * @type {PIXI.Container}
     */
    this.objects = null;

    /**
     * Preview Object Placement
     */
    this.preview = null;

    /**
     * Keep track of history so that CTRL+Z can undo changes
     * @type {Array}
     */
    this.history = [];

    /**
     * Track the PlaceableObject on this layer which is currently being hovered upon
     * @type {PlaceableObject}
     */
    this._hover = null;

    /**
     * Track the set of PlaceableObjects on this layer which are currently controlled by their id
     * @type {Object}
     */
    this._controlled = {};

    /**
     * Keep track of an object copied with CTRL+C which can be pasted later
     * @type {Array}
     */
    this._copy = [];

    /**
     * PlaceableObject layer options
     * @type {Object}
     */
    this.options = this.constructor.layerOptions;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Customize behaviors of this PlaceablesLayer by modifying some behaviors at a class level
   * @static
   * @type {Object}
   *
   * @property {boolean} canDragCreate        Does this layer support a mouse-drag workflow to create new objects?
   * @property {boolean} controllableObjects  Can placeable objects in this layer be controlled?
   * @property {boolean} rotatableObjects     Can placeable objects in this layer be rotated?
   * @property {boolean} snapToGrid           Do objects in this layer snap to the grid
   * @property {number} gridPrecision         At what numeric grid precision do objects snap?
   */
  static get layerOptions() {
    return {
      canDragCreate: game.user.isGM,
      canDelete: game.user.isGM,
      controllableObjects: false,
      rotatableObjects: false,
      snapToGrid: true,
      gridPrecision: 2
    }
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the active instance of this canvas layer
   * @static
   * @type {PlaceablesLayer}
   */
  static get instance() {
    return canvas.stage.children.find(l => l.constructor.name === this.name);
  }

  /* -------------------------------------------- */

  /**
   * Define the named Array within Scene.data containing the placeable objects displayed in this layer
   * @static
   * @type {String}
   */
  static get dataArray() {
    throw new Error("A PlaceablesLayer subclass must define the array of placeable object data it contains");
  }

  /* -------------------------------------------- */

  /**
   * Define a Container implementation used to render placeable objects contained in this layer
   * @static
   * @type {PIXI.Container}
   */
  static get placeableClass() {
    throw new Error("A PlaceablesLayer subclass must define the Container implementation used to display placeables.")
  }

  /* -------------------------------------------- */

  /**
   * Return the precision relative to the Scene grid with which Placeable objects should be snapped
   * @return {number}
   */
  get gridPrecision() {
    return this.constructor.layerOptions.gridPrecision;
  }

  /* -------------------------------------------- */

  /**
   * If objects on this PlaceableLayer have a HUD UI, provide a reference to its instance
   * @type {BasePlaceableHUD|null}
   */
  get hud() {
    return null;
  }

  /* -------------------------------------------- */

  /**
   * A convenience method for accessing the placeable object instances contained in this layer
   * @type {Array}
   */
  get placeables() {
    if ( !this.objects ) return [];
    return this.objects.children;
  }

  /* -------------------------------------------- */

  /**
   * An Array of placeable objects in this layer which have the _controlled attribute
   * @return {Array.<PlaceableObject>}
   */
  get controlled() {
    return Object.values(this._controlled);
  }

  /* -------------------------------------------- */
  /*  Rendering
  /* -------------------------------------------- */

  /** @override */
  async tearDown() {

    // Reset layer history
    this.history = [];

    // Release all controlled objects
    if ( this.options.controllableObjects ) {
      this._controlled = {};
    }

    // Destroy the layer children
    return super.tearDown();
  }

  /* -------------------------------------------- */

  /**
   * Draw the PlaceablesLayer.
   * Draw each Sound within the scene as a child of the sounds container.
   */
  async draw() {
    await super.draw();

    // Create objects container
    this.objects = this.addChild(new PIXI.Container());

    // Create preview container
    this.preview = this.addChild(new PIXI.Container());

    // Create and draw objects
    for ( let data of canvas.scene.data[this.constructor.dataArray] ) {
      await this.createObject(data);
    }

    // Sort objects
    this.sortObjects();
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Draw a single placeable object
   * @return {PlaceableObject}
   */
  createObject(data) {
    const obj = new this.constructor.placeableClass(data, canvas.scene);
    obj.draw();
    this.objects.addChild(obj);
    return obj;
  }

  /* -------------------------------------------- */

  /**
   * Reorder the child objects of the layer according to their z-index (if one exists)
   */
  sortObjects() {
    this.objects.children.sort((a, b) => {
      let s = a.data.z - b.data.z;
      return Number.isNaN(s) ? 1 : s;
    });
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Override the activation behavior of the PlaceablesLayer.
   * While active, ambient sound previews are displayed.
   */
  activate() {
    super.activate();
    if ( this.objects ) this.objects.visible = true;
  }

  /* -------------------------------------------- */

  /**
   * Override the deactivation behavior of the PlaceablesLayer.
   * When inactive, ambient sound previews are hidden from view.
   */
  deactivate() {
    super.deactivate();
    this.releaseAll();
    if ( this.objects ) this.objects.visible = false;
    if ( this.preview ) this.preview.removeChildren();
  }

  /* -------------------------------------------- */

  /**
   * Get a PlaceableObject contained in this layer by it's ID
   * @param {string} objectId   The ID of the contained object to retrieve
   * @return {PlaceableObject}  The object instance, or undefined
   */
  get(objectId) {
    return this.placeables.find(t => t.id === objectId);
  }

  /* -------------------------------------------- */

  /**
   * Release all controlled PlaceableObject instance from this layer.
   * @return {Number}           The number of PlaceableObject instances which were released
   */
  releaseAll() {
    const controlled = this.placeables.filter(t => t._controlled);
    controlled.forEach(obj => obj.release());
    return controlled.length;
  }

  /* -------------------------------------------- */

  /**
   * Simultaneously rotate multiple PlaceableObjects using a provided angle or incremental.
   * This executes a single database operation using Scene.update.
   * If rotating only a single object, it is better to use the PlaceableObject.rotate instance method.

   * @param {Number} angle      A target angle of rotation (in degrees) where zero faces "south"
   * @param {Number} delta      An incremental angle of rotation (in degrees)
   * @param {Number} snap       Snap the resulting angle to a multiple of some increment (in degrees)      
   * @param {Array|Set} ids     An Array or Set of object IDs to target for rotation

   * @return {Promise}          The resulting Promise from the Scene.update operation
   */
  async rotateMany({angle=null, delta=null, snap=null, ids=null}={}) {
    if ((!this.constructor.layerOptions.rotatableObjects ) || (game.paused && !game.user.isGM)) return;
    if ( (angle === null) && (delta === null) ) {
      throw new Error("Either a target angle or incremental delta must be provided.");
    }

    // Determine the set of rotatable object IDs unless some were explicitly provided
    ids = ids !== null ? Array.from(ids) : this.controlled.filter(o => !o.data.locked).map(o => o.id);
    if ( !ids.length ) return;

    // Conceal any active HUD
    const hud = this.hud;
    if ( hud ) hud.clear();

    // Construct updated object data Array
    const updateData = ids.map(id => {
      const obj = this.get(id);
      let degrees = angle ? angle : obj.data.rotation + delta;
      if ( snap ) degrees = Math.round(degrees / snap) * snap;
      return {_id: id, rotation: degrees % 360};
    });

    // Call the updateMany method
    return this.updateMany(updateData);
  }

  /* -------------------------------------------- */

  /**
   * Simultaneously move multiple PlaceableObjects via keyboard movement offsets.
   * This executes a single database operation using Scene.update.
   * If moving only a single object, this will delegate to PlaceableObject.update for performance reasons.
   * 
   * @param {Number} dx         The number of incremental grid units in the horizontal direction
   * @param {Number} dy         The number of incremental grid units in the vertical direction
   * @param {Boolean} rotate    Rotate the token to the keyboard direction instead of moving
   * @param {Array|Set} ids     An Array or Set of object IDs to target for rotation
   *
   * @return {Promise}          The resulting Promise from the Scene.update operation
   */
  async moveMany({dx=0, dy=0, rotate=false, ids=null}={}) {
    if ((game.paused && !game.user.isGM) || (!dx && !dy)) return;

    // Determine the set of movable object IDs unless some were explicitly provided
    ids = ids !== null ? Array.from(ids) : this.controlled.filter(o => !o.data.locked).map(o => o.id);
    if ( !ids.length ) return;

    // Determine the rotation angle
    let offsets = [dx, dy];
    let angle = 0;
    if ( rotate ) {
      if (offsets.equals([0, 1])) angle = 0;
      else if (offsets.equals([-1, 1])) angle = 45;
      else if (offsets.equals([-1, 0])) angle = 90;
      else if (offsets.equals([-1, -1])) angle = 135;
      else if (offsets.equals([0, -1])) angle = 180;
      else if (offsets.equals([1, -1])) angle = 225;
      else if (offsets.equals([1, 0])) angle = 270;
      else if (offsets.equals([1, 1])) angle = 315;
    }

    // Conceal any active HUD
    const hud = this.hud;
    if ( hud ) hud.clear();

    // Construct the update Array
    const updateData = ids.map(id => {
      let update = {_id: id};
      if ( rotate ) update.rotation = angle;
      else {
        let obj = this.get(id);
        mergeObject(update, obj._getShiftedPosition(...offsets));
      }
      return update;
    });

    // Call the updateMany method
    return this.updateMany(updateData, {updateKeys: rotate ? ["rotation"] : ["x", "y"]});
  }

  /* -------------------------------------------- */

  /**
   * Undo a change to the objects in this layer
   * This method is typically activated using CTRL+Z while the layer is active
   * @return {Promise}
   */
  async undoHistory() {
    if ( !this.history.length ) return Promise.reject("No more tracked history to undo!");
    let event = this.history.pop();
    const cls = this.constructor.placeableClass;

    // Undo creation
    if ( event.type === "create" ) {
      let obj = this.get(event.data._id);
      if ( obj ) return obj.delete({isUndo: true});
    }
    else if ( event.type === "createMany" ) {
      return this.deleteMany(event.data.map(d => d._id), {isUndo: true});
    }

    // Undo updates
    else if ( event.type === "update" ) {
      let obj = this.get(event.data._id);
      if ( obj ) return obj.update(event.data, {isUndo: true});
    }
    else if ( event.type === "updateMany" ) {
      return this.updateMany(event.data, {isUndo: true});
    }

    // Undo deletion
    else if ( event.type === "delete" ) {
      return cls.create(event.data, {isUndo: true});
    }
    else if ( event.type === "deleteMany" ) {
      return this.createMany(event.data, {isUndo: true});
    }
  }

  /* -------------------------------------------- */

  /**
   * Create multiple embedded entities in a parent Entity collection using an Array of provided data
   *
   * @param {Array} data          An Array of update data Objects which provide incremental data
   * @param {Object} options      Additional options which customize the update workflow
   *
   * @return {Promise}            A Promise which resolves to the returned socket response (if successful)
   */
  async createMany(data, options={}) {
    const embeddedName = this.constructor.placeableClass.name;
    return canvas.scene.createManyEmbeddedEntities(embeddedName, data, options);
  }

  /* -------------------------------------------- */

  /**
   * Update multiple embedded entities in a parent Entity collection using an Array of provided data
   *
   * @param {Array} data          An Array of update data Objects which provide incremental data
   * @param {Object} options      Additional options which customize the update workflow
   *
   * @return {Promise}            A Promise which resolves to the returned socket response (if successful)
   */
  async updateMany(data, options={}) {
    const embeddedName = this.constructor.placeableClass.name;
    return canvas.scene.updateManyEmbeddedEntities(embeddedName, data, options);
  }

  /* -------------------------------------------- */

  /**
   * Simultaneously delete multiple PlaceableObjects.
   * This executes a single database operation using Scene.update.
   * If deleting only a single object, this will delegate to PlaceableObject.delete for performance reasons.
   *
   * @param {Array} ids           An Array of object IDs to target for deletion
   * @param {Object} options      Additional options which customize the update workflow
   *
   * @return {Promise}            A Promise which resolves to the returned socket response (if successful)
   */
  async deleteMany(ids, options={}) {
    const embeddedName = this.constructor.placeableClass.name;
    return canvas.scene.deleteManyEmbeddedEntities(embeddedName, ids, options);
  }

  /* -------------------------------------------- */

  /**
   * A helper method to prompt for deletion of all PlaceableObject instances within the Scene
   * Renders a confirmation dialogue to confirm with the requester that all objects will be deleted
   */
  deleteAll() {
    const cls = this.constructor.placeableClass;
    if ( !game.user.isGM ) {
      throw new Error(`You do not have permission to delete ${cls.name} placeables from the Scene.`);
    }
    new Dialog({
      title: "Clear All Objects",
      content: `<p>Clear all ${cls.name} objects from this Scene?</p>`,
      buttons: {
        yes: {
          icon: '<i class="fas fa-trash"></i>',
          label: "Yes",
          callback: () => this.deleteMany(this.placeables.map(o => o.id), {})
        },
        no: {
          icon: '<i class="fas fa-times"></i>',
          label: "No"
        }
      },
      default: "yes"
    }).render(true);
  }

  /* -------------------------------------------- */

  /**
   * Record a new CRUD event in the history log so that it can be undone later
   * @param {String} type   The event type (create, update, delete)
   * @param {Object} data   The object data
   * @private
   */
  storeHistory(type, data) {
    if ( this.history.length >= 10 ) this.history.shift();
    this.history.push({type, data});
  }

  /* -------------------------------------------- */

  /**
   * Copy currently controlled PlaceableObjects to a temporary Array, ready to paste back into the scene later
   * @returns {Array}   The Array of copied Objects
   */
  copyObjects() {
    if ( this.options.controllableObjects ) this._copy = Array.from(this.controlled);
    else if ( this._hover) this._copy = [this._hover];
    else this._copy = [];
    const cn = this.constructor.placeableClass.name;
    ui.notifications.info(`Copied data for ${this._copy.length} ${cn} objects.`);
    return this._copy;
  }

  /* -------------------------------------------- */

  /**
   * Paste currently copied PlaceableObjects back to the layer by creating new copies
   * @return {Promise.<Array>}      An Array of created Objects
   */
  async pasteObjects(position, {hidden=false}={}) {
    if ( !this._copy.length ) return;
    const cls = this.constructor.placeableClass;

    // Adjust the pasted position for half a grid space
    position.x -= canvas.dimensions.size / 2;
    position.y -= canvas.dimensions.size / 2;

    // Get the left-most object in the set
    this._copy.sort((a, b) => a.data.x - b.data.x);
    let {x, y} = this._copy[0].data;

    // Iterate over objects
    const toCreate = [];
    for ( let c of this._copy ) {
      let data = duplicate(c.data);
      let snapped = canvas.grid.getSnappedPosition(position.x + (data.x - x), position.y + (data.y - y), 1);
      delete data._id;
      toCreate.push(mergeObject(data, {
        x: snapped.x,
        y: snapped.y,
        hidden: data.hidden || hidden
      }));
    }

    // Call paste hooks
    Hooks.call(`paste${cls.name}`, this._copy, toCreate);

    // Create all objects
    for ( let o of toCreate ) {
      await cls.create(canvas.scene._id, o);
    }
    ui.notifications.info(`Pasted data for ${toCreate.length} ${cls.name} objects.`);
    return toCreate;
  }

  /* -------------------------------------------- */

  /**
   * Select all PlaceableObject instances which fall within a coordinate rectangle.
   *
   * @param {Number} x      The top-left x-coordinate of the selection rectangle
   * @param {Number} y      The top-left y-coordinate of the selection rectangle
   * @param {Number} width  The width of the selection rectangle
   * @param {Number} height The height of the selection rectangle
   * @param {Object} releaseOptions   Optional arguments provided to any called release() method
   * @param {Object} controlOptions   Optional arguments provided to any called control() method
   * @return {boolean}       A boolean for whether the controlled set was changed in the operation
   */
  selectObjects({x, y, width, height, releaseOptions={}, controlOptions={}}) {
    const oldSet = Object.values(this._controlled);

    // Identify controllable objects
    const controllable = this.placeables.filter(obj => obj.visible && (obj.control instanceof Function));
    const newSet = controllable.filter(obj => {
      let c = obj.center;
      return Number.between(c.x, x, x+width) && Number.between(c.y, y, y+height);
    });

    // Release objects no longer controlled
    const toRelease = oldSet.filter(obj => !newSet.includes(obj));
    toRelease.forEach(obj => obj.release(releaseOptions));

    // Control new objects
    if ( isObjectEmpty(controlOptions) ) controlOptions.releaseOthers = false;
    const toControl = newSet.filter(obj => !oldSet.includes(obj));
    toControl.forEach(obj => obj.control(controlOptions));

    // Return a boolean for whether the control set was changed
    return (toRelease.length > 0) || (toControl.length > 0);
  }

  /* -------------------------------------------- */

  /**
   * Given an object and a proposed destination point, confirm the destination position or throw an Error
   * @param {PlaceableObject} object    The object being dragged
   * @param {Object} destination        The destination position
   * @param {boolean} snap              Snap to grid?
   * @return {{x, y}}                   The confirmed destination coordinates
   */
  getDragDestination(object, destination, snap=true) {
    const grid = canvas.grid;
    if ( snap ) destination = grid.getSnappedPosition(destination.x, destination.y, this.gridPrecision);
    return destination;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Default mouse-down event handling implementation
   * @private
   */
  _onMouseDown(event, {isRuler, isCtrlRuler, isSelect}={}) {

    // Clear active HUD
    if ( this.hud ) this.hud.clear();

    // Release controlled objects
    if ( [isRuler, isCtrlRuler, isSelect].includes(true) ) return;
    this.releaseAll();

    // Perhaps begin a drag-creation workflow
    if ( this.options.canDragCreate ) {
      event.stopPropagation();
      if ( event.data.createState > 0 ) return;
      event.data.createState = 0;
      this._onDragStart(event);
    }
  }

  /* -------------------------------------------- */

  /**
   * Default handling of drag start events by left click + dragging
   * @private
   */
  _onDragStart(event) {
    event.stopPropagation();

    // Identify the origin point
    let origin = event.data.getLocalPosition(this);
    if ( this.options.snapToGrid ) {
      origin = canvas.grid.getSnappedPosition(origin.x, origin.y, this.gridPrecision);
    }
    event.data.origin = origin;

    // Remove any existing preview
    if ( this.preview ) this.preview.removeChildren();

    // Listen for cancellation
    canvas.app.view.oncontextmenu = () => this._onDragCancel(event);

    // Register event state
    event.data.createState = 1;
  }

  /* -------------------------------------------- */

  /**
   * Default handling of mouse move events during a dragging workflow
   * @private
   */
  _onMouseMove(event) {
    if ( event.data.createState >= 1 ) {
      event.stopPropagation();
      event.data.destination = event.data.getLocalPosition(this);
    }
  }

  /* -------------------------------------------- */

  /**
   * Default handling of drag cancel events by right clicking during a drag creation
   * @private
   */
  _onDragCancel(event) {

    // Remove preview objects
    if ( this.preview ) {
      for ( let c of this.preview.children ) {
        if ( c._original ) {
          if ( "locked" in c._original.data ) c._original.data.locked = false;
          c._original.alpha = 1.0;
        }
      }
    }
    if ( this.preview ) this.preview.removeChildren();

    // Remove listeners and state flags
    this.off("mousemove", this._onMouseMove);
    canvas.app.view.oncontextmenu = null;
    event.data.createState = 0;
    event.data.object = null;
  }

  /* -------------------------------------------- */

  /**
   * Handle successful creation of an object through the drag creation workflow.
   * This logic requires that the drag exceeded some minimum distance for the new object to be created.
   * @private
   */
  _onDragCreate(event) {
    let distance = Math.hypot(event.data.destination.x - event.data.origin.x,
                              event.data.destination.y - event.data.origin.y);
    if (distance >= canvas.dimensions.size / 2) {
      this.constructor.placeableClass.create(event.data.object.data);
    }
    this._onDragCancel(event);
  }

  /* -------------------------------------------- */

  /**
   * Default handling of mouse-up events which conclude a new object creation after dragging
   * @private
   */
  _onMouseUp(event) {
    const cs = event.data.createState;
    if ( cs === 2 ) {
      event.stopPropagation();
      this._onDragCreate(event);
      event.data.createState = 0;
    }
    else if ( cs === 1 ) {
      event.stopPropagation();
      this._onDragCancel(event);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-wheel events at the PlaceableObjects layer level to rotate multiple objects at once.
   * This handler will rotate all controlled objects by some incremental angle.
   * @param {Event} event   The mousewheel event which originated the request
   * @private
   */
  _onMouseWheel(event) {

    // Prevent wheel rotation for non-GM users if the game is paused
    if ( game.paused && !game.user.isGM ) return;

    // Rate limit PlaceableObject rotation since it triggers a database update
    let t = Date.now();
    let dt = t - (keyboard._wheelTime || 0);
    if ( dt < keyboard.constructor.MOUSE_WHEEL_RATE_LIMIT ) return;
    keyboard._wheelTime = t;

    // Determine the incremental angle of rotation from event data
    const dBig = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 60 : 45;
    let snap = event.shiftKey ? dBig : 15;
    let delta = snap * Math.sign(event.deltaY);

    // Call the rotateMany method to handle the request
    return this.rotateMany({delta, snap});
  }

  /* -------------------------------------------- */

  /**
   * Handle right mouse-click events which occur while this layer is active
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onRightDown(event) {
    if ( this.hud ) this.hud.clear();
  }

  /* -------------------------------------------- */

  /**
   * Handle a DELETE keypress while a placeable object is hovered
   * @param {Event} event    The delete key press event which triggered the request
   * @private
   */
  async _onDeleteKey(event) {
    if ( !this.options.canDelete ) return;
    let ids = null;
    if ( this.options.controllableObjects ) ids = this.controlled.map(obj => obj.id);
    else ids = this._hover ? [this._hover.id] : [];
    if ( !ids.length ) return;
    return canvas.activeLayer.deleteMany(ids);
  }
}

jQuery.fn.shake = function(shakes, distance, duration) {
  if (shakes > 0) {
    this.each(function () {
      let $el = $(this);
      let left = $el.css('left');
      $el.animate({left: "-=" + distance}, duration, function () {
        $el.animate({left: "+=" + distance * 2}, duration, function () {
          $el.animate({left: left}, duration, function () {
            $el.shake(shakes - 1, distance, duration);
          });
        });
      });
    });
  }
  return this;
};

/**
 * Display a right-click activated Context Menu which provides a dropdown menu of options
 * A ContextMenu is constructed by designating a parent HTML container and a target selector
 * An Array of menuItems defines the entries of the menu which is displayed
 * 
 * @param {HTMLElement|jQuery} element    The containing HTML element within which the menu is positioned
 * @param {String} selector               A CSS selector which activates the context menu.
 * @param {Array} menuItems               An Array of entries to display in the menu
 * @param {String} eventName              Optionally override the triggering event which can spawn the menu
 *
 * @param {Object} menuItem               Menu items in the array can have the following properties
 * @param {String} menuItem.name          The displayed item name
 * @param {String} menuItem.icon          An icon glyph HTML string
 * @param {Function} menuItem.condition   A function which returns a Boolean for whether or not to display the item
 * @param {Function} menuItem.callback    A callback function to trigger when the entry of the menu is clicked
 */
class ContextMenu {
  constructor(element, selector, menuItems, {eventName="contextmenu"}={}) {

    /**
     * The target HTMLElement being selected
     * @type {HTMLElement}
     */
    this.element = element;

    /**
     * The target CSS selector which activates the menu
     * @type {String}
     */
    this.selector = selector || element.attr("id");

    /**
     * An interaction event name which activates the menu
     * @type {String}
     */
    this.eventName = eventName;

    /**
     * The array of menu items being rendered
     * @type {Array}
     */
    this.menuItems = menuItems;

    /**
     * Track which direction the menu is expanded in
     * @type {Boolean}
     */
    this._expandUp = false;

    // Bind to the current element
    this.bind();
  }

  /* -------------------------------------------- */

  /**
   * A convenience accessor to the context menu HTML object
   * @return {*|jQuery.fn.init|jQuery|HTMLElement}
   */
  get menu() {
    return $("#context-menu");
  }

  /* -------------------------------------------- */

  /**
   * Attach a ContextMenu instance to an HTML selector
   */
  bind() {
    this.element.on(this.eventName, this.selector, event => {
      event.preventDefault();
      let parent = $(event.currentTarget),
          menu = this.menu;

      // Remove existing context UI
      $('.context').removeClass("context");

      // Close the current context
      if ( $.contains(parent[0], menu[0]) ) this.close();

      // If the new target element is different
      else {
        this.render(parent);
        ui.context = this;
      }
    })
  }

  /* -------------------------------------------- */

  /**
   * Animate closing the menu by sliding up and removing from the DOM
   */
  async close() {
    let menu = this.menu;
    await this._animateClose(menu);
    menu.remove();
    $('.context').removeClass("context");
    delete ui.context;
  }

  /* -------------------------------------------- */

  async _animateOpen(menu) {
    menu.hide();
    return new Promise(resolve => menu.slideDown(200, resolve));
  }

  /* -------------------------------------------- */

  async _animateClose(menu) {
    return new Promise(resolve => menu.slideUp(200, resolve));
  }

  /* -------------------------------------------- */

  /**
   * Render the Context Menu by iterating over the menuItems it contains
   * Check the visibility of each menu item, and only render ones which are allowed by the item's logical condition
   * Attach a click handler to each item which is rendered
   * @param target
   */
  render(target) {
    let html = $("#context-menu").length ? $("#context-menu") : $('<nav id="context-menu"></nav>');
    let ol = $('<ol class="context-items"></ol>');
    html.html(ol);

    // Build menu items
    for (let item of this.menuItems) {

      // Determine menu item visibility (display unless false)
      let display = true;
      if ( item.condition !== undefined ) {
        display = ( item.condition instanceof Function ) ? item.condition(target) : item.condition;
      }
      if ( !display ) continue;

      // Construct and add the menu item
      let name = game.i18n.localize(item.name);
      let li = $(`<li class="context-item">${item.icon}${name}</li>`);
      li.children("i").addClass("fa-fw");
      li.click(e => {
        e.preventDefault();
        e.stopPropagation();
        item.callback(target);
        this.close();
      });
      ol.append(li);
    }

    // Bail out if there are no children
    if ( ol.children().length === 0 ) return;

    // Append to target
    this._setPosition(html, target);

    // Animate open the menu
    return this._animateOpen(html);
  }

  /* -------------------------------------------- */

  _setPosition(html, target) {
    const targetRect = target[0].getBoundingClientRect();

    // Append to target and get the context bounds
    target.css('position', 'relative');
    html.css("visibility", "hidden");
    target.append(html);
    const contextRect = html[0].getBoundingClientRect();

    // Determine whether to expand down or expand up
    this._expandUp = (window.innerHeight - targetRect.bottom - contextRect.height - 20) < 0;

    // Display the menu
    html.addClass(this._expandUp ? "expand-up" : "expand-down");
    html.css("visibility", "");
    target.addClass("context");
  }

  /* -------------------------------------------- */

  static eventListeners() {
    document.addEventListener("click", ev => {
      if ( ui.context ) ui.context.close();
    });
  };
}

/* -------------------------------------------- */

/**
 * Create a modal dialog window displaying a title, a message, and a set of buttons which trigger callback functions.
 * @type {Application}
 *
 * @param {Object} dialogData           An object of dialog data which configures how the modal window is rendered
 * @param {string} dialogData.title     The window title
 * @param {string} dialogData.content   HTML content
 * @param {Function} dialogData.close   Common callback operations to perform when the dialog is closed
 * @param {Object} dialogData.buttons   Action buttons which trigger callback functions.
 *                                      Buttons are defined as an Object with the format ``{name: buttonData}``.
 *                                      Valid keys for buttonData include:
 *
 * @param {string} dialogData.buttons.button.icon A button icon
 * @param {string} dialogData.buttons.button.label A button label
 * @param {Function} dialogData.buttons.button.callback A callback function taking no arguments
 *
 * @param {Object} options              Dialog rendering options, see :class:`Application`
 * @param {string} options.default      The name of the default button which should be triggered on Enter
 *
 * @example
 * let d = new Dialog({
 *  title: "Test Dialog",
 *  content: "<p>You must choose either Option 1, or Option 2</p>",
 *  buttons: {
 *   one: {
 *    icon: '<i class="fas fa-check"></i>',
 *    label: "Option One",
 *    callback: () => console.log("Chose One")
 *   },
 *   two: {
 *    icon: '<i class="fas fa-times"></i>',
 *    label: "Option Two",
 *    callback: () => console.log("Chose Two")
 *   }
 *  },
 *  default: "two",
 *  close: () => console.log("This always is logged no matter which option is chosen")
 * });
 * d.render(true);
 */
class Dialog extends Application {
  constructor(dialogData, options) {
    super(options);
    this.data = dialogData;
  }

	/* -------------------------------------------- */

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    template: "templates/hud/dialog.html",
      classes: ["dialog"],
      width: 400
    });
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return this.data.title || "Dialog";
  }

  /* -------------------------------------------- */

  /** @override */
  getData() {
    let buttons = Object.keys(this.data.buttons).reduce((obj, key) => {
      let b = this.data.buttons[key];
      if ( b.condition !== false ) obj[key] = b;
      return obj;
    }, {});
    return {
      content: this.data.content,
      buttons: buttons
    }
  }

  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    html.find(".dialog-button").click(ev => {
      let id = ev.currentTarget.getAttribute("data-button"),
          button = this.data.buttons[id];
      this._submit(button, html);
    });

    // Default choice selection
    $(document).on('keydown.chooseDefault', event => {
      if ( (event.key === "Enter") && this.data.default ) {
        event.preventDefault();
        this._submit(this.data.buttons[this.data.default], html);
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Submit the Dialog by selecting one of its buttons
   * @param {Object} button     The configuration of the chosen button
   * @param {HTMLElement }html  The button HTML element
   * @private
   */
  _submit(button, html) {
    try {
      if (button.callback) button.callback(html);
      this.close();
    } catch(err) {
      ui.notifications.error(err);
      throw new Error(err);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  close() {
    if ( this.data.close ) this.data.close(this.element);
    super.close();
    $(document).off('keydown.chooseDefault');
  }

  /* -------------------------------------------- */

  /**
   * A helper factory method to create simple confirmation dialog windows which consist of simple yes/no prompts.
   * If you require more flexibility, a custom Dialog instance is preferred.
   *
   * @param {string} title          The confirmation window title
   * @param {string} content        The confirmation message
   * @param {Function} yes          Callback function upon yes
   * @param {Function} no           Callback function upon no
   * @param {boolean} defaultYes    Make "yes" the default choice?
   * @param {Object} options        Additional rendering options passed to the Dialog
   *
   * @return {Promise}              A promise which resolves once the user makes a choice or closes the window
   */
  static async confirm({title, content, yes, no, defaultYes=true}={}, options={}) {
    return new Promise(resolve => {
      const dialog = new this({
        title: title,
        content: content,
        buttons: {
          yes: {
            icon: '<i class="fas fa-check"></i>',
            label: game.i18n.localize("Yes"),
            callback: yes
          },
          no: {
            icon: '<i class="fas fa-times"></i>',
            label: game.i18n.localize("No"),
            callback: no
          }
        },
        default: defaultYes ? "yes" : "no",
        close: resolve
      }, options);
      dialog.render(true);
    });
  }
}

/* -------------------------------------------- */

/**
 * @see {@link Dialog.confirm}
 * @deprecated since 0.4.4
 */
confirmDialog = async function(...args) {
  console.warn(`The confirmDialog() global method which is moved to a static factory method Dialog.confirm()`);
  return Dialog.confirm(...args);
};

/**
 * A UI utility to make an element draggable.
 */
class Draggable {
  constructor(app, element, handle, resizable) {

    // Setup element data
    this.app = app;
    this.element = element[0];
    this.handle = handle || this.element;

    /**
     * Duplicate the application's starting position to track differences
     * @type {Object}
     */
    this.position = null;

    /**
     * Remember event handlers associated with this Draggable class so they may be later unregistered
     * @type {Object}
     */
    this.handlers = {};

    // Initialize draggable state
    this._initializeDrag();

    // Initialize resiable state
    if ( resizable ) this._initializeResize();
  }

  /* ----------------------------------------- */

  _initializeDrag() {

    // Register handlers
    this.handlers["dragDown"] = ["mousedown", e => this._onDragMouseDown(e), false];
    this.handlers["dragMove"] = ["mousemove", e => this._onDragMouseMove(e), false];
    this.handlers["dragUp"] = ["mouseup", e => this._onDragMouseUp(e), false];

    // Attach the click handler and CSS class
    this.handle.addEventListener(...this.handlers.dragDown);
    this.handle.classList.add("draggable");
  }

  /* ----------------------------------------- */

  _initializeResize() {

    // Create the handle;
    let handle = $('<div class="window-resizable-handle"><i class="fas fa-arrows-alt-h"></i></div>')[0];
    this.element.appendChild(handle);

    // Register handlers
    this.handlers["resizeDown"] = ["mousedown", e => this._onResizeMouseDown(e), false];
    this.handlers["resizeMove"] = ["mousemove", e => this._onResizeMouseMove(e), false];
    this.handlers["resizeUp"] = ["mouseup", e => this._onResizeMouseUp(e), false];

    // Attach the click handler and CSS class
    handle.addEventListener(...this.handlers.resizeDown);
    this.handle.classList.add("resizable");
  }

  /* ----------------------------------------- */

  /**
   * Handle the initial mouse click which activates dragging behavior for the application
   * @private
   */
  _onDragMouseDown(event) {
    event.preventDefault();

    // Float the window to a higher z-index
    this._floatToTop();

    // Record initial position
    this.position = duplicate(this.app.position);
    this._initial = {x: event.clientX, y: event.clientY};

    // Add temporary handlers
    window.addEventListener(...this.handlers.dragMove);
    window.addEventListener(...this.handlers.dragUp);
  }

  /* ----------------------------------------- */

  /**
   * Move the window with the mouse, bounding the movement to ensure the window stays within bounds of the viewport
   * @private
   */
  _onDragMouseMove(event) {
    event.preventDefault();
    this.app.setPosition({
      left: this.position.left + (event.clientX - this._initial.x),
      top: this.position.top + (event.clientY - this._initial.y)
    });
  }

  /* ----------------------------------------- */

  /**
   * Conclude the dragging behavior when the mouse is release, setting the final position and removing listeners
   * @private
   */
  _onDragMouseUp(event) {
    event.preventDefault();
    window.removeEventListener(...this.handlers.dragMove);
    window.removeEventListener(...this.handlers.dragUp);
  }

  /* ----------------------------------------- */

  /**
   * Handle the initial mouse click which activates dragging behavior for the application
   * @private
   */
  _onResizeMouseDown(event) {
    event.preventDefault();

    // Float the window to a higher z-index
    this._floatToTop();

    // Record initial position
    this.position = duplicate(this.app.position);
    if ( this.position.height === "auto" ) this.position.height = this.element.clientHeight;
    if ( this.position.width === "auto" ) this.position.width = this.element.clientWidth;
    this._initial = {x: event.clientX, y: event.clientY};

    // Add temporary handlers
    window.addEventListener(...this.handlers.resizeMove);
    window.addEventListener(...this.handlers.resizeUp);
  }

  /* ----------------------------------------- */

  /**
   * Move the window with the mouse, bounding the movement to ensure the window stays within bounds of the viewport
   * @private
   */
  _onResizeMouseMove(event) {
    event.preventDefault();
    this.app.setPosition({
      width: this.position.width + (event.clientX - this._initial.x),
      height: this.position.height + (event.clientY - this._initial.y)
    });
  }

  /* ----------------------------------------- */

  /**
   * Conclude the dragging behavior when the mouse is release, setting the final position and removing listeners
   * @private
   */
  _onResizeMouseUp(event) {
    event.preventDefault();
    window.removeEventListener(...this.handlers.resizeMove);
    window.removeEventListener(...this.handlers.resizeUp);
    this.app._onResize(event);
  }

  /* ----------------------------------------- */

  _floatToTop() {
    let z = Number(window.document.defaultView.getComputedStyle(this.element).zIndex);
    if ( z <= _maxZ ) {
      this.element.style.zIndex = Math.min(++_maxZ, 9999);
    }
  }

}
/**
 * A collection of helper functions and utility methods related to the rich text editor
 */
class TextEditor {

  /**
   * Create a Rich Text Editor. The current implementation uses TinyMCE
   * @param {Object} options          Configuration options provided to the Editor init
   * @param {string} content          Initial HTML or text content to populate the editor with
   * @return {tinyMCE.Editor}         The editor instance.
   */
  static create(options, content) {
    let defaultOptions = {
      branding: false,
      menubar: false,
      statusbar: false,
      plugins: CONFIG.TinyMCE.plugins,
      toolbar: CONFIG.TinyMCE.toolbar,
      content_css: CONFIG.TinyMCE.css.join(","),
      save_enablewhendirty: true,
      table_default_styles: {},

      // Style Dropdown Formats
      style_formats: [
        {
          title: "Custom",
          items: [
            {
              title: "Secret",
              block: 'section',
              classes: 'secret',
              wrapper: true
            }
          ]
        }
      ],
      style_formats_merge: true,

      // Bind callback events
      init_instance_callback: editor => {
        const window = editor.getWin();

        // Set initial content
        if ( content ) editor.setContent(content);

        // Prevent window zooming
        window.addEventListener("wheel", event => {
          if ( event.ctrlKey ) event.preventDefault();
        }, {passive: false});

        // Handle dropped Entity data
        window.addEventListener("drop", ev => this._onDropEditorData(ev, editor))
      }
    };

    const mceConfig = mergeObject(defaultOptions, options);
    mceConfig.target = options.target;
    return tinyMCE.init(mceConfig);
  }

  /* -------------------------------------------- */

  /**
   * Enrich HTML content by replacing or augmenting components of it
   * @param {String} content        The original HTML content (as a string)
   * @param {boolean} secrets       Remove secret tags?
   * @param {boolean} entities      Replace dynamic entity links?
   * @param {boolean} links         Replace hyperlink content?
   * @param {boolean} rolls         Replace inline dice rolls?
   * @return {String}               The enriched HTML content
   */
  static enrichHTML(content, {secrets=false, entities=true, links=true, rolls=true}={}) {
    let html = document.createElement("div");
    html.innerHTML = content;

    // Strip secrets
    if ( !secrets ) {
      let elements = html.querySelectorAll("section.secret");
      elements.forEach(e => e.parentNode.removeChild(e));
    }

    // Match content links
    if ( entities ) {
      const entityTypes = CONST.ENTITY_LINK_TYPES.concat("Compendium");
      const entityMatchRgx = `@(${entityTypes.join("|")})\\[([^\\]]+)\\](?:{([^}]+)})?`;
      const rgx = new RegExp(entityMatchRgx, 'g');

      // Find and preload compendium indices
      const matches = Array.from(html.innerHTML.matchAll(rgx));
      if ( matches.length ) this._preloadCompendiumIndices(matches);

      // Replace content links
      html.innerHTML = html.innerHTML.replace(rgx, this._replaceContentLinks.bind(this));
    }

    // Replace hyperlinks
    if ( links ) {
      let rgx = /(?:[^\S]|^)((?:(?:https?:\/\/)|(?:www\.))(?:\S+))/gi;
      html.innerHTML = html.innerHTML.replace(rgx, this._replaceHyperlinks);
    }

    // Process inline dice rolls
    if ( rolls ) {
      const rgx = /\[\[(\/[a-zA-Z]+\s)?([^\]]+)\]\]/gi;
      html.innerHTML = html.innerHTML.replace(rgx, this._replaceInlineRolls);
    }

    // Return the enriched HTML
    return html.innerHTML;
  };

  /* -------------------------------------------- */

  /**
   * If dynamic content links are used from a certain compendium, we will go ahead and preload the index for that
   * Compendium pack in the background so the links can function better.
   * @private
   */
  static async _preloadCompendiumIndices(matches) {
    const collections = new Set([]);
    for ( let m of matches ) {
      if (m[1] !== "Compendium") continue;
      collections.add(m[2].split(".").slice(0, 2).join("."))
    }
    for ( let c of collections ) {
      const pack = game.packs.find(p => p.collection === c);
      if ( pack && !pack.index.length ) pack.getIndex();
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle replacement of content links within HTML by delegating to different helper methods based on entity type
   * @private
   */
  static _replaceContentLinks(match, entityType, id, name) {

    // Match Compendium content
    if ( entityType === "Compendium" ) {
      return this._replaceCompendiumLink(match, id, name);
    }

    // Match World content
    else {
      return this._replaceEntityLink(match, entityType, id, name);
    }
  }

  /* -------------------------------------------- */

  /**
   * Replace a matched Entity Link with an actual HTML link to that entity
   * Be failure-tolerant, allowing for the possibility that the entity does not exist
   * @param {string} match        The full matched string
   * @param {string} id           The Entity ID or name
   * @param {name} name           A custom text name to display
   * @return {string}             The replacement string
   */
  static  _replaceCompendiumLink(match, id, name) {
    const [scope, packName, target] = id.split(".");
    const pack = game.packs.find(p => p.collection === `${scope}.${packName}`);
    if ( !pack ) return match;
    const config = CONFIG[pack.metadata.entity];
    let icon = config.sidebarIcon;

    // Case 1 - the pack Index is already queried
    let entry = null;
    if ( pack.index.length ) {
      entry = pack.index.find(i => (i.id === target) || (i.name === target));
      if ( entry ) {
        name = name || entry.name;
        return `<a class="entity-link" data-pack=${pack.collection} data-id="${entry.id}" draggable="true"><i class="${icon}"></i> ${name}</a>`;
      }
    }

    // Case 2 - defer lookup for later, but request the Index now
    name = name || target;
    return `<a class="entity-link" data-pack=${pack.collection} data-lookup="${target}" draggable="true"><i class="${icon}"></i> ${name}</a>`;
  }

  /* -------------------------------------------- */

  /**
   * Replace a matched Entity Link with an actual HTML link to that entity
   * Be failure-tolerant, allowing for the possibility that the entity does not exist
   * @param {string} match        The full matched string
   * @param {string} entityType   The named type of Entity being embedded
   * @param {string} id           The Entity ID or name
   * @param {name} name           A custom text name to display
   * @return {string}             The replacement string
   */
  static _replaceEntityLink(match, entityType, id, name) {
    const config = CONFIG[entityType];
    const collection = config.entityClass.collection;

    // Track inline data
    const inline = { cls: "entity-link", icon: config.sidebarIcon, data: { entity: entityType } };

    // Match either on ID or by name
    let entity = null;
    if (/^[a-zA-Z0-9]{16}$/.test(id)) entity = collection.get(id);
    if ( !entity ) entity = collection.entities.find(e => e.data.name === id);

    // If an entity was found, populate data
    if ( entity ) {
      inline.data.id = entity.id;
      inline.name = entity.name;
    }

    // Otherwise flag the link as broken
    else {
      inline.cls += ` broken`;
      inline.icon = "fas fa-unlink";
      inline.name = name || id;
    }

    // Format an Entity link to the target
    const data = Object.entries(inline.data).reduce((arr, e) => {
      arr.push(`data-${e[0]}="${e[1]}"`);
      return arr;
    }, []);
    return `<a class="${inline.cls}" draggable="true" ${data.join(' ')}><i class="${inline.icon}"></i> ${inline.name}</a>`;
  }

  /* -------------------------------------------- */

  /**
   * Replace a hyperlink-like string with an actual HTML <a> tag
   * @return {String}   The replacement string
   */
  static _replaceHyperlinks(match, url) {
    let href = /^https?:\/\//.test(url) ? url : `http://${url}`;
    return `<a class="hyperlink" href="${href}" target="_blank">${url}</a>`;
  }

  /* -------------------------------------------- */

  /**
   * Replace an inline roll formula with a rollable button or an eagerly evaluated roll result
   * @param {string} match      The matched string
   * @param {string} command    An optional command
   * @param {string} formula    The matched formula
   * @return {string}           The replaced match
   */
  static _replaceInlineRolls(match, command, formula) {
    const isDeferred = !!command;
    let roll;

    // Define default inline data
    const inline = { cls: "inline-roll", data: {} };

    // If the roll is deferred, parse it as a chat command
    if ( isDeferred ) {
      const chatCommand = `${command}${formula}`;
      let parsedCommand = null;
      try {
        parsedCommand = ChatLog.parse(chatCommand);
      } catch(err) {
        return match;
      }
      inline.cls += ` ${parsedCommand[0]}`;
      inline.data.mode = parsedCommand[0];

      // Flavor text
      const flavor = parsedCommand[1][3];
      inline.data.flavor = flavor ? flavor.trim() : "";

      // Parsed formula
      inline.data.formula = parsedCommand[1][2].trim();
      inline.result = parsedCommand[1][2].trim();

      // Tooltip
      inline.title = inline.data.flavor || inline.data.formula;
    }

    // Otherwise perform the dice roll
    else {
      try {
        roll = new Roll(formula).roll();
        inline.cls += " inline-result";
        inline.result = roll.total;
        inline.title = formula;
        inline.data.roll = escape(JSON.stringify(roll));
      } catch(err) {
        return match;
      }
    }

    // Return the inline HTML
    const data = Object.entries(inline.data).reduce((arr, e) => {
      arr.push(`data-${e[0]}="${e[1]}"`);
      return arr;
    }, []);
    return `<a class="${inline.cls}" ${data.join(' ')} title="${inline.title}"><i class="fas fa-dice-d20"></i> ${inline.result}</a>`;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  static activateListeners() {
    const body = $("body");
    body.on("click", "a.entity-link", this._onClickEntityLink);
    body.on('dragstart', "a.entity-link", this._onDragEntityLink);
    body.on("click", "a.inline-roll", this._onClickInlineRoll);
  }

  /* -------------------------------------------- */

  /**
   * Handle click events on Entity Links
   * @param {Event} event
   * @private
   */
  static async _onClickEntityLink(event) {
    event.preventDefault();
    const  a = event.currentTarget;
    let entity = null;

    // Case 1 - Compendium Link
    if ( a.dataset.pack ) {
      const pack = game.packs.find(p => p.collection === a.dataset.pack);
      let id = a.dataset.id;
      if ( a.dataset.lookup ) {
        if ( !pack.index.length ) await pack.getIndex();
        const entry = pack.index.find(i => (i.id === a.dataset.lookup) || (i.name === a.dataset.lookup));
        id = entry.id;
      }
      entity = id ? await pack.getEntity(id) : null;
    }

    // Case 2 - World Entity Link
    else {
      const cls = CONFIG[a.dataset.entity].entityClass;
      entity = cls.collection.get(a.dataset.id);
    }

    // Render the Entity sheet
    if ( !entity ) return;
    if ( (entity.entity === "Scene") && entity.journal ) return entity.journal.sheet.render(true);
    else entity.sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle left-mouse clicks on an inline roll, dispatching the formula or displaying the tooltip
   * @param {MouseEvent} event    The initiating click event
   * @private
   */
  static async _onClickInlineRoll(event) {
    event.preventDefault();
    const a = event.currentTarget;

    // For inline results expand or collapse the roll details
    if ( a.classList.contains("inline-result") ) {
      const roll = Roll.fromJSON(unescape(a.dataset.roll));
      const tooltip = a.classList.contains("expanded") ? roll.total : `${roll.result} = ${roll.total}`;
      a.innerHTML = `<i class="fas fa-dice-d20"></i> ${tooltip}`;
      a.classList.toggle("expanded");
    }

    // Otherwise execute the deferred roll
    else {
      const roll = new Roll(a.dataset.formula).roll();
      return roll.toMessage({flavor: a.dataset.flavor}, {rollMode: a.dataset.mode});
    }

    // For
  }

  /* -------------------------------------------- */

  /**
   * Begin a Drag+Drop workflow for a dynamic content link
   * @param {Event} event   The originating drag event
   * @private
   */
  static _onDragEntityLink(event) {
    event.stopPropagation();
    const a = event.currentTarget;
    let dragData = null;

    // Case 1 - Compendium Link
    if ( a.dataset.pack ) {
      const pack = game.packs.find(p => p.collection === a.dataset.pack);
      let id = a.dataset.id;
      if ( a.dataset.lookup && pack.index.length ) {
        const entry = pack.index.find(i => (i.id === a.dataset.lookup) || (i.name === a.dataset.lookup));
        if ( entry ) id = entry.id;
      }
      if ( !id ) return false;
      dragData = { type: pack.entity, pack: pack.collection, id: id };
    }

    // Case 2 - World Entity Link
    else dragData = { type: a.dataset.entity, id: a.dataset.id };
    event.originalEvent.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

	/* -------------------------------------------- */

  /**
   * Begin a a data transfer drag event with default handling
   * @private
   */
	_onDragStart(event) {
	  event.stopPropagation();
	  let li = event.currentTarget.closest("li.directory-item");
    const dragData = li.classList.contains("folder") ?
      { type: "Folder", id: li.dataset.folderId, entity: this.constructor.entity } :
      { type: this.constructor.entity, id: li.dataset.entityId };
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
    this._dragType = dragData.type;
  }

	/* -------------------------------------------- */

  /**
   * Handle dropping of transferred data onto the active rich text editor
   * @param {Event} event     The originating drop event which triggered the data transfer
   * @param {tinyMCE} editor  The TinyMCE editor instance being dropped on
   * @private
   */
  static async _onDropEditorData(event, editor) {
    event.preventDefault();
	  const data = JSON.parse(event.dataTransfer.getData('text/plain'));
	  if ( !data ) return;

	  // Case 1 - Entity from Compendium Pack
    if ( data.pack ) {
      const pack = game.packs.find(p => p.collection === data.pack);
      if (!pack) return;
      const entity = await pack.getEntity(data.id);
      const link = `@Compendium[${data.pack}.${data.id}]{${entity.name}}`;
      editor.insertContent(link);
    }

    // Case 2 - Entity from World
    else {
      const config = CONFIG[data.type];
      if ( !config ) return false;
      const entity = config.collection.instance.get(data.id);
      if ( !entity ) return false;
      const link = `@${data.type}[${entity._id}]{${entity.name}}`;
      editor.insertContent(link);
    }
  }
}

// Global Export
window.TextEditor = TextEditor;

/* -------------------------------------------- */
/*  Backwards Compatibility Globals             */
/* -------------------------------------------- */

function createEditor(...args) {
  console.warn(`You are using the createEditor(options, content) function and must migrate to TextEditor.create()`);
  return TextEditor.create(...args);
}

function enrichHTML(...args) {
  console.warn(`You are using the enrichHTML(options, content) function and must migrate to TextEditor.enrichHTML()`);
  return TextEditor.enrichHTML(...args);
}

/* -------------------------------------------- */


/**
 * The FilePicker application renders contents of the server-side public directory
 * This app allows for navigating and uploading files to the public path
 * @type {Application}
 */
class FilePicker extends Application {
  constructor(options={}) {
    super(options);

    /**
     * The full requested path given by the user
     * @type {string}
     */
    this.request = options.current;

    /**
     * The file sources which are available for browsing
     * @type {Object}
     */
    this.sources = Object.entries({
      data: {
        target: "",
        dirs: [],
        files: [],
        label: "User Data",
        icon: "fas fa-database"
      },
      public: {
        target: "",
        dirs: [],
        files: [],
        label: "Core Data",
        icon: "fas fa-server"
      },
      s3: {
        buckets: [],
        bucket: "",
        target: "",
        dirs: [],
        files: [],
        label: "Amazon S3",
        icon: "fas fa-cloud"
      }
    }).reduce((obj, s) => {
      if ( game.data.storages.includes(s[0]) ) obj[s[0]] = s[1];
      return obj;
    }, {});

    /**
     * Track the active source tab which is being browsed
     * @type {string}
     */
    this.activeSource = "data";

    /**
     * The general file type which controls the set of extensions which will be accepted
     * @type {String}
     */
    this.type = options.type;

    /**
     * The target HTML element this file picker is bound to
     * @type {HTMLElement}
     */
    this.field = options.field;

    /**
     * A button which controls the display of the picker UI
     * @type {HTMLElement}
     */
    this.button = options.button;

    /**
     * The current set of file extensions which are being filtered upon
     * @type {Array}
     */
    this.extensions = this._getExtensions(this.type);

    // Infer the source
    const [source, target] = this._inferCurrentDirectory(this.request);
    this.activeSource = source;
    this.sources[source].target = target;

    // Track whether we have loaded files
    this._loaded = false;
  }

  /* -------------------------------------------- */

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    template: "templates/apps/filepicker.html",
      classes: ["filepicker"],
      width: 500,
      tabs: [{navSelector: ".tabs"}]
    });
  }

  /* -------------------------------------------- */

  /**
   * Given a current file path, determine the directory it belongs to
   * @param {String} target   The currently requested target path
   * @return {Array}          An array of the inferred source and target path
   */
  _inferCurrentDirectory(target) {

    // Determine target
    const ignored = [CONST.DEFAULT_TOKEN];
    if ( !target || ignored.includes(target) ) target = this.constructor.LAST_BROWSED_DIRECTORY;

    // Infer the data source
    const publicDirs = ["css", "fonts", "icons", "lang", "scripts", "sounds", "ui"];
    const source = publicDirs.some(d => target.startsWith(d)) ? "public" : "data";

    // Infer the current target
    if (["http://", "https://"].some(c => target.startsWith(c))) target = "";
    let dir = target.split("/");
    if ( dir.length === 0 ) target = "";

    // Return the inferred data
    return [source, target];
  }

  /* -------------------------------------------- */

  /**
   * Get the valid file extensions for a given named file picker type
   * @param {string} type
   * @return {Array}
   * @private
   */
  _getExtensions(type) {

    // Identify allowed extensions
    let types = [];
    if ( type === "image" ) types = CONST.IMAGE_FILE_EXTENSIONS;
    else if ( type === "audio" ) types = CONST.AUDIO_FILE_EXTENSIONS;
    else if ( type === "video" ) types = CONST.VIDEO_FILE_EXTENSIONS;
    else if ( type === "imagevideo") types = CONST.IMAGE_FILE_EXTENSIONS.concat(CONST.VIDEO_FILE_EXTENSIONS);
    if ( types.length === 0 ) return undefined;

    // Return the allowed types
    else return types.reduce((arr, t) => {
      arr.push(`.${t}`);
      arr.push(`.${t.toUpperCase()}`);
      return arr;
    }, []);
  }

  /* -------------------------------------------- */
  /*  FilePicker Properties                       */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    let type = this.type || "file";
    let title = type === "imagevideo" ? "Image or Video" : type.capitalize();
	  return `${title} Browser`;
  }

  /* -------------------------------------------- */

  /**
   * Return the source object for the currently active source
   * @return {Object}
   */
  get source() {
    return this.sources[this.activeSource];
  }

  /* -------------------------------------------- */

  /**
   * Return the target directory for the currently active source
   * @return {string}
   */
  get target() {
    return this.source.target;
  }

  /* -------------------------------------------- */

  /**
   * Return a flag for whether the current user is able to upload file content
   * @return {boolean}
   */
  get canUpload() {
    if ( !["data", "s3"].includes(this.activeSource) ) return false;
    if ( game.user && game.user.isGM ) return true;
    const allowTrusted = game.settings.get("core", "allowTrustedUpload");
    return game.user && game.user.isTrusted && allowTrusted;
  }

  /* -------------------------------------------- */

  /**
   * Return the upload URL to which the FilePicker should post uploaded files
   * @return {string}
   */
  static get uploadURL() {
    return ROUTE_PREFIX ? `/${ROUTE_PREFIX}/upload` : '/upload';
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async getData(options) {

    // Sort directories and files alphabetically
    const source = this.source;
    const active = this.active;

    // Sort directories alphabetically and store their partial URLs
    let dirs = source.dirs.map(d => { return {
      name: d.split("/").pop(),
      path: d
    }});
    dirs = dirs.sort((a, b) => a.name.localeCompare(b.name));

    // Sort files alphabetically and store their client URLs
    let files = source.files.map(f => { return {
        name: f.split("/").pop(),
        url: f
    }});
    files = files.sort((a, b) => a.name.localeCompare(b.name));

    // Return rendering data
    return {
      user: game.user,
      request: this.request,
      sources: this.sources,
      source: source,
      target: source.target,
      active: active,
      dirs: dirs,
      files: files,
      extensions: this.extensions,
      canUpload: this.canUpload,
      canGoBack: active !== "",
      isS3: this.activeSource === "s3",
      noResults: dirs.length + files.length === 0
    }
  }

  /* -------------------------------------------- */

  /**
   * Browse to a specific location for this FilePicker instance
   * @param {string} target     The target within the currently active source location.
   * @param {Object} options    Browsing options
   */
  async browse(target, options={}) {
    target = typeof target === "string" ? target : this.target;
    const source = this.activeSource;
    const isS3 = source === "s3";
    options = mergeObject({
      extensions: this.extensions,
      wildcard: false,
      bucket: isS3 ? this.source.bucket : null
    }, options);
    if ( target.startsWith("/") ) target = target.slice(1);

    // Avoid browsing certain paths
    if ( target === CONST.DEFAULT_TOKEN ) target = this.constructor.LAST_BROWSED_DIRECTORY;

    // Request files from the server
    const result = await this.constructor.browse(source, target, options).catch(error => {
      ui.notifications.warn(error);
      return this.constructor.browse(source, "", options);
    });

    // Flag updates
    this.constructor.LAST_BROWSED_DIRECTORY = result.target;

    // Populate S3 bucket list
    if ( isS3 && !this.source.buckets.length ) {
      this.source.buckets = result.dirs;
      this.source.bucket = result.dirs[0];
      return this.browse("/");
    }

    // Populate browser content
    mergeObject(this.sources[source], result);
    this._loaded = true;

    // Render the application
    this.render(true);
    return result;
  }

  /* -------------------------------------------- */

  /**
   * Browse files for a certain directory location
   * @param {string} source     The source location in which to browse. See FilePicker#sources for details
   * @param {string} target     The target within the source location
   *
   * @param {Object} options              Optional arguments
   * @param {string} options.bucket       A bucket within which to search if using the S3 source
   * @param {Array} options.extensions    An Array of file extensions to filter on
   * @param {boolean} options.wildcard    The requested dir represents a wildcard path
   *
   * @return {Promise}          A Promise which resolves to the directories and files contained in the location
   */
  static async browse(source, target, options={}) {
    return new Promise((resolve, reject) => {
      game.socket.emit("getFiles", source, target, options, result => {
        if ( result.error ) return reject(result.error);
        result.target = decodeURI(result.target);
        result.dirs = result.dirs.map(decodeURI);
        resolve(result);
      });
    })
  }

  /* -------------------------------------------- */

  /**
   * Dispatch a POST request to the server containing a directory path and a file to upload
   * @param {string} source   The data source to which the file should be uploaded
   * @param {string} path     The destination path
   * @param {File} file       The File object to upload
   * @param {Object} options  Additional file upload options passed as form data
   * @return {Promise<boolean>}
   */
  static async upload(source, path, file, options) {

    // Create the form data to post
    const fd = new FormData();
    fd.set("source", source);
    fd.set("target", path);
    fd.set("upload", file);
    Object.entries(options).forEach(o => fd.set(...o));

    // Dispatch the request
    const response = await fetch(this.uploadURL, {method: "POST", body: fd}).then(r => r.json());
    if (response.error) {
      ui.notifications.error(response.error);
      return false;
    } else if (response.message) {
      ui.notifications.info(response.message);
    }
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Additional actions performed when the file-picker UI is rendered
   */
  render(force, options) {
    this.position.height = null;
    this.element.css({height: ""});
    if ( !this._loaded ) return this.browse();
    else super.render(force, options);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Activate listeners to handle user interactivity for the FilePicker UI
   * @param html
   */
  activateListeners(html) {
	  super.activateListeners(html);
	  const form = html[0];

    // Change the directory
    html.find('input[name="target"]').on("keydown", this._onRequestTarget.bind(this));

    // Filter results
    html.find('input[name="filter"]').on("keyup", this._onFilterResults.bind(this));

    // Select an entry in the directory
    html.find(".file-directory").on("click", "li", this._onPick.bind(this));

    // Go back one directory
    html.find('.back').click(this._onBack.bind(this));

    // Change the S3 bucket
    html.find('select[name="bucket"]').change(this._onChangeBucket.bind(this));

    // Handle file drops on the picker window
    form.ondrop = this._onDrop.bind(this);

    // Upload new file
    if ( this.canUpload ) form.upload.onchange = ev => this._onUpload(ev);

	  // Form submission
    form.onsubmit = ev => this._onSubmit(ev);
  }

	/* -------------------------------------------- */

  /** @override */
  _onChangeTab(event, tabs, active) {
    this.activeSource = active;
    this.browse(this.source.target);
  }

  /* -------------------------------------------- */

  /**
   * Handle a drop event to support dropping files onto the file picker and automatically uploading them
   * @param event
   * @private
   */
  async _onDrop(event) {
    if ( this.activeSource === "public" ) return;
    const form = event.currentTarget;
    form.disabled = true;
    const target = form.target.value;

    // Process the data transfer
    const data = event.dataTransfer;
    const files = data.files;
    if ( !files || !files.length ) return;

    // Iterate over dropped files
    for ( let upload of files ) {
      if ( !this.extensions.some(ext => upload.name.endsWith(ext)) ) {
        ui.notifications.error(`Incorrect ${this.type} file extension. Supports ${this.extensions.join(" ")}.`);
        continue;
      }
      await this.constructor.upload(this.activeSource, target, upload, {
        bucket: form.bucket ? form.bucket.value : null
      });
    }

    // Re-enable the form
    form.disabled = false;
    return this.browse(target);
  }

  /* -------------------------------------------- */

  /**
   * Handle user submission of the address bar to request an explicit target
   * @param {KeyboardEvent} event     The originating keydown event
   * @private
   */
  _onRequestTarget(event) {
    if ( event.key === "Enter" ) {
      event.preventDefault();
      this.browse(event.target.value);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle file or folder selection within the file picker
   * @param {Event} event     The originating click event
   * @private
   */
  _onPick(event) {
    const li = event.currentTarget;
    if ( li.classList.contains("dir") ) return this.browse(li.dataset.path);
    else li.closest("form").file.value = li.dataset.path;
  }

  /* -------------------------------------------- */

  /**
   * Handle backwards navigation of the folder structure
   * @private
   */
  _onBack(event) {
    event.preventDefault();
    let target = this.target.split("/");
    target.pop();
    return this.browse(target.join("/"));
  }

  /* -------------------------------------------- */

  _onChangeBucket(event) {
    event.preventDefault();
    const select = event.currentTarget;
    this.sources.s3.bucket = select.value;
    return this.browse("/");
  }

  /* -------------------------------------------- */

  /**
   * Handle a keyup event in the filter box to restrict the set of files shown in the FilePicker
   * @private
   */
  _onFilterResults(event) {
    event.preventDefault();
    let input = event.currentTarget;

    // Define filtering function
    let filter = query => {
      this.element.find('.file-directory').children().each((i, li) => {
        li.hidden = !query.test(li.dataset.path);
      });
    };

    // Filter if we are done entering keys
    let query = new RegExp(RegExp.escape(input.value), "i");
    this._filterTime = new Date();
    setTimeout(() => {
      if ( new Date() - this._filterTime > 250) filter(query);
    }, 251);
  }


  /* -------------------------------------------- */

  /**
   * Handle file picker form submission
   * @param ev {Event}
   * @private
   */
  _onSubmit(ev) {
    ev.preventDefault();
    let path = ev.target.file.value;
    if ( !path ) return ui.notifications.error("You must select a file to proceed.");

    // Update the target field
    if ( this.field ) {
      this.field.value = path;
      this.field.dispatchEvent(new Event("change"));
    }

    // Trigger a callback and close
    if ( this.options.callback ) this.options.callback(path);
    this.close();
  }

  /* -------------------------------------------- */

  /**
   * Handle file upload
   * @param ev
   * @private
   */
  async _onUpload(ev) {
    const form = ev.target.form;
    const upload = form.upload.files[0];

    // Validate file extension
    if ( !this.extensions.some(ext => upload.name.endsWith(ext)) ) {
      ui.notifications.error(`Incorrect ${this.type} file extension. Supports ${this.extensions.join(" ")}.`);
      return false;
    }

    // Dispatch the request
    const target = form.target.value;
    await this.constructor.upload(this.activeSource, target, upload, {
      bucket: form.bucket ? form.bucket.value : null
    });
    return this.browse(target);
  }

  /* -------------------------------------------- */
  /*  Factory Methods
  /* -------------------------------------------- */

  /**
   * Bind the file picker to a new target field.
   * Assumes the user will provide a <button> HTMLElement which has the data-target and data-type attributes
   * The data-target attribute should provide the name of the input field which should receive the selected file
   * The data-type attribute is a string in ["image", "audio"] which sets the file extensions which will be accepted
   *
   * @param button {HTMLElement}    The button element
   */
  static fromButton(button, options) {
    if ( !(button instanceof HTMLElement ) ) throw "You must pass an HTML button";
    let type = button.getAttribute("data-type");

    // Identify the target form field
    let form = button.form,
        target = form[button.getAttribute("data-target")];
    if ( !target ) return;

    // Build and return a FilePicker instance
    return new FilePicker({field: target, type: type, current: target.value, button: button});
  }
}

FilePicker.LAST_BROWSED_DIRECTORY = "";

$(document).ready(function() {

  /**
   * Support mousewheel control for range type input elements
   */
  $('body').on("mousewheel", 'input[type="range"]', ev => {
    let rng = ev.currentTarget,
        step = (parseFloat(rng.step) || 1.0) * Math.sign(-1 * ev.originalEvent.deltaY);
    rng.value = Math.clamped(parseFloat(rng.value) + step, parseFloat(rng.min), parseFloat(rng.max));
    rng.dispatchEvent(new Event('change'));
  })
});

/**
 * A common framework for displaying notifications to the client.
 * Submitted notifications are added to a queue, and up to 3 notifications are displayed at once.
 * Each notification is displayed for 5 seconds at which point further notifications are pulled from the queue.
 *
 * @extends {Application}
 *
 * @example
 * ui.notifications.info("This is an info message");
 * ui.notifications.warn("This is a warning message");
 * ui.notifications.error("This is an error message");
 * ui.notifications.info("This is a 4th message which will not be shown until the first info message is done");
 */
class Notifications extends Application {
  constructor(options) {
    super(options);

    /**
     * Submitted notifications which are queued for display
     * @type {Array}
     */
    this.queue = [];

    /**
     * Notifications which are currently displayed
     * @type {Array}
     */
    this.active = [];

    this.initialize();
  }

	/* -------------------------------------------- */

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
      popOut: false,
      id: "notifications",
      template: "templates/hud/notifications.html"
    });
  }

	/* -------------------------------------------- */

  initialize() {
    if ( !MESSAGES ) return;
    for ( let m of MESSAGES ) {
      this.notify(m.message, m.type);
    }
  }

	/* -------------------------------------------- */

  async _render(...args) {
    await super._render(...args);
    while ( this.queue.length ) this.fetch();
  }

	/* -------------------------------------------- */

  /**
   * Push a new notification into the queue
   * @param {String} message    The content of the notification message
   * @param {String} type       The type of notification, currently "info", "warning", and "error" are supported
   */
  notify(message, type) {

    // Construct notification data
    let n = {
      message: message,
      type: ["info", "warning", "error"].includes(type) ? type : "info",
      timestamp: new Date().getTime()
    };
    this.queue.push(n);

    // Call the fetch method
    if ( this.rendered ) this.fetch();
  }

	/* -------------------------------------------- */

  /**
   * Display a notification with the "info" type
   * @param {String} message    The content of the notification message
   */
	info(message) {
	  this.notify(message, "info");
  }

	/* -------------------------------------------- */

  /**
   * Display a notification with the "warning" type
   * @param {String} message    The content of the notification message
   */
  warn(message) {
	  this.notify(message, "warning");
  }

	/* -------------------------------------------- */

  /**
   * Display a notification with the "error" type
   * @param {String} message    The content of the notification message
   */
  error(message) {
	  this.notify(message, "error");
  }

	/* -------------------------------------------- */

  /**
   * Retrieve a pending notification from the queue and display it
   * @private
   * @return {Promise}
   */
	async fetch() {
	  if ( this.queue.length === 0 || this.active.length >= 3 ) return;
    const next = this.queue.pop();
    const now = Date.now();
    let cleared = false;

    // Define the function to remove the notification
    const _remove = li => {
      if ( cleared ) return;
      li.fadeOut(66, () => li.remove());
      this.active.pop();
      this.fetch();
    };

    // Construct a new notification
    const li = $(`<li class="notification ${next.type}">${next.message}</li>`);
    li.click(ev => {
      if ( Date.now() - now > 250 ) _remove(li)
    });
	  this.element.prepend(li);
	  li.hide().slideDown(132);
	  this.active.push(li);

	  // Schedule clearing the notification 5 seconds later
	  window.setTimeout(() => _remove(li), 5000);
  }
}

/**
 * A helper class for creating tabbed containers.
 * Create one Tabs instance per tabbed navigation container in your application.
 *
 * @example
 * <!-- Example HTML -->
 * <nav class="tabs" data-group="group1">
 *  <a class="item" data-tab="tab1">Tab 1</li>
 *  <a class="item" data-tab="tab2">Tab 2</li>
 * </nav>
 *
 * <div class="tab" data-tab="tab1" data-group="group1">Content 1</div>
 * <div class="tab" data-tab="tab2" data-group="group1">Content 2</div>
 *
 * @example
 * // JavaScript Listener
 * let nav = $('.tabs[data-group="group1"]');
 * new Tabs(nav, {
 *   initial: "tab1",
 *   callback: t => console.log("Tab ${t} was clicked")
 * });
 *
 * @param tabs {HTMLElement|JQuery} An HTML element or JQuery object representing the tab navigation container.
 */
class Tabs {
  constructor(tabs, {initial, callback, container=null} = {}) {
    console.warn(`The Tabs class is deprecated in favor of the new TabsV2 interface and will be removed in 0.6.0.`);

    /**
     * The collection of tabs
     * @type {jQuery}
     */
    this.tabs = (tabs instanceof jQuery) ? tabs : $(tabs);

    /**
     * The callback function to trigger when a Tab is activated
     * @type {Function}
     */
    this.callback = ( callback instanceof Function ) ? callback : null;

    /**
     * The container element within which both the tab navigation and the tab content exists
     */
    this.container = (container instanceof jQuery) ? container : this.tabs.parents(".app");

    /**
     * The currently active tab name
     * @type {string}
     */
    this.tab = initial;

    /**
     * The currently active tab element
     * @type {jQuery}
     */
    this.active = initial ? this.tabs.children(`[data-tab="${initial}"]`) : this.tabs.children('.active');

    // Initialize the currently active tab
    if ( this.active.length === 0 ) this.active = this.tabs.children().first();
    this.activateTab(this.active);

    // Register event listeners
    this.tabs.on("click", ".item", event => {
      event.preventDefault();
      let tab = $(event.currentTarget);
      this.activateTab(tab);
      if ( this.callback ) this.callback(tab);
    });
  }

  /* -------------------------------------------- */

  /**
   * The named tab group
   * Retrieved as a property since the composition of the DOM may change over time
   * @type {jQuery}
   */
  get group() {
    let tabGroup = this.tabs.attr("data-group");
    if ( tabGroup ) return this.container.find(`.tab[data-group="${tabGroup}"]`);
    else return this.tabs.siblings(".tab[data-tab]");
  }

  /* -------------------------------------------- */

  /**
   * Activate a tab by it's name. This gets called automatically when a tab in the navigation is clicked,
   * however you may also call this function directly.
   *
   * @param {jQuery} tab     The tab control being activated
   */
  activateTab(tab) {

    // If only a name was provided, get the tab
    if ( typeof tab === "string" ) {
      tab = this.tabs.find(`[data-tab="${tab}"]`);
    } if ( !tab ) return;

    // Flag tab as active
    tab.siblings().removeClass('active');
    tab.addClass('active');
    tab.show();

    // Show the content
    this.group.removeClass('active');
    this.group.filter(`[data-tab="${tab.attr("data-tab")}"]`).addClass('active');

    // Assign references
    this.tab = tab.data("tab");
    this.active = tab;
  }
}

/**
 * A controller class for managing tabbed navigation within an Application instance.
 * @see {@link Application}
 *
 * @param {string} navSelector      The CSS selector used to target the navigation element for these tabs
 * @param {string} contentSelector  The CSS selector used to target the content container for these tabs
 * @param {string} initial          The tab name of the initially active tab
 * @param {Function|null} callback  An optional callback function that executes when the active tab is changed
 *
 * @example
 * <!-- Example HTML -->
 * <nav class="tabs">
 *   <a class="item" data-tab="tab1">Tab 1</li>
 *   <a class="item" data-tab="tab2">Tab 2</li>
 * </nav>
 *
 * <section class="content">
 *   <div class="tab" data-tab="tab1">Content 1</div>
 *   <div class="tab" data-tab="tab2">Content 2</div>
 * </section>
 *
 * @example
 * // JavaScript
 * const tabs = new TabsV2({navSelector: ".tabs", contentSelector: ".content", initial: "tab1"});
 * tabs.bind(html);
 *
 */
class TabsV2 {
  constructor({navSelector, contentSelector, initial, callback}={}) {

    /**
     * The value of the active tab
     * @type {string}
     */
    this.active = initial;

    /**
     * A callback function to trigger when the tab is changed
     * @type {Function|null}
     */
    this.callback = callback;

    /**
     * The CSS selector used to target the tab navigation element
     * @type {string}
     */
    this._navSelector = navSelector;

    /**
     * A reference to the HTML navigation element the tab controller is bound to
     * @type {HTMLElement|null}
     */
    this._nav = null;

    /**
     * The CSS selector used to target the tab content element
     * @type {string}
     */
    this._contentSelector = contentSelector;

    /**
     * A reference to the HTML container element of the tab content
     * @type {HTMLElement|null}
     */
    this._content = null;
  }

  /* -------------------------------------------- */

  /**
   * Bind the Tabs controller to an HTML application
   * @param {HTMLElement} html
   */
  bind(html) {

    // Identify navigation element
    this._nav = html.querySelector(this._navSelector);

    // Identify content container
    if ( !this._contentSelector ) this._content = null;
    else if ( html.matches(this._contentSelector )) this._content = html;
    else this._content = html.querySelector(this._contentSelector);

    // Initialize the active tab
    this.activate(this.active);

    // Register listeners
    this._nav.addEventListener("click", this._onClickNav.bind(this))
  }

  /* -------------------------------------------- */

  /**
   * Activate a new tab by name
   * @param {string} tabName
   * @param {boolean} triggerCallback
   */
  activate(tabName, {triggerCallback=false}={}) {

    // Validate the requested tab name
    const items = this._nav.querySelectorAll(".item");
    if ( !items.length ) return;
    const valid = Array.from(items).some(i => i.dataset.tab === tabName);
    if ( !valid ) tabName = items[0].dataset.tab;

    // Change active tab
    for ( let i of items ) {
      i.classList.toggle("active", i.dataset.tab === tabName);
    }

    // Change active content
    if ( this._content ) {
      const tabs = this._content.querySelectorAll(".tab");
      for ( let t of tabs ) {
        t.classList.toggle("active", t.dataset.tab === tabName);
      }
    }

    // Store the active tab
    this.active = tabName;

    // Optionally trigger the callback function
    if ( triggerCallback ) this.callback(null, this, tabName);
  }

  /* -------------------------------------------- */

  /**
   * Handle click events on the tab navigation entries
   * @param {MouseEvent} event    A left click event
   * @private
   */
  _onClickNav(event) {
    const tab = event.target.closest(".item");
    if ( !tab ) return;
    event.preventDefault();
    const tabName = tab.dataset.tab;
    if ( tabName !== this.active) this.activate(tabName, {triggerCallback: true});
  }
}
/**
 * WebRTC Handler
 * Does the basic linking between the WebRTCClient and the UI/events as well as most WebRTC related logic.
 *
 * @param {WebRTCSettings} settings   The WebRTC Settings object to use
 */
class WebRTC {
  constructor(settings) {
    /**
     * Configuration for all settings used by the WebRTC Implementation
     * @type {WebRTCSettings}
     */
    this.settings = settings;

    /**
     * Configuration sheet for the Audio/Video settings
     * @type {AVConfig}
     */
    this.config = new AVConfig(this);

    /**
     * WebRTC Implementation to use for all signalling and connecting logic
     * Must implement the WebRTCInterface interface
     * @type {WebRTCInterface}
     */
    this.client = new CONFIG.WebRTC.clientClass(this, this.settings);

    /**
     * Flag to determine if we are connected to the signalling server or not
     * Required for synchronization between connection and reconnection attempts
     * and to know if disconnection events are due to loss of connection or
     * @type {Boolean}
     */
    this._connected = false;

    /**
     * Object to keep track of which users are speaking and their volume histories
     * Format is :
     * {id :
     *   {
     *     speaking: Boolean
     *     volumeHistories: Array.Number
     *   }
     * }
     * @type {Object}
     */
    this._speakingData = {};

    /**
     * Push-To-Talk handlers
     * Key Event handlers for push to talk need to be bound and cannot be anonymous functions
     * if we want to use removeEventListener. The `.bind` returns a new function every time so we
     * cannot use them as is with addEventListener. We save bound copies of the event handler so we
     * can add/remove them as needed in case voice detection modes are modified.
     * @type {Object.<Function>}
     */
    this._pttHandlers = {};

    /**
     * Push-To-Talk timeout ID for muting
     * When using Push-To-Talk, we need to delay the disablement of our microphone after the user releases
     * their PTT key. But if they press it again, we need to cancel the timeout, so we have to save its ID here
     * @type {Number}
     */
    this._pttMuteTimeout = 0;

    /**
     * Cached setting on whether we are broadcasting our audio
     * In case we unmute our microphone and we're in PTT mode, we need to know if we are to 
     * broadcast our audio to every user or not.
     * @type {Boolean}
     */
    this._broadcastingAudio = false;

    /**
     * Define the amount of time in milliseconds allowed to elapse once connectivity is lost
     * @type {number}
     * @private
     */
    this._reconnectPeriodMS = 5000;
  }

  /* -------------------------------------------- */

  /**
   * Initialize the WebRTC implementation.
   * After initialization, will automatically connect and set up the calls
   */
  async initialize() {
    await this.client.initialize();
    return this.connect(this);
  }

  /* -------------------------------------------- */

  /**
   * Connect to the WebRTC server and initialize our local stream
   * If connection fails, will release capture resources.
   */
  async connect() {
    if (this.settings.mode === WebRTCSettings.WEBRTC_MODE.DISABLED) return false;

    // Set initial connection options
    const isCustom = (this.settings.serverType === "custom") && (this.settings.serverUrl !== '');
    let connectionOptions = isCustom ? {
      host: this.settings.serverUrl,
      room: this.settings.serverRoom,
      username: this.settings.serverUsername,
      password: this.settings.serverPassword,
    } : {
      host: null,
      room: null,
      sessionId: game.sessionId
    };

    // Disconnect from any existing session
    await this.disconnect();

    // Connect to the session
    this.debug("Connecting to Signalling Server");
    const connected = await this.client.connect(connectionOptions);
    if (connected) {
      Hooks.callAll("rtcClientConnected", this);
      await this._initLocalStream();
    } else {
      ui.notifications.error(game.i18n.localize("WEBRTC.ConnectError"));
      await this._closeLocalStream();
    }
    return this._connected = connected;
  }

  /* -------------------------------------------- */

  /**
   * Disconnect from the WebRTC server and close our local stream.
   * Prevent the disconnection from appearing as if it was a lost connection.
   * Signal the disconnection only if we had been connected.
   * @return {Boolean}     Returns whether there was a valid connection that was terminated
   */
  async disconnect() {
    const wasConnected = this._connected;
    this._closeLocalStream();
    this._connected = false;
    // Always call disconnect in case there was an in-progress connection attempt
    await this.client.disconnect();
    if (wasConnected) {
      this.debug("Disconnected from Signalling Server");
      Hooks.callAll("rtcClientDisconnected", this);
    }
    return wasConnected;
  }

  /* -------------------------------------------- */

  /**
   * Initialize local stream according to A/V mode and user settings.
   * Tries to fallback to using audio only or video only if capturing both fails.
   */
  async _initLocalStream() {
    const settings = this.settings;
    if (settings.mode === WebRTCSettings.WEBRTC_MODE.DISABLED) return this._closeLocalStream();

    // Define Video Source
    let videoSrc = settings.videoSrc;
    if ((settings.mode === WebRTCSettings.WEBRTC_MODE.AUDIO) || !settings.users[game.user.id].canBroadcastVideo) {
      videoSrc = null;
    }

    // Define Audio Source
    let audioSrc = settings.audioSrc;
    if ((settings.mode === WebRTCSettings.WEBRTC_MODE.VIDEO) || !settings.users[game.user.id].canBroadcastAudio) {
      audioSrc = null;
    }

    // Get the local stream
    let stream = await this.client.initLocalStream(audioSrc, videoSrc);

    // In case of failure attempting to capture A/V, try to capture audio only or video only
    if (!stream && audioSrc && videoSrc) {
      let capturedOnly = 'Audio';
      stream = await this.client.initLocalStream(audioSrc, null); // try without video first
      if (!stream) { // if it fails, try video only
        capturedOnly = 'Video';
        stream = await this.client.initLocalStream(null, videoSrc);
      }
      if (stream)
        ui.notifications.warn(game.i18n.localize(`WEBRTC.CaptureWarning${capturedOnly}`));
    }

    // If we successfully began a stream
    if (stream) {
      this.debug("Success in initializing media source : ", stream);
      this.setVideoStream(game.user.id, stream);
      this._setupVoiceDetection(stream);
    }

    // If we were not successful
    else {
      let error_type = null;
      if (audioSrc && videoSrc)
        error_type = "AudioVideo";
      else if (audioSrc)
        error_type = "Audio";
      else if (videoSrc)
        error_type = "Video";
      if (error_type)
        ui.notifications.error(game.i18n.localize(`WEBRTC.CaptureError${error_type}`));
    }

    // Refresh the UI now that we have (or not) a local stream
    ui.webrtc.render();
    return stream;
  }

  /* -------------------------------------------- */

  /**
   * Closes our local stream.
   * Stop listening to audio levels from our existing stream, then close
   */
  async _closeLocalStream() {
    await this.client.closeLocalStream();
    this._stopVoiceDetection();
    ui.webrtc.render();
    return null;
  }

  /* -------------------------------------------- */
  /*  UI Control Methods                          */
  /* -------------------------------------------- */

  /**
   * Connects a stream to a video element
   * @param {String} userId         The ID of the user to whom the stream belongs to
   * @param {MediaStream} stream    The media stream to set for the user. Can be `null` if user has no stream
   */
  setVideoStream(userId, stream) {
    const videoElement = ui.webrtc.getUserVideoElement(userId);
    if (videoElement) {
      this.client.assignStreamToVideo(stream, videoElement);
      this.client.setAudioOutput(videoElement, this.settings.audioSink);
      videoElement.dispatchEvent(new CustomEvent('srcObjectSet', { detail: stream }));
    }
  }

  /* -------------------------------------------- */

  /**
   * Renders the webrtc streams.
   * This should be called by the UI after it's done rendering, so the webrtc implementation
   * can set all video elements to the appropriate streams
   */
  render() {
    let streams = this.client.getConnectedStreams();
    const sd = this._speakingData;
    let isSpeaking = sd[game.user.id] ? sd[game.user.id].speaking : false;
    this.setVideoStream(game.user.id, this.client.getStreamForUser(game.user.id));
    ui.webrtc.setUserIsSpeaking(game.user.id, isSpeaking);
    for (let info of streams) {
      isSpeaking = sd[info.id] ? sd[info.id].speaking : false;
      this.setVideoStream(info.id, info.remote);
      ui.webrtc.setUserIsSpeaking(info.id, isSpeaking);
    }
  }

  /* -------------------------------------------- */
  /*  Stream Related methods                      */
  /* -------------------------------------------- */

  /**
   * Checks whether a stream has any video tracks
   * @param {MediaStream} stream    The stream to check
   * @return {boolean}
   */
  streamHasVideo(stream) {
    return (stream != null) && (stream.getVideoTracks().length > 0);
  }

  /* -------------------------------------------- */

  /**
   * Checks whether a stream has any audio tracks
   * @param {MediaStream} stream    The stream to check
   * @return {boolean}
   */
  streamHasAudio(stream) {
    return (stream != null) && (stream.getAudioTracks().length > 0);
  }

  /* -------------------------------------------- */

  /**
   * Enables or disables media tracks
   * See https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/enabled
   * @param {Array} tracks          The tracks to enable/disable
   * @param {boolean} enable        Whether to enable or disable the tracks
   */
  _enableMediaTracks(tracks, enable) {
    for (let track of tracks || [])
      track.enabled = enable;
  }

  /* -------------------------------------------- */

  /**
   * Enable or disable the video tracks in a stream
   *
   * Disabling a track represents what a typical user would consider muting it. We use the term 'enable' here instead
   * of 'mute' to match the MediaStreamTrack field name and to avoid confusion with the 'muted' read-only field of the
   * MediaStreamTrack as well as the video element's `muted` field which only stops playing the audio.
   *
   * Muting by definition stops rendering any of the data, while a disabled track in this case is still rendering its
   * data, but is simply generating disabled content (silence and black frames).
   *
   * See https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/enabled
   *
   * @param {MediaStream} stream    The stream to modify
   * @param {boolean} enable        (optional) Whether to enable or disable the tracks
   */
  enableStreamVideo(stream, enable = true) {
    if (stream) this._enableMediaTracks(stream.getVideoTracks(), enable);
  }

  /* -------------------------------------------- */

  /**
   * Disables the video tracks in a stream
   * @param {MediaStream} stream    The stream to modify
   */
  disableStreamVideo(stream) {
    return this.enableStreamVideo(stream, false);
  }

  /* -------------------------------------------- */

  /**
   * Checks if a stream has any video tracks enabled
   * @param {MediaStream} stream    The stream to check
   * @return {boolean}
   */
  isStreamVideoEnabled(stream) {
    const tracks = stream ? stream.getVideoTracks() : [];
    return tracks.some(t => t.enabled);
  }

  /* -------------------------------------------- */

  /**
   * Enable or disable the audio tracks in a stream
   *
   * Disabling a track represents what a typical user would consider muting it.
   * We use the term 'enable' here instead of 'mute' to match the MediaStreamTrack
   * field name and to avoid confusion with the 'muted' read-only field of the MediaStreamTrack
   * as well as the video element's `muted` field which only stops playing the audio.
   * Muting by definition stops rendering any of the data, while a disabled track in this case
   * is still rendering its data, but is simply generating disabled content (silence and black frames)
   * See https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/enabled
   *
   * @param {MediaStream} stream    The stream to modify
   * @param {boolean} enable        (optional) Whether to enable or disable the tracks
   */
  enableStreamAudio(stream, enable=true) {
    if (stream) this._enableMediaTracks(stream.getAudioTracks(), enable);
  }

  /* -------------------------------------------- */

  /**
   * Disables the audio tracks in a stream
   * @param {MediaStream} stream    The stream to modify
   */
  disableStreamAudio(stream) {
    return this.enableStreamAudio(stream, false);
  }

  /* -------------------------------------------- */

  /**
   * Checks if a stream has any audio tracks enabled
   * @param {MediaStream} stream    The stream to check
   * @return {Boolean}
   */
  isStreamAudioEnabled(stream) {
    const tracks = stream ? stream.getAudioTracks() : [];
    return tracks.some(t => t.enabled);
  }

  /* -------------------------------------------- */

  /**
   * Enables or disables the sending of our own video camera capture to
   * all users.
   * @param {boolean} enable    (optional) Whether to enable camera or not
   */
  enableCamera(enable = true) {
    let streamInfos = this.client.getConnectedStreams();
    let stream = this.client.getStreamForUser(game.user.id);
    // Enable/Disable the master stream so it affects new users joining in
    this.enableStreamVideo(stream, enable);
    // Enable/Disable the individual local streams on each connected calls
    for (let info of streamInfos)
      this.enableStreamVideo(info.local, enable);
  }

  /* -------------------------------------------- */

  /**
   * Disables the sending of our own video camera capture to all users.
   */
  disableCamera() {
    return this.enableCamera(false);
  }

  /* -------------------------------------------- */

  /**
   * Enables or disables the sending of our own audio capture to all users.
   * Enable/Disable the master stream so it affects new users joining in.
   * Also Enable/Disable the individual local streams on each connected calls.
   * @param {Boolean} enable    (optional) Whether to enable the microphone or not
   */
  enableMicrophone(enable = true) {
    const stream = this.client.getStreamForUser(game.user.id);
    this.enableStreamAudio(stream, enable);
    this.broadcastMicrophone(enable && this._broadcastingAudio);
  }

  /* -------------------------------------------- */

  /**
   * Disables the sending of our own audio capture to all users.
   */
  disableMicrophone() {
    return this.enableMicrophone(false);
  }

  /* -------------------------------------------- */

  /**
   * Enable or disable the broadcasting of our own microphone to the other users
   * without changing the enabled state of the master stream.
   * This is to be used with push-to-talk or voice activity detection
   * methods.
   * 
   * @param {Boolean} broadcast    Whether to broadcast our microphone to the other users or not
   */
  broadcastMicrophone(broadcast) {
    const streamInfos = this.client.getConnectedStreams();
    for (let info of streamInfos)
      this.enableStreamAudio(info.local, broadcast);
  }

  /* -------------------------------------------- */

  /**
   * Setup Voice detection for our local stream.
   * Depending on the settings, this will either add an audio level listener or key/mouse event listeners for
   * push-to-talk handling.
   *
   * Note that if the microphone is disabled (muted), then we never broadcast our audio regardless of the voice
   * detection algorithm in use.
   *
   * Valid voice detection modes are : 
   * - "always": Broadcasting of audio is always on
   * - "activity": Broadcasting only if audio level above a threshold
   * - "ptt": Broadcasting while a keyboard or mouse button is pressed
   */
  _setupVoiceDetection(stream) {
    this._stopVoiceDetection();
    if (!this.streamHasAudio(stream)) return;

    const mode = this.settings.voiceMode;
    if ( ["always", "activity"].includes(mode) ) {
      const audioLevelHandler = this._onAudioLevel.bind(this, game.user.id);
      game.audio.startLevelReports(game.user.id, stream, audioLevelHandler, CONFIG.WebRTC.emitVolumeInterval);
      this._resetSpeakingHistory(game.user.id);
    }

    if (mode === "ptt") {
      this._pttBroadcast(stream, false);
    } else if (mode === "always") {
      const audioEnabled = this.isStreamAudioEnabled(stream);
      this._broadcastingAudio = true;
      this.broadcastMicrophone(audioEnabled);
    } else if (mode === "activity") {
      this._broadcastingAudio = false;
      this.broadcastMicrophone(false);
    }
  

    if (this.settings.voicePttMouse) {
      this._pttHandlers = {
        mousedown: this._onPTTMouseDown.bind(this),
        mouseup: this._onPTTMouseUp.bind(this),
      };
    } else {
      this._pttHandlers = {
        keydown: this._onPTTKeyDown.bind(this),
        keyup: this._onPTTKeyUp.bind(this),
      };
    }
    for (let [event, handler] of Object.entries(this._pttHandlers))
      window.addEventListener(event, handler, { capture: true });
  }

  /**
   * Stop listening to local stream for voice detection and push to talk
   */
  _stopVoiceDetection() {
    game.audio.stopLevelReports(game.user.id);
    this._resetSpeakingHistory(game.user.id);
    for (let [event, handler] of Object.entries(this._pttHandlers))
      window.removeEventListener(event, handler, { capture: true });
    this._pttHandlers = {}
  }

  /**
   * Push the Push-To-Talk trigger
   * This will enable or disable broadcasting if we are not muted depending on
   * whether we are in push-to-talk or push-to-mute
   */
  _pttPush() {
    if (this.settings.voiceMode === "ptt") {
      const stream = this.client.getStreamForUser(game.user.id);
      if (this._pttMuteTimeout > 0) clearTimeout(this._pttMuteTimeout);
      this._pttMuteTimeout = 0;
      this._pttBroadcast(stream, true);
    }
    
    // Always and Voice activity will use ptt trigger as a push-to-mute
    else {
      const timeoutCallback = () => this.enableMicrophone(false);
      this._pttMuteTimeout = setTimeout(timeoutCallback, this.settings.voicePttDelay);
    }
  }

  /**
   * Release the Push-To-Talk trigger
   * After the configured delay, the 
   */
  _pttRelease() {   
    if (this.settings.voiceMode === "ptt") {
      const stream = this.client.getStreamForUser(game.user.id);
      const timeoutCallback = () => this._pttBroadcast(stream, false);
      this._pttMuteTimeout = setTimeout(timeoutCallback, this.settings.voicePttDelay);
    }
    
    // Always and voice activity need to unmute right away when triggered
    else {
      if (this._pttMuteTimeout > 0) clearTimeout(this._pttMuteTimeout);
      this._pttMuteTimeout = 0;
      this.enableMicrophone(true);
    }
  }

  /**
   * Starts or stops broadcasting audio when in Push-to-talk mode
   * If our local stream is muted, then it will set to broadcasting mode without
   * actually broadcasting audio
   * @param {MediaStream} stream     Our local stream
   * @param {Boolean} broadcast      Whether to broadcast or not
   */
  _pttBroadcast(stream, broadcast) {
    this._broadcastingAudio = broadcast;
    ui.webrtc.setUserIsSpeaking(game.user.id, this._broadcastingAudio);
    this.broadcastMicrophone(this.isStreamAudioEnabled(stream) && this._broadcastingAudio);

  }

  /* -------------------------------------------- */
  /*  Event handlers                              */
  /* -------------------------------------------- */

  /**
   * Notify of changes to the webrtc related settings.
   * Handle settings changes in order of importance. If the mode changed, we reload the page and can ignore any other
   * setting change and if the server changed and we need to reconnect, we can ignore anything that has to do with the
   * our own stream since we'll recreate it or with the other user's settings.
   *
   * @param {Object} changed      Object of {key: value} of all changed settings
   */
  onSettingsChanged(changed) {
    const keys = Object.keys(changed);

    // Change the server mode
    if (keys.includes('mode'))
      return window.location.reload();

    // Change the server configuration
    if (keys.includes('server'))
      return this.connect();

    // Change audio or video sources
    if (keys.some(k => ['videoSrc', 'audioSrc'].includes(k)) ||
      hasProperty(changed, `users.${game.user.id}.canBroadcastVideo`) ||
      hasProperty(changed, `users.${game.user.id}.canBroadcastAudio`)) {
      this._initLocalStream();
    }
    else if (keys.includes('voice'))
      this._setupVoiceDetection(this.client.getStreamForUser(game.user.id));

    // Change audio output sink
    if (keys.includes('audioSink'))
      this.render();

    // Change connected users
    if (keys.includes('users')) {
      let streamInfo = null;
      for ( let [userId, user] of Object.entries(changed.users) ) {
        const userKeys = Object.keys(user);

        // If peer has new permissions, we expect them to re-init their local stream on their own, but just in case
        // audio or video has been blocked and the peer is cheating via some module that forces their video on, then
        // we can remove their tracks immediately.
        if ((userId !== game.user.id) && userKeys.some(k => ["canBroadcastVideo", "canBroadcastAudio"].includes(k))) {
          streamInfo = streamInfo === null ? this.client.getConnectedStreams() : streamInfo;
          const info = streamInfo.find(i => i.id === userId);
          if (info && info.remote) this.onUserStreamChange(userId, info.remote);
        }
      }
    }

    // Call client specific setting handling
    this.client.onSettingsChanged(changed);
  }

  /* -------------------------------------------- */

  /**
   * Notify a change of a user's stream
   * @param {string} userId         The ID of the user whose stream has changed.
   * @param {MediaStream} stream    The new stream of the user, or null if  the user is not sending data anymore.
   */
  onUserStreamChange(userId, stream) {
    Hooks.call("rtcUserStreamChange", this, { userId, stream });
    this.setVideoStream(userId, stream);
    const userSettings = this.settings.users[userId];

    // Remove video or audio tracks that the user does not have permission to be sharing
    if (stream) {
      if (!userSettings.canBroadcastAudio) stream.getAudioTracks().forEach(track => stream.removeTrack(track));
      if (!userSettings.canBroadcastVideo) stream.getVideoTracks().forEach(track => stream.removeTrack(track));
    }

    // Start/stop listening to stream audio levels depending on whether the stream (streamHasAudio is null safe) has audio tracks or not
    if (this.streamHasAudio(stream)) {
      const audioLevelHandler = this._onAudioLevel.bind(this, userId);
      game.audio.startLevelReports(userId, stream, audioLevelHandler, CONFIG.WebRTC.emitVolumeInterval);
    }
    else game.audio.stopLevelReports(userId);
    this._resetSpeakingHistory(userId);

    // Disable stream components if muted or hidden
    if (userSettings.muted) this.disableStreamAudio(stream);
    if (userSettings.hidden) this.disableStreamVideo(stream);

    // Render the AV interface
    ui.webrtc.render();
  }

  /* -------------------------------------------- */

  /**
   * Notify when a new local stream is created for a user's call
   * When the client creates a new local stream to share with a user, this method is
   * called to notify the implementation of it.
   * On voice activity mode, all user's local streams are disabled unless audio is above threshold, but
   * the master stream is not disabled so we can detect our audio level. New users joining would get a copy
   * of the master stream's tracks which would therefore be enabled, even if voice activity is below threshold
   * so we need to disable them if our last voice level events were inactive.
   * 
   * @param {String} userId        ID of the user for whom this stream was created
   * @param {MediaStream} stream   Our new local stream
   */
  onLocalStreamCreated(userId, stream) {
    if (!this.streamHasAudio(stream)) return;
    const masterStream = this.client.getStreamForUser(game.user.id);
    this.enableStreamAudio(stream, this.isStreamAudioEnabled(masterStream) && this._broadcastingAudio);
  }

  /* -------------------------------------------- */

  /**
   * Notify of an error from the webrtc library
   * @param {string} error   The error string
   */
  onError(error) {
    console.error(game.i18n.localize("WEBRTC.GenericError") + error)
  }

  /* -------------------------------------------- */

  /**
   * Notify of a disconnection with the server
   * Only consider it a connection loss if we were supposed to be still connected
   */
  async onDisconnect() {
    if (this._connected) {
      ui.notifications.warn(game.i18n.localize("WEBRTC.ConnectionLostWarning"));
      await this.disconnect();
      while (!this._connected) {
        await new Promise((resolve) => setTimeout(() => resolve(), this._reconnectPeriodMS));
        if (!this._connected) await this.connect();
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Resets the speaking history of a user
   * If the user was considered speaking, then mark them as not speaking
   * @param {string} userId         The ID of the user
   */
  _resetSpeakingHistory(userId) {
    ui.webrtc.setUserIsSpeaking(userId, false);
    delete this._speakingData[game.user.id];
  }

  /* -------------------------------------------- */

  /**
   * Periodic notification of user audio level
   *
   * This function uses the audio level (in dB) of each stream it's listening to to determine if a user
   * is speaking or not and notifies the UI of such changes.
   *
   * The User is considered speaking if they are above the decibel threshold in any of the history values.
   * This marks them as speaking as soon as they have a high enough volume, and marks them as not speaking only after
   * they drop below the threshold in all histories (last 4 volumes = for 200 ms).
   *
   * There can be more optimal ways to do this and which uses whether the user was already considered speaking before
   * or not, in order to eliminate short bursts of audio (coughing for example).
   *
   * @param {String} userId          The user ID of the user whose audio levels are being reported
   * @param {Number} dbLevel         The audio level in decibels of the user within the last 50ms
   * @private
   */
  _onAudioLevel(userId, dbLevel) {
    if ( !this._speakingData.hasOwnProperty(userId) ) {
      this._speakingData[userId] = { speaking: false, volumeHistories: [] };
    }
    const speakingData = this._speakingData[userId];

    // Add the current volume to the history of the user and keep the list below the history length config.
    if (speakingData.volumeHistories.push(dbLevel) > CONFIG.WebRTC.speakingHistoryLength)
      speakingData.volumeHistories.shift();

    // Count the number and total decibels of speaking events which exceed an activity threshold
    const threshold = this.settings.voiceActivityThreshold;
    const [count, max, total] = speakingData.volumeHistories.reduce((totals, vol) => {
      if ( vol >= threshold )  {
        totals[0] += 1;
        totals[1] = Math.min(totals[1], vol);
        totals[2] += vol;
      }
      return totals;
    }, [0, 0, 0]);

    const wasSpeaking = speakingData.speaking;
    const isSpeaking = wasSpeaking ? (count > 0) : (count >= CONFIG.WebRTC.speakingThresholdEvents);
    if (isSpeaking !== wasSpeaking) {
      ui.webrtc.setUserIsSpeaking(userId, isSpeaking);
      if ((userId === game.user.id) && (this.settings.voiceMode === "activity")) {
        this.broadcastMicrophone(isSpeaking);
      }
    }
    speakingData.speaking = isSpeaking;
  }

  /* -------------------------------------------- */

  /**
   * Handle Key Down event for Push-To-Talk
   * When the right key is pressed, prevents the event from being propagated and pushes the PTT
   * @event {KeyEvent} event   The original keydown event
   */
  _onPTTKeyDown(event) {
    if (event.keyCode !== this.settings.voicePttKey) return;
    event.preventDefault();
    if (event.repeat) return;
    this._pttPush();
  }

  /* -------------------------------------------- */

  /**
   * Handle Key Up event for Push-To-Talk
   * When the right key is released, prevents the event from being propagated and releases the PTT
   * @event {KeyEvent} event   The original keyup event
   */
  _onPTTKeyUp(event) {
    if (event.keyCode !== this.settings.voicePttKey) return;
    event.preventDefault();
    this._pttRelease();
  }

  /* -------------------------------------------- */

  /**
   * Handle Mouse Down event for Push-To-Talk
   * Pushes the PTT when the right button is pressed
   * @event {MouseEvent} event   The original mousedown event
   */
  _onPTTMouseDown(event) {
    if (event.button !== this.settings.voicePttKey) return;
    this._pttPush();
  }

  /* -------------------------------------------- */

  /**
   * Handle Mouse Up event for Push-To-Talk
   * Releases the PTT when the right button is released
   * @event {MouseEvent} event   The original mouseup event
   */
  _onPTTMouseUp(event) {
    if (event.button !== this.settings.voicePttKey) return;
    this._pttRelease();
  }

  /* -------------------------------------------- */

  /**
   * Display debug messages on the console if debugging is enabled
   * @param {...*} args      Arguments to console.log
   */
  debug(...args) {
    if (this.settings.debug)
      console.log("RTC : ", ...args);
  }
}
/**
 * WebRTC Implementation Interface which defines the base class for WebRTC Implementations and the interface used by
 * the WebRTC class.
 *
 * Whenever a new remote stream is received by this implementation, a call to `this.webrtc.onUserStreamChange()`
 * will be made to notify of the new or deleted stream.
 *
 * Most functions of this interface will throw not implemented errors. Do not call `super` on any of them.
 *
 * @interface
 * @param {WebRTC} webrtc             The WebRTC object
 * @param {WebRTCSettings} settings   The WebRTC Settings object
 */
class WebRTCInterface {
  constructor(webrtc, settings) {
    this.webrtc = webrtc;
    this.settings = settings;
  }

  /* -------------------------------------------- */

  /**
   * Initialize the WebRTC implementation.
   * This Will only be called once by the main setupGame() initialization function.
   * @return {Promise.boolean}
   */
  async initialize() {
    throw Error("Not Implemented");
  }

  /* -------------------------------------------- */

  /**
   * Connect to the signalling server.
   * Any existing connections will be dropped and any existing calls hung up.
   *
   * Once a connection to the server is established and the user authenticated, join a room and automatically
   * establish a call with other users in that same room.
   *
   * If a local master media stream has been created, add a copy of that stream to every peer.
   *
   * Whether to include audio or video in that cloned stream, or whether to enable or disable the audio and video of
   * the remote stream from that peer will automatically be determined based on the saved settings.
   *
   * This will be called again in case a server configuration changed.
   *
   * @param {string|null} host       Server host address. Set to `null` to use current FVTT server
   * @param {string} room            Name of the room to join on the signalling server
   * @param {string} username        Username to authenticate to the server (if needed)
   * @param {string} password        Password to authenticate the user (if needed)
   * @return {Promise.boolean}       Returns success/failure to connect
   */
  async connect({ host=null, room, username, password} = {}) {
    throw Error("Not Implemented");
  }

  /* -------------------------------------------- */

  /**
   * Disconnect from the signalling server, any existing calls will be terminated.
   * This is also called whenever the server configuration is changed.
   *
   * @return {Promise.boolean}       Returns success/failure to connect
   */
  async disconnect() {
    throw Error("Not Implemented");
  }

  /* -------------------------------------------- */

  /**
   * Initialize a local media stream
   * Capture the local audio and video and returns the stream associated with them.
   *
   * If @temporary is `false` (default), then this will initialize the master stream, not the actual
   * streams being sent to individual users. However, if a master stream was already created, it
   * will automatically get closed and every individual streams derived from it that are being sent
   * to connected users will be removed from the calls.
   * Each established or subsequent calls will receive a copy of the created stream (A/V depending on user permissions)
   *
   * If @temporary is `true` then this only applies to a temporary stream and does not affect
   * the master stream or any streams in existing calls.
   * Note that this assumes only one temporary stream can be created at a time.
   *
   * @param {string|null} audioSrc       ID of the audio source to capture from or null to disable Audio
   * @param {string|null} videoSrc       ID of the video source to capture from or null to disable Video
   * @param {boolean} temporary          Whether to create a temporary stream or the master stream
   * @return {Promise.MediaStream}       Returns the local stream or `null` if none could be created
   */
  async initLocalStream(audioSrc, videoSrc, temporary=false) {
    throw Error("Not Implemented");
  }

  /* -------------------------------------------- */

  /**
   * Closes a local stream
   * If @temporary is `false` (default), the master stream will be destroyed and all local streams removed
   * from any existing calls.
   * If the master stream is closed, any subsequent WebRTC calls will not have any streams sent to the peer.
   * If @temporary is `true`, closes the temporary stream
   *
   * @param {Boolean} temporary          Whether to create a temporary stream or the master stream
   * @return {Promise}
   */
  async closeLocalStream(temporary=false) {
    throw Error("Not Implemented");
  }

  /* -------------------------------------------- */

  /**
   * Retreive the stream for a user
   * Calling this with `game.user.id` is the proper way of retreiving the local master stream
   * without re-initializing it.
   * Any other user will return the remote stream of that user or `null` if no stream could be found.
   *
   * @param {String} userId            ID of the user
   * @return {MediaStream}             The remote stream of the user
   */
  getStreamForUser(userId) {
    throw Error("Not Implemented");
  }

  /* -------------------------------------------- */

  /**
   * Get the list of connected streams
   * The result would be an array of objects in the form of {id, pc, local, remote}
   * where id is the user's ID, pc is the RTCPeerConnection object associated to the peer,
   * local is the local stream added to the call and remote is the remote user's stream
   *
   * @return {Array.Object}
   */
  getConnectedStreams() {
    throw Error("Not Implemented");
  }

  /* -------------------------------------------- */

  /**
   * Assigns a stream to a video element
   *
   * @param {MediaStream} stream        The stream to assign
   * @param {HTMLVideoElement} video    The video element to configure
   */
  assignStreamToVideo(stream, video) {
    throw Error("Not Implemented");
  }

  /* -------------------------------------------- */

  /**
   * Sets the audio output on a video element
   *
   * Note: This feature is not supported by Firefox by default as it depends on the setSinkId
   * API which is available behind the media.setsinkid.enabled settings in Firefox
   * See https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId
   *
   * @param {HTMLVideoElement} video    The video element to configure
   * @param {String} audioSinkId        ID of the audio output device
   */
  setAudioOutput(video, audioSinkId) {
    throw Error("Not Implemented");
  }

  /* -------------------------------------------- */

  /**
   * Get the list of available video sources
   * The expected result is an object with the device id as key and its human-readable label as value
   *
   * @return {Promise.Object}
   */
  async getVideoSources() {
    throw Error("Not Implemented");
  }

  /* -------------------------------------------- */

  /**
   * Get the list of available audio sources
   * The expected result is an object with the device id as key and its human-readable label as value
   *
   * @return {Promise.Object}
   */
  async getAudioSources() {
    throw Error("Not Implemented");
  }

  /* -------------------------------------------- */

  /**
   * Get the list of available audio output devices
   * The expected result is an object with the device id as key and its human-readable label as value
   *
   * Note: This feature is not supported by Firefox by default as it depends on the enumerateDevices
   * API which doesn't list output devices on Firefox 63+ unless the media.setsinkid.enabled settings
   * is enabled.
   * See https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices
   *
   * @return {Promise.Object}
   */
  async getAudioSinks() {
    throw Error("Not Implemented");
  }

  /* -------------------------------------------- */

  /**
   * Notify of settings changes
   * This can be used to act according
   * @param {Object} changed     Object consisting of the changed settings in the form {key: value}
   */
  onSettingsChanged(changed) {
  }
}

/**
 * WebRTC Settings abstraction class
 * This class serves to gather all webrtc related settings from
 * the world settings, client settings and user permissions and provide
 * access to them in a convenient way. It will also send notifications whenever
 * these settings change to make it easier to listen to those changes with a single hook.
 *
 * This class will also dynamically creates getters and setters for all the known webrtc world
 * and client settings and will also allow access to nested object values using a upperCase notation form.
 * There is therefore a `serverUrl` getter which can be used to access the `server.url` webrtc world setting.
 * The automatic creation of these properties allows the use of the settings object in handlebars directly
 * for example, since it's not possible to call `getUserSetting` within a template.
 *
 * The user settings can also be accessed via the `users` getter which returns a Proxy object.
 * The Proxy object will automatically generate object with their own getters and setters so that any
 * user ID being accessed will return the user setting's for that user.
 * As an example, doing `this.users['any-value-at-all'].volume` will return the default volume.
 */
class WebRTCSettings {
  constructor() {
    // Register the settings
    this.registerSettings();

    // Save the client and world settings and make a copy of the old settings so we can check for
    // changes whenever an update occurs

    /**
     * Current client Settings
     * This will contain any changed value via the setters on this class
     * The client settings are all the settings that will only be saved on the client. They are things that
     * should not affect any other player, such as the configured volume of a user, the position of a popout
     * window, or the current client's audio and video device choices.
     * @type {Object}
     * @private
     */
    this._clientSettings = this.clientSettings;

    /**
     * Current world Settings
     * This will contain any changed value via the setters on this class or if the world settings
     * are modified by another user.
     * The world settings are all the settings that affect the world, such as the AV mode, or the server
     * settings.
     * @type {Object}
     * @private
     */
    this._worldSettings = this.worldSettings;

    /**
     * Previous client Settings
     * This contains a saved copy of the client settings from the last onSettingsChanged notification.
     * By comparing the current settings to the old, we can determine which values have changed.
     * @type {Object}
     * @private
     */
    this._previousClientSettings = duplicate(this._clientSettings);

    /**
     * Previous world Settings
     * This contains a saved copy of the world settings from the last onSettingsChanged notification.
     * By comparing the current settings to the old, we can determine which values have changed.
     * @type {Object}
     * @private
     */
    this._previousWorldSettings = duplicate(this._worldSettings);

    /**
     * Previous User entities settings
     * This stores the user settings of all users in this world that are derived from the entity itself.
     * This is used to compare if any of the user settings have changed.
     * We need to store this because we could get notified of user data changes such as their 'role' which
     * could affect (or not) their broadcasting permission, but without storing the old values based on their
     * previous role/permissions, we can't derive which actual keys are affected by the data change.
     * Same applies to the avatar. If a user doesn't have an avatar set and they change their character assignment,
     * the avatar representation for that user will change, but it wouldn't if they have a custom player avatar.
     * @type {Object}
     * @private
     */
    this._previousUsersEntitySettings = this._getAllUsersEntitySettings();

    /**
     * ID of the timeout used to debounce the onSettingsChanged notification
     * We need to debounce the settings changed notification in case multiple settings get set at once,
     * without this we could get a spam of callbacks/hooks when simply moving or resizing a popout window.
     * @type {Number}
     * @private
     */
    this._debounceId = 0;

    // Automatically create the getters and setters for this class.
    this._createProperties();

    // Always ensure there is a random server room
    if ( !this.serverRoom || (this.serverRoom.length < 32) ) this.serverRoom = randomID(32);
  }

  /**
   * Register world and client settings
   */
  registerSettings() {
    game.settings.register("core", "rtcWorldSettings", {
      name: "WebRTC (Audio/Video Conferencing) World Settings",
      scope: "world",
      default: this.constructor.getDefaultWorldSettings(),
      type: Object,
      onChange: () => this.onSettingsChanged()
    });
    game.settings.register("core", "rtcClientSettings", {
      name: "WebRTC (Audio/Video Conferencing) Client specific Configuration",
      scope: "client",
      default: this.constructor.getDefaultClientSettings(),
      type: Object,
      onChange: () => this.onSettingsChanged()
    });

  }

  /* -------------------------------------------- */
  /*  Default settings                            */
  /* -------------------------------------------- */

  /**
   * Return default world settings
   */
  static getDefaultWorldSettings() {
    return {
      mode: WebRTCSettings.WEBRTC_MODE.DISABLED,
      server: {
        type: "FVTT",
        url: "",
        room: "",
        username: "",
        password: ""
      },
      turn: {
        type: "server",
        url: "",
        username: "",
        password: "",
      }
    };
  }
  /**
   * Return default client settings
   */
  static getDefaultClientSettings() {
    return {
      videoSrc: "default",
      audioSrc: "default",
      audioSink: "default",
      dockSize: "medium",
      dockPosition: "bottom",
      hidePlayerList: false,
      muteAll: false,
      voice: {
        mode: WebRTCSettings.VOICE_MODES.PTT,
        pttKey: 192, // Tilde
        pttName: "`",
        pttMouse: false,
        pttDelay: 100,
        activityThreshold: -45
      },
      users: {}
    };
  }

  /**
   * Return default user settings
   */
  static getDefaultUserSettings() {
    return {
      popout: false,
      x: 100,
      y: 100,
      z: 0,
      width: 320,
      volume: 1.0,
      muted: false,
      hidden: false,
      blocked: false
    };
  }

  /* -------------------------------------------- */
  /*  Get/Set APIs                                */
  /* -------------------------------------------- */

  /**
   * Get the value of a webrtc client setting
   * Returns the client setting if set or the default value
   * @param {String} key   The name of the setting
   */
  getClientSetting(key) {
    const value = getProperty(this._clientSettings, key);
    if (value !== undefined) return value;
    return getProperty(this.constructor.getDefaultClientSettings(), key);
  }

  /* -------------------------------------------- */

  /**
   * Set the value of a webrtc client setting
   * Sets the client setting if different from the existing value
   * @param {String} key    The name of the setting
   * @param {Object} value  The new value
   */
  setClientSetting(key, value) {
    if (getProperty(this._clientSettings, key) === value) return;
    setProperty(this._clientSettings, key, value);
    this.clientSettings = this._clientSettings;
  }

  /* -------------------------------------------- */

  /**
   * Get the value of a webrtc world setting
   * Returns the world setting if set or the default value
   * @param {String} key   The name of the setting
   */
  getWorldSetting(key) {
    let value = getProperty(this._worldSettings, key);
    if (value !== undefined) return value;
    return getProperty(this.constructor.getDefaultWorldSettings(), key);
  }

  /* -------------------------------------------- */

  /**
   * Set the value of a webrtc world setting
   * Sets the world setting if different from the existing value
   * @param {String} key    The name of the setting
   * @param {Object} value  The new value
   */
  setWorldSetting(key, value) {
    if (getProperty(this._worldSettings, key) === value) return;
    setProperty(this._worldSettings, key, value);
    this.worldSettings = this._worldSettings;
  }

  /* -------------------------------------------- */

  /**
   * Get a specific user's configuration setting value
   * This returns a user's setting or its default value. It will also agreggate data from the user's entity itself
   * @param {String} userId    The ID of the user whose setting to access
   * @param {String} key       The name of the setting to retreive
   */
  getUserSetting(userId, key) {
    // If getting an entity field, get the user entity and return its value
    if (["canBroadcastAudio", "canBroadcastVideo", "avatar"].includes(key)) {
      let user = game.users.get(userId);
      // Make sure the user is valid
      if (user) {
        if (key === "canBroadcastVideo")
          return user.can('BROADCAST_VIDEO');
        if (key === 'canBroadcastAudio')
          return user.can('BROADCAST_AUDIO');
        if (key === 'avatar')
          return user.avatar;
      }
      return undefined;
    }
    // Get user setting from client settings, or get deault value if not set
    let value = getProperty(this.getClientSetting('users'), `${userId}.${key}`);
    if (value !== undefined)
      return value;
    return getProperty(this.constructor.getDefaultUserSettings(), key);
  }

  /**
   * Set a specific user's configuration setting value
   * This will either set the value in the client settings or will update the user entity itself, depending on the setting
   * @param {String} userId    The ID of the user whose setting to access
   * @param {String} key      The name of the setting to retreive
   * @param {Object} value     The value to set
   */
  setUserSetting(userId, key, value) {
    if (this.getUserSetting(userId, key) === value)
      return;
    if (["canBroadcastAudio", "canBroadcastVideo", "avatar"].includes(key)) {
      let user = game.users.get(userId);
      if (user) {
        if (key === "canBroadcastVideo")
          user.setPermission('BROADCAST_VIDEO', value);
        if (key === 'canBroadcastAudio')
          user.setPermission('BROADCAST_AUDIO', value);
        if (key === 'avatar')
          user.update({ avatar: value });
      }
      return;
    }
    // Set the new value and replace the user's dictionary
    // We duplicate it here so the setClientSetting recognizes the data as being different
    let users = duplicate(this.getClientSetting('users'));
    setProperty(users, `${userId}.${key}`, value);
    this.setClientSetting('users', users);
  }

  /* -------------------------------------------- */
  /*  Custom getters and setters                  */
  /* -------------------------------------------- */

  /**
   * Require the server to be using https protocol in order for AV to not be disabled
   * @type {number}
   */
  get mode() {
   if (window.location.protocol === "http:") return WebRTCSettings.WEBRTC_MODE.DISABLED;
   return this.getWorldSetting("mode");
  }
  set mode(value) {
     this.setWorldSetting("mode", value);
  }

  /**
   * The webrtc world settings
   * @type {Object}
   */
  get worldSettings() {
    return game.settings.get("core", "rtcWorldSettings");
  }
  set worldSettings(value) {
    game.settings.set("core", "rtcWorldSettings", value);
  }

  /**
   * The webrtc client settings
   * @type {Object}
   */
  get clientSettings() {
    return game.settings.get("core", "rtcClientSettings");
  }
  set clientSettings(value) {
    // Since settings has a client scope, this should technically be synchronous
    game.settings.set("core", "rtcClientSettings", value);
  }

  /**
   * Get the webrtc debug configuration
   * @type {Object}
   */
  get debug() {
    return CONFIG.debug.av;
  }
  set debug(value) {
    if (CONFIG.debug.av !== value) {
      let changed = { debug: value }
      CONFIG.debug.av = value;
      Hooks.callAll("rtcSettingsChanged", this, changed);
      game.webrtc.onSettingsChanged(changed);
    }
  }

  /**
   * Get the webrtc debug value in CONFIG for the AV Client
   * @type {Object}
   */
  get debugClient() {
    return CONFIG.debug.avclient;
  }
  set debugClient(value) {
    if (CONFIG.debug.avclient !== value) {
      let changed = { debugClient: value };
      CONFIG.debug.avclient = value;
      Hooks.callAll("rtcSettingsChanged", this, changed);
      game.webrtc.onSettingsChanged(changed);
    }
  }

  /* -------------------------------------------- */
  /*  Settings changed handlers                   */
  /* -------------------------------------------- */

  /**
   * Get notified that a setting has changed
   */
  onSettingsChanged() {
    /* Debounce a setting change so if config is changed via multiple setter calls, we have time
     * to save all the config changes and send one changed event instead of multiple.
     * This also prevents a reload from happening before all settings are saved in case a mode
     * change happens for example.
     */
    if (this._debounceId > 0)
      clearTimeout(this._debounceId);
    this._debounceId = setTimeout(this._onSettingsChangedDebounced.bind(this), 100);
  }

  /**
   * Get notified that a setting has changed and no changes occured within the last 100ms
   * This will do a diff of all settings and send a notification to the game.webrtc and via a hook
   * that shows which settings have been changed.
   * @private
   */
  _onSettingsChangedDebounced() {
    this._debounceId = 0;

    // Update our values in case they were changed by a remote user (in the case of world settings)
    this._clientSettings = this.clientSettings;
    this._worldSettings = this.worldSettings;

    // Find the changed values of the client, world and user db-stored settings (permissions and avatar)
    let changedClient = diffObject(this._previousClientSettings, this._clientSettings);
    let changedWorld = diffObject(this._previousWorldSettings, this._worldSettings);
    let changedUsers = diffObject(this._previousUsersEntitySettings, this._getAllUsersEntitySettings());

    // Find all the changed values
    let changed = mergeObject(changedClient, changedWorld, { inplace: false });
    if (Object.keys(changedUsers).length > 0)
      changed = mergeObject(changed, { users: changedUsers });

    // If there were no actual changes, no need to notify anyone
    if (isObjectEmpty(changed))
      return;

    // Flatten the changed values to match the getters/setters we use.
    // We don't need to flatten the users dictionary though since we can't use upperCase notation for the users object.
    delete changedClient.users;
    let flat_changes = flattenObject(mergeObject(changedClient, changedWorld));
    for (let key in flat_changes) {
      let name = WebRTCSettings._dotNotationToUpperCase(key);
      changed[name] = flat_changes[key];
    }

    // If a custom server is chosen but no URL is specified, change the server Type back to 'FVTT'
    // we will get debounced and called again, so we can return now.
    if (this.serverUrl === "" && this.serverType === "custom") {
      this.serverType = "FVTT";
      return;
    }

    // If server URL changed, then we need to generate a new room ID to avoid leaking
    // the room ID when switching servers. The onSettingsChanged will get debounced again.
    if (changed.serverUrl !== undefined &&
      (changed.serverRoom === undefined || changed.serverRoom.length < 32)) {
      this.serverRoom = randomID(32);
      return;
    }

    // Store the new settings values and notify game.webrtc and hooks of the new changes.
    this._previousClientSettings = duplicate(this._clientSettings);
    this._previousWorldSettings = duplicate(this._worldSettings);
    this._previousUsersEntitySettings = this._getAllUsersEntitySettings();
    game.webrtc.onSettingsChanged(changed);
    Hooks.callAll("rtcSettingsChanged", this, changed);
  }

  /**
   * Converts a property name from the "dot.notation.format" into an "upperaseNotationFormat" instead.
   * This is used to convert properties such as "server.url" into a suitable getter "serverUrl" name
   * for example.
   * @param {String} name    The property name to convert
   * @return {String}
   * @private
   */
  static _dotNotationToUpperCase(name) {
    return name.replace(/\.[a-z]/g, (match) => match[1].toUpperCase());
  }

  /**
   * Get all the entity related user settings
   * This is used to compare when an entity setting has changed, such as the avatar
   * or the permissions of a user
   *
   * @private
   */
  _getAllUsersEntitySettings() {
    let all_permissions = {};
    for (let user of game.users.entities) {
      for (let key of ["canBroadcastAudio", "canBroadcastVideo", "avatar"]) {
        setProperty(all_permissions, `${user.id}.${key}`, this.getUserSetting(user.id, key))
      }
    }
    return all_permissions;
  }


  /* -------------------------------------------- */
  /*  Magic                                       */
  /* -------------------------------------------- */

  /**
   * Create properties on this object for accessing client and world settings
   * This creates getters and setters for the client and world settings based on the default values
   * The getters/setters functions get bound in the _createGettersAndSetters so we can pass them directly
   * @private
   */
  _createProperties() {
    WebRTCSettings._createGettersAndSetters(this, this.constructor.getDefaultClientSettings(),
      this.getClientSetting, this.setClientSetting);
    WebRTCSettings._createGettersAndSetters(this, this.constructor.getDefaultWorldSettings(),
      this.getWorldSetting, this.setWorldSetting);
  }

  /**
   * Define getters and setters on an object based on the keys in the `settings` dictionary.
   * Each key in the `settings` object will get its own getter and setter as well
   * as a getter/setter for each sub-field in a upperCase notation to make it easier to
   * access any of the values through a getter/setter.
   *
   * @param {Object} obj           The object to create setters and getters on
   * @param {Object} settings      The settings to create getters and setters from
   * @param {Function} getter      The getter callback
   * @param {Function} setter      The setter callback
   * @private
   */
  static _createGettersAndSetters(obj, settings, getter, setter) {
    // Creater a getter and a setter for every key in the settings
    for (let key in settings) {
      // Don't overwrite custom functions or custom getters/setters
      if (obj.hasOwnProperty(key) || obj.constructor.prototype.hasOwnProperty(key))
        continue;
      Object.defineProperty(obj, key, {
        get: getter.bind(obj, key),
        set: setter.bind(obj, key),
      });
    }

    // flatten the settings keys and create a getter/setter for each flat key in upperCase notation
    let flat_settings = flattenObject(settings);
    let flat_keys = Object.keys(flat_settings);
    for (let key of flat_keys) {
      // Replace names from dot notation to upperCase notation, so 'server.url'
      // can be accessed as 'serverUrl' for example
      let name = WebRTCSettings._dotNotationToUpperCase(key);
      // Don't overwrite custom functions or previously set getters/setters
      if (obj.hasOwnProperty(name) || obj.constructor.prototype.hasOwnProperty(name))
        continue;
      Object.defineProperty(obj, name, {
        get: getter.bind(obj, key),
        set: setter.bind(obj, key),
      });
    }
  }

  /**
   * Create a proxy object that calls a getter and setter function whenever one of its fields is accessed
   * @param {Object} obj        The object to proxy
   * @param {Function} getter   The getter to call
   * @param {Function} setter   The setter to call
   * @private
   */
  _createProxy(obj, getter, setter) {
    var proxyHandler = {
      get: function (obj, name) {
        return getter(name)
      },
      set: function (obj, name, value) {
        setter(name, value);
        // We need to return true to tell the Proxy that the set was handled
        return true;
      }
    };
    return new Proxy(obj, proxyHandler);
  }


  /* -------------------------------------------- */
  /*  User specific getters and setters           */
  /* -------------------------------------------- */

  /**
   * Get the users configuration settings
   * This returns a proxy object which will automatically call _getUserProxy and _setUserObject for each user entry that is accessed.
   * @type {Object}

   */
  get users() {
    let obj = this.getClientSetting('users');
    return this._createProxy(obj, this._getUserProxy.bind(this), this._setUserObject.bind(this));
  }
  set users(value) {
    this.setClientSetting('users', value);
  }

  /**
   * Get a specific user's configuration settings
   * This returns a proxy object which will automatically call `this.getUserSetting` and `this.setUserSetting` for each user setting accessed
   * @param {String} userId    The ID of the user whose settings to access
   * @private
   */
  _getUserProxy(userId) {
    let obj = this.getClientSetting('users')[userId] || {};
    return this._createProxy(obj, this.getUserSetting.bind(this, userId), this.setUserSetting.bind(this, userId));
  }

  /**
   * Set a specific user's configuration settings
   *
   * @param {String} userId    The ID of the user whose settings to set
   * @param {Object} value     The user's settings to set
   * @private
   */
  _setUserObject(userId, value) {
    // Need to duplicate, otherwise we modify the local value and the setClientSetting thinks nothing has changed
    // We also can't use the `this.users` getter since it returns the proxy object. But we can use its setter.
    let users = duplicate(this.getClientSetting('users'));
    users[userId] = value;
    this.users = users
  }
}

/**
 * WebRTC Mode, Disabled, Audio only, Video only, Audio & Video
 * @type {Object}
 */
WebRTCSettings.WEBRTC_MODE = {
  DISABLED: 0,
  AUDIO: 1,
  VIDEO: 2,
  AUDIO_VIDEO: 3
};

WebRTCSettings.VOICE_MODES = {
  ALWAYS: "always",
  ACTIVITY: "activity",
  PTT: "ptt"
};
/**
 * The default Actor Sheet
 *
 * This Application is responsible for rendering an actor's attributes and allowing the actor to be edited.
 *
 * System modifications may elect to override this class to better suit their own game system by re-defining the value
 * ``CONFIG.Actor.sheetClass``.

 * @type {BaseEntitySheet}
 *
 * @param actor {Actor}                 The Actor instance being displayed within the sheet.
 * @param [options] {Object}            Additional options which modify the rendering of the Actor's sheet.
 * @param [options.editable] {Boolean}  Is the Actor editable? Default is true.
 */
class ActorSheet extends BaseEntitySheet {
  constructor(...args) {
    super(...args);

    /**
     * If this Actor Sheet represents a synthetic Token actor, reference the active Token
     * @type {Token}
     */
    this.token = this.object.token;
  }

  /* -------------------------------------------- */

  /**
   * Default rendering and configuration options used for the ActorSheet and its subclasses.
   * See Application.defaultOptions and FormApplication.defaultOptions for more details.
   * @type {Object}
   */
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      height: 720,
      width: 800,
      template: "templates/sheets/actor-sheet.html",
      closeOnSubmit: false,
      submitOnClose: true,
      submitOnChange: true,
      resizable: true,
      baseApplication: "ActorSheet"
    });
  }

  /* -------------------------------------------- */

  /**
   * Define a unique and dynamic element ID for the rendered ActorSheet application
   * @return {string}
   */
  get id() {
    const actor = this.actor;
    let id = `actor-${actor.id}`;
    if (actor.isToken) id += `-${actor.token.id}`;
    return id;
  }

  /* -------------------------------------------- */

  /**
   * The displayed window title for the sheet - the entity name by default
   * @type {String}
   */
  get title() {
    return (this.token && !this.token.data.actorLink) ? `[Token] ${this.actor.name}` : this.actor.name;
  }

  /* -------------------------------------------- */

  /**
   * A convenience reference to the Actor entity
   * @type {Actor}
   */
  get actor() {
    return this.object;
  }

  /* -------------------------------------------- */

  /**
   * Prepare data for rendering the Actor sheet
   * The prepared data object contains both the actor data as well as additional sheet options
   */
  getData() {
    const data = super.getData();

    // Entity data
    data.actor = data.entity;
    data.data = data.entity.data;

    // Owned items
    data.items = data.actor.items;
    data.items.sort((a, b) => (a.sort || 0) - (b.sort || 0));
    return data;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getHeaderButtons() {
    let buttons = super._getHeaderButtons();

    // Token Configuration
    let canConfigure = this.options.editable && (game.user.isGM || (this.actor.owner && game.user.isTrusted));
    if (canConfigure) {
      buttons = [
        {
          label: "Sheet",
          class: "configure-sheet",
          icon: "fas fa-cog",
          onclick: ev => this._onConfigureSheet(ev)
        },
        {
          label: this.token ? "Token" : "Prototype Token",
          class: "configure-token",
          icon: "fas fa-user-circle",
          onclick: ev => this._onConfigureToken(ev)
        }
      ].concat(buttons);
    }
    return buttons
  }

  /* -------------------------------------------- */
  /*  Event Listeners                             */
  /* -------------------------------------------- */

  /**
   * Activate the default set of listeners for the Actor Sheet
   * These listeners handle basic stuff like form submission or updating images
   *
   * @param html {JQuery}     The rendered template ready to have listeners attached
   */
  activateListeners(html) {
    super.activateListeners(html);

    // Everything below is only needed if the sheet is editable
    if (!this.options.editable) return;

    // Make the Actor sheet droppable for Items
    this.form.ondragover = ev => this._onDragOver(ev);
    this.form.ondrop = ev => this._onDrop(ev);

    // Support Image updates
    html.find('img[data-edit="img"]').click(ev => this._onEditImage(ev));
  }

  /* -------------------------------------------- */

  /**
   * Handle requests to configure the prototype Token for the Actor
   * @private
   */
  _onConfigureToken(event) {
    event.preventDefault();

    // Determine the Token for which to configure
    const token = this.token || new Token(this.actor.data.token);

    // Render the Token Config application
    new TokenConfig(token, {
      left: Math.max(this.position.left - 560 - 10, 10),
      top: this.position.top,
      configureDefault: !this.token
    }).render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle requests to configure the default sheet used by this Actor
   * @private
   */
  _onConfigureSheet(event) {
    event.preventDefault();
    new EntitySheetConfig(this.actor, {
      top: this.position.top + 40,
      left: this.position.left + ((this.position.width - 400) / 2)
    }).render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the actor profile image by opening a FilePicker
   * @private
   */
  _onEditImage(event) {
    new FilePicker({
      type: "image",
      current: this.actor.data.img,
      callback: path => {
        event.currentTarget.src = path;
        this._onSubmit(event);
      },
      top: this.position.top + 40,
      left: this.position.left + 10
    }).browse(this.actor.data.img);
  }

  /* -------------------------------------------- */

  /**
   * Default handler for beginning a drag-drop workflow of an Owned Item on an Actor Sheet
   * @param event
   * @private
   */
  _onDragItemStart(event) {
    const li = event.currentTarget;
    const item = this.actor.getOwnedItem(li.dataset.itemId);
    const dragData = {
      type: "Item",
      actorId: this.actor.id,
      data: item.data
    };
    if (this.actor.isToken) dragData.tokenId = this.actor.token.id;
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /**
   * Allow the Actor sheet to be a displayed as a valid drop-zone
   * @private
   */
  _onDragOver(event) {
    event.preventDefault();
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle dropped data on the Actor sheet
   * @private
   */
  async _onDrop(event) {
    event.preventDefault();

    // Try to extract the data
    let data;
    try {
      data = JSON.parse(event.dataTransfer.getData('text/plain'));
      if (data.type !== "Item") return;
    } catch (err) {
      return false;
    }

    // Case 1 - Import from a Compendium pack
    const actor = this.actor;
    if (data.pack) {
      return actor.importItemFromCollection(data.pack, data.id);
    }

    // Case 2 - Data explicitly provided
    else if (data.data) {
      let sameActor = data.actorId === actor._id;
      if (sameActor && actor.isToken) sameActor = data.tokenId === actor.token.id;
      if (sameActor) return this._onSortItem(event, data.data); // Sort existing items
      else return actor.createEmbeddedEntity("OwnedItem", duplicate(data.data));  // Create a new Item
    }

    // Case 3 - Import from World entity
    else {
      let item = game.items.get(data.id);
      if (!item) return;
      return actor.createEmbeddedEntity("OwnedItem", duplicate(item.data));
    }
  }

  /* -------------------------------------------- */
  /*  Owned Item Sorting
  /* -------------------------------------------- */

  /**
   * Handle a drop event for an existing Owned Item to sort that item
   * @param {Event} event
   * @param {Object} itemData
   * @private
   */
  _onSortItem(event, itemData) {

    // TODO - for now, don't allow sorting for Token Actor ovrrides
    if (this.actor.isToken) return;

    // Get the drag source and its siblings
    const source = this.actor.getOwnedItem(itemData._id);
    const siblings = this._getSortSiblings(source);

    // Get the drop target
    const dropTarget = event.target.closest(".item");
    const targetId = dropTarget ? dropTarget.dataset.itemId : null;
    const target = siblings.find(s => s.data._id === targetId);

    // Ensure we are only sorting like-types
    if (target && (source.data.type !== target.data.type)) return;

    // Perform the sort
    const sortUpdates = SortingHelpers.performIntegerSort(source, {target: target, siblings});
    const updateData = sortUpdates.map(u => {
      const update = u.update;
      update._id = u.target.data._id;
      return update;
    });

    // Perform the update
    return this.actor.updateManyEmbeddedEntities("OwnedItem", updateData);
  }

  /* -------------------------------------------- */

  _getSortSiblings(source) {
    return this.actor.items.filter(i => (i.data.type === source.data.type) && (i.data._id !== source.data._id));
  }
}
/**
 * Audio/Video Conferencing Configuration Sheet
 * @type {FormApplication}
 */
class AVConfig extends FormApplication {
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      title: game.i18n.localize("WEBRTC.Title"),
      id: "av-config",
      template: "templates/sheets/av-config.html",
      popOut: true,
      width: 480,
      height: "auto",
      tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "general"}]
    });
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering data for the AVConfig form
   * @return {Promise.Object}
    */
  async getData() {
    const videoSources = await this.object.client.getVideoSources();
    const audioSources = await this.object.client.getAudioSources();
    const audioSinks = await this.object.client.getAudioSinks();
    const { videoSrc, audioSrc, audioSink } = this.object.settings;

    // If the currently chosen device is unavailable, display a separate option for 'unavailable device (use default)'
    const videoSrcUnavailable = videoSrc && (videoSrc !== "default") && !Object.keys(videoSources).includes(videoSrc);
    const audioSrcUnavailable = audioSrc && (audioSrc !== "default") && !Object.keys(audioSources).includes(audioSrc);
    const audioSinkUnavailable = audioSink && (audioSink !== "default") && !Object.keys(audioSinks).includes(audioSink);
    const isSSL = window.location.protocol === "https:";

    // Return data to the template
    return {
      user: game.user,
      settings: this.object.settings,
      canSelectMode: game.user.isGM && isSSL,
      noSSL: !isSSL,
      videoSources,
      audioSources,
      audioSinks: isObjectEmpty(audioSinks) ? false : audioSinks,
      videoSrcUnavailable,
      audioSrcUnavailable,
      audioSinkUnavailable
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Activate event listeners for the AVConfig sheet
   * Listen to tab event changes and server/turn config changes
   * @param html {jQuery|HTMLElement}   The rendered HTML for the app
   */
  activateListeners(html) {
    super.activateListeners(html);

    // Push-to-talk key assignment
    html.find('input[name="voicePttName"]').keydown(this._onPTTKeyDown.bind(this))
      .mousedown(this._onPTTMouseDown.bind(this));

    // Options below are GM only
    if ( !game.user.isGM ) return;

    // Change the server type
    html.find("select[name=serverType]").change(this._onServerTypeChanged.bind(this));

    // Change the turn type
    html.find("select[name=turnType]").change(this._onTurnTypeChanged.bind(this));

    // Activate or de-activate the custom server and turn configuration sections based on current settings
    this._setConfigSectionEnabled(".webrtc-custom-server-config", this.object.settings.serverType === "custom");
    this._setConfigSectionEnabled(".webrtc-custom-turn-config", this.object.settings.turnType === "custom");
  }

  /* -------------------------------------------- */

  /**
   * Set a section's input to enabled or disabled
   * @param {string} selector    Selector for the section to enable or disable
   * @param {boolean} enabled    Whether to enable or disable this section
   * @private
   */
  _setConfigSectionEnabled(selector, enabled = true) {
    let section = this.element.find(selector);
    if (section) {
      section.css("opacity", enabled ? 1.0 : 0.5);
      section.find("input").prop("disabled", !enabled);
    }
  }

  /* -------------------------------------------- */

  /**
   * Callback when the server type changes
   * Will enable or disable the server section based on whether the user selected a custom server or not
   * @param {Event} event   The event that triggered the server type change
   * @private
   */
  _onServerTypeChanged(event) {
    event.preventDefault();
    const choice = event.currentTarget.value;
    this._setConfigSectionEnabled(".webrtc-custom-server-config", choice === "custom")
  }

  /* -------------------------------------------- */

  /**
   * Callback when the turn server type changes
   * Will enable or disable the turn section based on whether the user selected a custom turn or not
   * @param {Event} event   The event that triggered the turn server type change
   * @private
   */
  _onTurnTypeChanged(event) {
    event.preventDefault();
    const choice = event.currentTarget.value;
    this._setConfigSectionEnabled(".webrtc-custom-turn-config", choice === "custom")
  }

  /* -------------------------------------------- */

  /**
   * Handle the assignment of a push-to-talk/push-to-mute key
   * @param {Event} event
   * @private
   */
  _onPTTKeyDown(event) {
    event.preventDefault();
    event.target.value = event.originalEvent.key.toUpperCase();
    const form = event.target.form;
    form["voicePttKey"].value = event.originalEvent.keyCode;
    form["voicePttMouse"].value = false;
  }

  /**
   * Handle the assignment of a push-to-talk/push-to-mute mouse key
   * @param {Event} event
   * @private
   */
  _onPTTMouseDown(event) {
    if (document.activeElement !== event.target) return;
    event.preventDefault();
    const button = event.originalEvent.button;
    event.target.value = `MOUSE-${button}`;
    const form = event.target.form;
    form["voicePttKey"].value = button;
    form["voicePttMouse"].value = true;
  }

  /* -------------------------------------------- */

  /**
   * This method is called upon form submission after form data is validated
   * For audio and video sources, an empty string option indicates sending is disabled.
   * This is stored in the setting as null, rather than ""
   *
   * @param event {Event}       The initial triggering submission event
   * @param formData {Object}   The object of validated form data with which to update the object
   * @private
   */
  _updateObject(event, formData) {
    formData.videoSrc = formData.videoSrc || null;
    formData.audioSrc = formData.audioSrc || null;
    for (let key in formData)
      this.object.settings[key] = formData[key];
  }
}
/**
 * Configure the Combat tracker to display additional information as appropriate
 * @type {FormApplication}
 */
class CombatTrackerConfig extends FormApplication {
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "combat-config",
      title: game.i18n.localize("COMBAT.Settings"),
      classes: ["sheet", "combat-sheet"],
      template: "templates/sheets/combat-config.html",
      width: 420
    });
  }

  /* -------------------------------------------- */

  async getData() {
    return {
      settings: game.settings.get("core", Combat.CONFIG_SETTING)
    };
  };

  /* -------------------------------------------- */

  _updateObject(event, formData) {
    game.settings.set("core", Combat.CONFIG_SETTING, {
      resource: formData.resource,
      skipDefeated: formData.skipDefeated
    });
  }
}

/**
 * Edit a folder, configuring its name and appearance
 * @type {FormApplication}
 */
class FolderConfig extends FormApplication {
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.id = "folder-edit";
    options.classes = ["sheet"];
    options.template = "templates/sidebar/folder-edit.html";
    options.width = 400;
    return options;
  }

  /* -------------------------------------------- */

  get title() {
    if ( this.object._id ) return `${game.i18n.localize("FOLDER.Update")}: ${this.object.name}`;
    return game.i18n.localize("FOLDER.Create");
  }

  /* -------------------------------------------- */

  async getData() {
    return {
      folder: this.object.data,
      submitText: game.i18n.localize(this.object._id ? "FOLDER.Update" : "FOLDER.Create")
    }
  }

  /* -------------------------------------------- */

  _updateObject(event, formData) {
    if ( !formData.parent ) formData.parent = null;
    if ( !this.object._id ) Folder.create(formData);
    else this.object.update(formData);
  }
}

/**
 * A tool for fine tuning the grid in a Scene
 * @extends {FormApplication}
 */
class GridConfig extends FormApplication {
  constructor(scene, ...args) {
    super(scene, ...args);

    /**
     * The counter-factual dimensions being evaluated
     * @type {Object}
     */
    this._dimensions = {};

    /**
     * A reference to the bound key handler function so it can be removed
     * @type {Function|null}
     * @private
     */
    this._keyHandler = null;

    /**
     * A reference to the bound mousewheel handler function so it can be removed
     * @type {Function|null}
     * @private
     */
    this._wheelHandler = null;
  }

  /* -------------------------------------------- */

  /** @override */
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "grid-config",
      template: "templates/scene/grid-config.html",
      title: game.i18n.localize("SCENES.GridConfigTool"),
      width: 480,
      height: "auto",
      closeOnSubmit: true,
      submitOnChange: true
    });
  }

	/* -------------------------------------------- */

  /** @override */
  getData() {
    return {
      gridTypes: SceneConfig._getGridTypes(),
      scale: canvas.background.img ? this.object.data.width / canvas.background.img.texture.width : 1,
      scene: this.object.data
    };
  }

	/* -------------------------------------------- */

  /** @override */
  async _render(...args) {
    await super._render(...args);
    this._refresh({grid: true, background: true});
  }

	/* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
	/* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    this._keyHandler = this._keyHandler || this._onKeyDown.bind(this);
    document.addEventListener("keydown", this._keyHandler);
    this._wheelHandler = this._wheelHandler || this._onWheel.bind(this);
    document.addEventListener("wheel", this._wheelHandler, {passive: false});
    html.find('button[name="reset"]').click(this._onReset.bind(this));
  }

	/* -------------------------------------------- */

  /** @override */
  async close(options) {
    document.removeEventListener("keydown", this._keyHandler);
    document.removeEventListener("wheel", this._wheelHandler);
    this._keyHandler = this._wheelHandler = null;
    if ( !this._submitting ) canvas.draw();
    return super.close(options);
  }

	/* -------------------------------------------- */

  /**
   * Handle resetting the form and re-drawing back to the original dimensions
   * @param {KeyboardEvent} event    The original keydown event
   * @private
   */
  _onKeyDown(event) {
    const key = game.keyboard.getKey(event);
    if ( !(key in game.keyboard.moveKeys) ) return;
    event.preventDefault();

    const up = ["w", "W", "ArrowUp"];
    const down = ["s", "S", "ArrowDown"];
    const left = ["a", "A", "ArrowLeft"];
    const right = ["d", "D", "ArrowRight"];

    // Increase the Scene scale on shift + up or down
    if ( event.shiftKey ) {
      let delta = up.includes(key) ? 1 : (down.includes(key) ? -1 : 0);
      this._scaleBackgroundSize(delta);
    }

    // Resize grid size on ALT
    else if ( event.altKey ) {
      let delta = up.includes(key) ? 1 : (down.includes(key) ? -1 : 0);
      this._scaleGridSize(delta);
    }

    // Shift grid position
    else {
      if ( up.includes(key) ) this._shiftBackground({deltaY: -1});
      else if ( down.includes(key) ) this._shiftBackground({deltaY: 1});
      else if ( left.includes(key) ) this._shiftBackground({deltaX: -1});
      else if ( right.includes(key) ) this._shiftBackground({deltaX: 1});
    }
  }

	/* -------------------------------------------- */

  /**
   * Handle resetting the form and re-drawing back to the original dimensions
   * @param {WheelEvent} event    The original wheel event
   * @private
   */
  _onWheel(event) {

    // Increase the Scene scale on shift
    if ( event.shiftKey ) {
      event.preventDefault();
      event.stopImmediatePropagation();
      this._scaleBackgroundSize(-Math.sign(event.deltaY));
    }

    // Increase the Grid scale on alt
    if ( event.altKey ) {
      event.preventDefault();
      event.stopImmediatePropagation();
      this._scaleGridSize(-Math.sign(event.deltaY));
    }
  }

	/* -------------------------------------------- */

  /**
   * Handle resetting the form and re-drawing back to the original dimensions
   * @param {MouseEvent} event    The original click event
   * @private
   */
  _onReset(event) {
    event.preventDefault();
    this._dimensions = {};
    this.render();
  }

	/* -------------------------------------------- */
  /*  Previewing and Updating Functions           */
	/* -------------------------------------------- */

  _scaleBackgroundSize(delta) {
    const scale = Math.round((parseFloat(this.form.scale.value) + (0.05 * delta)) * 100) / 100;
    this.form.scale.value = Math.clamped(scale, 0.25, 10.0);
    this._refresh({background: true});
  }

	/* -------------------------------------------- */

  _scaleGridSize(delta) {
    this.form.grid.value = Math.clamped(parseInt(this.form.grid.value) + delta, 50, 200);
    this._refresh({grid: true});
  }

	/* -------------------------------------------- */

  _shiftBackground({deltaX=0, deltaY=0}={}) {
    this.form.shiftX.value = parseInt(this.form.shiftX.value) + deltaX;
    this.form.shiftY.value = parseInt(this.form.shiftY.value) + deltaY;
    this._refresh({background: true});
  }

	/* -------------------------------------------- */

  _refresh({background=false, grid=false}={}) {
    const form = this.form;
    const bg = canvas.background.img;
    const tex = bg ? bg.texture : {width: canvas.scene.data.width, height: canvas.scene.data.height};

    // Establish new Scene dimensions
    const scale = parseFloat(form.scale.value);
    const d = this._dimensions = canvas.getDimensions({
      width: tex.width * scale,
      height: tex.height * scale,
      grid: Math.clamped(parseInt(form.grid.value), 50, 200),
      gridDistance: this.object.data.gridDistance,
      shiftX: parseInt(form.shiftX.value),
      shiftY: parseInt(form.shiftY.value)
    });

    // Update the background layer
    if ( background && bg ) {
      bg.position.set(d.paddingX - d.shiftX, d.paddingY - d.shiftY);
      bg.width = d.sceneWidth;
      bg.height = d.sceneHeight;
    }

    // Draw the grid layer
    if ( grid ) {
      canvas.grid.tearDown();
      canvas.grid.draw({type: parseInt(form.gridType.value), dimensions: d, gridColor: 0xFF0000, gridAlpha: 1.0});
    }
  }

	/* -------------------------------------------- */

  /** @override */
  _onChangeInput(event) {
    event.preventDefault();
    this._refresh({background: true, grid: true});
  }

	/* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    formData.width = this._dimensions.sceneWidth;
    formData.height = this._dimensions.sceneHeight;
    await this.object.update(formData);
  }
}

/**
 * An Image Popout Application
 * Provides optional support to edit the image path being viewed
 * @type {Application}
 * @param image {String}       The image being viewed
 * @param options {Object}     Standard Application rendering options
 */
class ImagePopout extends FormApplication {

  /** @override */
	static get defaultOptions() {
	  const options = super.defaultOptions;
	  mergeObject(options, {
      template: "templates/apps/image-popout.html",
      classes: ["image-popout", "dark"],
      editable: false,
      resizable: true,
      shareable: false
    });
	  return options;
  }

  /* -------------------------------------------- */

  /** @override */
  getData() {
    const data = super.getData();
    data.image = this.object;
    data.title = this.title;
    return data;
  }

  /* -------------------------------------------- */

  /** @override */
  async _render(...args) {
    this.position = await this.constructor.getPosition(this.object);
    return super._render(...args);
  }

  /* -------------------------------------------- */

  /** @override */
  _getHeaderButtons() {
    let buttons = super._getHeaderButtons();
    if ( game.user.isGM && this.options.shareable ) {
      buttons.unshift({
        label: "Show Players",
        class: "share-image",
        icon: "fas fa-eye",
        onclick: ev => this.shareImage()
      });
    }
    return buttons
  }

  /* -------------------------------------------- */
  /*  Helper Methods
  /* -------------------------------------------- */

  /**
   * Determine the correct position and dimensions for the displayed image
   * @returns {Object}    The positioning object which should be used for rendering
   * @private
   */
  static async getPosition(img) {
    if ( !img ) return { width: 720, height: window.innerHeight * 0.8 };
    const position = {};
    let size;
    try {
      size = await this.getImageSize(img);
    } catch(err) {
      return { width: 720, height: window.innerHeight * 0.8 };
    }
    let wh = window.innerHeight,
        ww = window.innerWidth,
        wr = window.innerWidth / window.innerHeight,
        ir = size[0] / size[1];
    if (ir > wr) {
      position.width = Math.min(size[0] * 2, parseInt(0.95 * ww));
      position.height = parseInt(position.width / ir);
    } else {
      position.height = Math.min(size[1] * 2, parseInt(0.95 * wh));
      position.width = parseInt(position.height * ir);
    }
    position.top = (wh - position.height) / 2;
    position.left = (ww - position.width) / 2;
    return position;
  }

  /* -------------------------------------------- */

  /**
   * Determine the Image dimensions given a certain path
   * @return {Promise<Array.<Number>>}
   */
  static getImageSize(path) {
    return new Promise((resolve, reject) => {
      let img = new Image();
      img.onload = function() {
        resolve([this.width, this.height]);
      };
      img.onerror = reject;
      img.src = path;
    });
  }

  /* -------------------------------------------- */

  /**
   * Share the displayed image with other connected Users
   */
  shareImage() {
    game.socket.emit("shareImage", {
      image: this.object,
      title: this.options.title,
      entity: this.options.entity || null
    });
  }
}

/**
 * The default Item Sheet
 *
 * This Application is responsible for rendering an item's attributes and allowing the item to be edited.
 *
 * System modifications may elect to override this class to better suit their own game system by re-defining the value
 * ``CONFIG.Item.sheetClass``.

 * @type {BaseEntitySheet}
 *
 * @param item {Item}                   The Item instance being displayed within the sheet.
 * @param [options] {Object}            Additional options which modify the rendering of the item.
 */
class ItemSheet extends BaseEntitySheet {
  constructor(...args) {
    super(...args);
    if ( this.actor ) {
      this.actor.apps[this.appId] = this;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      template: "templates/sheets/item-sheet.html",
      width: 500,
      closeOnSubmit: false,
      submitOnClose: true,
      submitOnChange: true,
      resizable: true,
      baseApplication: "ItemSheet"
    });
  }

  /* -------------------------------------------- */

  /** @override */
  get id() {
    if (this.actor) return `actor-${this.actor.id}-item-${this.item.id}`;
    else return super.id;
  }

  /* -------------------------------------------- */

  /**
   * A convenience reference to the Item entity
   * @type {Item}
   */
  get item() {
    return this.object;
  }

  /* -------------------------------------------- */

  /**
   * The Actor instance which owns this item. This may be null if the item is unowned.
   * @type {Actor}
   */
  get actor() {
    return this.item.actor;
  }

  /* -------------------------------------------- */

  /** @override */
  getData() {
    const data = super.getData();
    data.item = data.entity;
    data.data = data.entity.data;
    return data;
  }

  /* -------------------------------------------- */

  /** @override */
  _getHeaderButtons() {
    let buttons = super._getHeaderButtons();
    let canConfigure = this.isEditable && game.user.isTrusted;
    if (!canConfigure) return buttons;

    // Add a Sheet Configuration button
    buttons.unshift({
      label: "Sheet",
      class: "configure-sheet",
      icon: "fas fa-cog",
      onclick: ev => this._onConfigureSheet(ev)
    });
    return buttons;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    if (!this.options.editable) return;

    // Update when we change the image
    html.find('img[data-edit="img"]').click(ev => this._onEditImage(ev));
  }

  /* -------------------------------------------- */

  /**
   * Handle requests to configure the default sheet used by this Item
   * @private
   */
  _onConfigureSheet(event) {
    event.preventDefault();
    new EntitySheetConfig(this.item, {
      top: this.position.top + 40,
      left: this.position.left + ((this.position.width - 400) / 2)
    }).render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the item image
   * @private
   */
  _onEditImage(event) {
    new FilePicker({
      type: "image",
      current: this.item.data.img,
      callback: path => {
        event.currentTarget.src = path;
        this._onSubmit(event);
      },
      top: this.position.top + 40,
      left: this.position.left + 10
    }).browse(this.item.data.img);
  }
}

/**
 * A Macro configuration sheet
 * @extends {BaseEntitySheet}
 *
 * @see {@link Macro} The Macro Entity which is being configured
 */
class MacroConfig extends BaseEntitySheet {

  /** @override */
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      classes: ["sheet", "macro-sheet"],
      template: "templates/sheets/macro-config.html",
      width: 560
    });
  }

	/* -------------------------------------------- */

  /** @override */
  get id() {
    return `macro-config-${this.object._id}`;
  }

	/* -------------------------------------------- */

  /** @override */
  getData() {
    const data = super.getData();
    data.macroTypes = duplicate(game.system.entityTypes.Macro);
    if ( !Macros.canUseScripts(game.user) ) data.macroTypes.findSplice(t => t === "script");
    data.macroScopes = CONST.MACRO_SCOPES;
    return data;
  }

	/* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find('img[data-edit="img"]').click(ev => this._onEditImage(ev));
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the actor profile image by opening a FilePicker
   * @private
   */
  _onEditImage(event) {
    new FilePicker({
      type: "image",
      current: this.object.data.img,
      callback: path => {
        event.currentTarget.src = path;
        this._onSubmit(event, {preventClose: true});
      },
      top: this.position.top + 40,
      left: this.position.left + 10
    }).browse(this.object.data.img);
  }

  /* -------------------------------------------- */

  /** @override */
  _updateObject(event, formData) {
    if ( !this.object.data._id ) {
      return Macro.create(formData);
    } else {
      super._updateObject(event, formData);
    }
  }
}

/**
 * Template Measurement Config Sheet
 * @extends {FormApplication}
 *
 * @param {MeasureTemplate} template The template object being configured
 * @param {Object} options           Additional application rendering options
 * @param {boolean} options.preview  Configure a preview version of a sound which is not yet saved
 */
class MeasuredTemplateConfig extends FormApplication {

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "template-config",
      classes: ["sheet", "template-sheet"],
      title: "Measurement Template Configuration",
      template: "templates/scene/template-config.html",
      width: 400
    });
  }

  /* -------------------------------------------- */

  /** @override */
  getData() {
    return {
      object: duplicate(this.object.data),
      options: this.options,
      templateTypes: CONFIG.MeasuredTemplate.types,
      gridUnits: canvas.scene.data.gridUnits,
      submitText: this.options.preview ? "Create" : "Update"
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _updateObject(event, formData) {
    if ( !game.user.isTrusted ) throw "You do not have the ability to configure a MeasurementTemplate.";
    if ( this.object.id ) {
      formData["id"] = this.object.id;
      this.object.update(formData);
    }
    else this.object.constructor.create(formData);
  }
}

/**
 * A generic application for configuring permissions for various Entity types
 * @type {BaseEntitySheet}
 *
 * @param entity {Entity}               The Entity instance for which permissions are being configured.
 * @param [options] {Object}            Application options.
 */
class PermissionControl extends BaseEntitySheet {
	static get defaultOptions() {
	  const options = super.defaultOptions;
	  options.id = "permission";
	  options.template = "templates/apps/permission.html";
	  return options;
  }

  /* -------------------------------------------- */

  get title() {
    return `${this.entity.name}: Permission Control`;
  }

  /* -------------------------------------------- */

  /**
   * Prepare permissions data as an array of users and levels for which to configure the entity
   */
  getData() {
    const e = this.entity;
    const data = {
      entity: e,
      users: [],
      levels: [{level: "-1", name: "Default"}],
    };

    // Configure permission levels
    for (let [n, l] of Object.entries(CONST.ENTITY_PERMISSIONS)) {
      data.levels.push({level: l, name: n.titleCase()});
    }

    // Get the users and their current permission level and name
    for (let u of game.users) {
      if ( u.isGM ) continue;
      data.users.push({user: u, level: e.data.permission[u._id]});
    }
    return data;
  }

  /* -------------------------------------------- */

  /**
   * This method is called upon form submission after form data is validated
   * @param event {Event}       The initial triggering submission event
   * @param formData {Object}   The object of validated form data with which to update the object
   * @private
   */
  _updateObject(event, formData) {
    event.preventDefault();
    if (!game.user.isGM) throw "You do not have the ability to configure permissions.";

    // Collect user permissions
    const perms = {};
    for ( let [user, level] of Object.entries(formData) ) {
      if ( name !== "default" && level === "-1" ) {
        delete perms[user];
        continue;
      }
      perms[user] = parseInt(level);
    }

    // Update the entity
    this.entity.update({permission: perms}, {diff: false});
  }
}

/**
 * The Player Configuration application provides a form used to allow the current client to 
 * edit preferences and configurations about their own User entity.
 * @type {FormApplication}
 * 
 * @param {User} user       The User entity being configured.
 * @param {Object} options  Additional rendering options which modify the behavior of the form.
 */
class PlayerConfig extends FormApplication {
  constructor(user, options) {
    super(user, options);
    this.user = this.object;
    game.actors.apps.push(this);
  }

	/* -------------------------------------------- */

  /**
   * Assign the default options which are supported by the entity edit sheet
   * @type {Object}
   */
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "player-config",
      template: "templates/user/player-config.html",
      width: 400,
      height: "auto"
    })
  }

  /* -------------------------------------------- */

  get title() {
    return `${game.i18n.localize("PLAYERS.ConfigTitle")}: ${this.user.name}`;
  }

  /* -------------------------------------------- */

  /**
   * Provide data to the form
   * @return {Object}   The data provided to the template when rendering the form
   */
	getData() {
    const controlled = game.users.entities.map(e => e.data.character).filter(a => a);
    const actors = game.actors.entities.filter(a => a.hasPerm(this.user, "OBSERVER") && !controlled.includes(a._id));
    return {
      user: this.user,
      actors: actors,
      options: this.options
    }
  }

	/* -------------------------------------------- */

  /**
   * Activate the default set of listeners for the Entity sheet
   * These listeners handle basic stuff like form submission or updating images
   *
   * @param html {JQuery}     The rendered template ready to have listeners attached
   */
  activateListeners(html) {
    super.activateListeners(html);

    // When a character is clicked, record it's ID in the hidden input
    let input = html.find('[name="character"]');
    html.find('.actor').click(ev => {

      // Record the selected actor
      let li = ev.currentTarget;
      let actorId = li.getAttribute("data-actor-id");
      input.val(actorId);

      // Add context to the selection
      for ( let a of html[0].getElementsByClassName("actor") ) {
        a.classList.remove("context");
      }
      li.classList.add("context");
    });

    // Release the currently selected character
    html.find('button[name="release"]').click(ev => {
      if ( canvas.tokens ) canvas.tokens.releaseAll();
      this.user.update({character: null}).then(() => this.render(false));
    });

    // Support Image updates
    html.find('img[data-edit="avatar"]').click(ev => this._onEditAvatar(ev));
  }

  /* -------------------------------------------- */
  
  /**
   * Handle changing the user avatar image by opening a FilePicker
   * @private
   */
  _onEditAvatar(event) {
    event.preventDefault();
    new FilePicker({
      type: "image",
      current: this.user.data.avatar,
      callback: path => {
        event.currentTarget.src = path;
        this._onSubmit(event, {preventClose: true});
      },
      top: this.position.top + 40,
      left: this.position.left + 10
    }).browse(this.user.data.avatar);
  }

  /* -------------------------------------------- */

  /**
   * This method is called upon form submission after form data is validated
   * @param event {Event}       The initial triggering submission event
   * @param formData {Object}   The object of validated form data with which to update the object
   * @private
   */
  _updateObject(event, formData) {
    this.user.update(formData);
  }
}

/* -------------------------------------------- */
/**
 * Playlist Configuration Sheet
 * @extends {BaseEntitySheet}
 */
class PlaylistConfig extends BaseEntitySheet {
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.id = "playlist-config";
    options.template = "templates/playlist/edit-playlist.html";
    options.width = 360;
    return options;
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return `Edit ${this.object.name} Playlist`;
  }

  /* -------------------------------------------- */

  /** @override */
  getData() {
    const data = duplicate(this.object.data);
    data.modes = Object.keys(CONST.PLAYLIST_MODES).reduce((obj, val) => {
      obj[val.titleCase()] = CONST.PLAYLIST_MODES[val];
      return obj;
    }, {});
    return data;
  }
}


/* -------------------------------------------- */


/**
 * Playlist Sound Configuration Sheet
 * @type {FormApplication}
 *
 * @param {Playlist} playlist   The Playlist entity within which the Sound is configured
 * @param {Object} sound        An Object for the Playlist Sound data
 * @param {Object} options      Additional application rendering options
 */
class PlaylistSoundConfig extends FormApplication {
  constructor(playlist, sound, options) {
    super(sound, options);
    this.playlist = playlist;
  }

  /* -------------------------------------------- */

  /** @override */
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "track-config",
      template: "templates/playlist/edit-track.html",
      width: 360
    });
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return `${this.playlist.name} Playlist: ${this.object.name || "New Track"}`;
  }

  /* -------------------------------------------- */

  /** @override */
  getData() {
    const data = duplicate(this.object);
    data.lvolume = AudioHelper.volumeToInput(data.volume);
    return data;
  }

  /* -------------------------------------------- */

  /** @override */
  _updateObject(event, formData) {
    if (!game.user.isGM) throw "You do not have the ability to edit playlist sounds.";
    formData["volume"] = AudioHelper.inputToVolume(formData["lvolume"]);
    if (this.object._id) {
      formData["_id"] = this.object._id;
      this.playlist.updateEmbeddedEntity("PlaylistSound", formData, {});
    } else this.playlist.createEmbeddedEntity("PlaylistSound", formData, {});
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find('input[name="path"]').change(this._onSourceChange.bind(this));
    return html;
  }

  /* -------------------------------------------- */

  /**
   * Auto-populate the track name using the provided filename, if a name is not already set
   * @param {Event} event
   * @private
   */
  _onSourceChange(event) {
    event.preventDefault();
    const field = event.target;
    const form = field.form;
    if (!form.name.value) form.name.value = field.value.split("/").pop().split(".").shift();
  }
}

/**
 * The RollTable configuration sheet
 * @type {BaseEntitySheet}
 *
 * @param table {RollTable}          The rollable table entity being configured
 * @param options {Object}           Additional application rendering options
 */
class RollTableConfig extends BaseEntitySheet {
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.classes = options.classes.concat(["roll-table-config"]);
    options.template = "templates/sheets/roll-table-config.html";
    options.width = 720;
    options.height = "auto";
    options.closeOnSubmit = false;
    options.scrollY = ["ol.table-results"];
    return options;
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return `${game.i18n.localize("TABLE.SheetTitle")}: ${this.entity.name}`;
  }

  /* -------------------------------------------- */

  /** @override */
  getData() {
    const results = this.entity.results.map(r => {
      r = duplicate(r);
      r.isText = r.type === CONST.TABLE_RESULT_TYPES.TEXT;
      r.isEntity = r.type === CONST.TABLE_RESULT_TYPES.ENTITY;
      r.isCompendium = r.type === CONST.TABLE_RESULT_TYPES.COMPENDIUM;
      r.img = r.img || CONFIG.RollTable.resultIcon;
      return r;
    });
    results.sort((a, b) => a.range[0] - b.range[1]);

    // Merge data and return;
    return mergeObject(super.getData(), {
      results: results,
      resultTypes: Object.entries(CONST.TABLE_RESULT_TYPES).reduce((obj, v) => {
        obj[v[1]] = v[0].titleCase();
        return obj;
      }, {}),
      entityTypes: CONST.COMPENDIUM_ENTITY_TYPES,
      compendiumPacks: game.packs.map(p => p.collection)
    });
  }

  /* -------------------------------------------- */
  /* 	Event Listeners and Handlers								*/
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);

    // Roll the Table
    const button = html.find("button.roll");
    button.click(this._onRollTable.bind(this));
    button[0].disabled = false;

    // The below options require an editable sheet
    if (!this.options.editable) return;

    // Reset the Table
    html.find("button.reset").click(this._onResetTable.bind(this));

    // Save the sheet on checkbox change
    html.find('input[type="checkbox"]').change(this._onSubmit.bind(this));

    // Create a new Result
    html.find("a.create-result").click(this._onCreateResult.bind(this));

    // Delete a Result
    html.find("a.delete-result").click(this._onDeleteResult.bind(this));

    // Support Image updates
    html.find('.result-image img[data-edit]').click(this._onEditImage.bind(this));

    // Lock or Unlock a Result
    html.find("a.lock-result").click(this._onLockResult.bind(this));

    // Modify Result Type
    html.find(".result-type select").change(this._onChangeResultType.bind(this));

    // Re-normalize Table Entries
    html.find(".normalize-results").click(this._onNormalizeResults.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a TableResult in the RollTable entity
   * @param event
   * @return {Promise}
   * @private
   */
  async _onCreateResult(event) {
    event.preventDefault();

    // Save any pending changes
    await this._onSubmit(event);

    // Get existing results
    const results = this.entity.results;
    let last = results[results.length - 1];

    // Get weight and range data
    let weight = last ? (last.weight || 1) : 1;
    let totalWeight = results.reduce((t, r) => t + r.weight, 0) || 1;
    let minRoll = results.length ? Math.min(...results.map(r => r.range[0])) : 0;
    let maxRoll = results.length ? Math.max(...results.map(r => r.range[1])) : 0;

    // Determine new starting range
    let spread = maxRoll - minRoll + 1,
      perW = Math.round(spread / totalWeight);
    let range = [maxRoll + 1, maxRoll + (weight * perW)];

    // Create the new Result
    return this.entity.createEmbeddedEntity("TableResult", {
      type: last ? last.type : CONST.TABLE_RESULT_TYPES.TEXT,
      collection: last ? last.collection : null,
      weight: weight,
      range: range,
      drawn: false
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting a TableResult from the RollTable entity
   * @param event
   * @return {Promise}
   * @private
   */
  async _onDeleteResult(event) {
    event.preventDefault();
    await this._onSubmit(event);
    const li = event.currentTarget.closest(".table-result");
    return this.entity.deleteEmbeddedEntity("TableResult", li.dataset.resultId);
  }

  /* -------------------------------------------- */

  /**
   * Submit the entire form when a table result type is changed, in case there are other active changes
   * @param {Event} event
   * @private
   */
  _onChangeResultType(event) {
    event.preventDefault();
    const rt = CONST.TABLE_RESULT_TYPES;
    const select = event.target;
    const value = parseInt(select.value);
    const key = select.name.replace(".type", ".collection");
    let collection = "";
    if ( value === rt.ENTITY ) collection = "Actor";
    else if ( value === rt.COMPENDIUM ) collection = game.packs[0].collection;
    const updateData = {[key]: collection, resultId: ""};
    return this._onSubmit(event, {updateData});
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the actor profile image by opening a FilePicker
   * @param {Event} event
   * @private
   */
  _onEditImage(event) {
    const li = event.currentTarget.closest(".table-result");
    const result = this.entity.getTableResult(li.dataset.resultId);
    if (result.type !== CONST.TABLE_RESULT_TYPES.TEXT) return;
    const fp = new FilePicker({
      type: "image",
      current: result.img,
      callback: path => {
        event.currentTarget.src = path;
        this._onSubmit(event);
      },
      top: this.position.top + 40,
      left: this.position.left + 10
    });
    fp.browse(result.img);
  }

  /* -------------------------------------------- */

  /**
   * Handle a button click to re-normalize dice result ranges across all RollTable results
   * @param {Event} event
   * @private
   */
  async _onNormalizeResults(event) {
    event.preventDefault();
    if ( !this.rendered || this._submitting) return false;

    // Save any pending changes
    await this._onSubmit(event);

    // Normalize the RollTable
    return this.entity.normalize();
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the drawn status of the result in the table
   * @param {Event} event
   * @private
   */
  _onLockResult(event) {
    event.preventDefault();
    const li = event.currentTarget.closest("li.table-result");
    const result = this.entity.getTableResult(li.dataset.resultId);
    return this.entity.updateEmbeddedEntity("TableResult", {_id: result._id, drawn: !result.drawn});
  }

  /* -------------------------------------------- */

  /**
   * Reset the Table to it's original composition with all options unlocked
   * @param {Event} event
   * @private
   */
  _onResetTable(event) {
    event.preventDefault();
    return this.entity.reset();
  }

  /* -------------------------------------------- */

  /**
   * Handle drawing a result from the RollTable
   * @param {Event} event
   * @private
   */
  async _onRollTable(event) {
    event.preventDefault();
    await this._onSubmit(event);
    event.currentTarget.disabled = true;
    let [roll, result] = this.entity.roll();
    if (result !== null) {
      if (game.settings.get("core", "animateRollTable")) await this._animateRoll(result);
      await this.entity.draw({roll, result});
    }
    event.currentTarget.disabled = false;
  }

  /* -------------------------------------------- */

  /**
   * Configure the update object workflow for the Roll Table configuration sheet
   * Additional logic is needed here to reconstruct the results array from the editable fields on the sheet
   * @param {Event} event            The form submission event
   * @param {Object} formData        The validated FormData translated into an Object for submission
   * @return {Promise}
   * @private
   */
  async _updateObject(event, formData) {

    // Expand the data to update the results array
    const expanded = expandObject(formData);
    expanded.results = expanded.hasOwnProperty("results") ? Object.values(expanded.results) : [];
    for (let r of expanded.results) {

      // Update the Range array
      r.range = [r.rangeL, r.rangeH];

      // Link entity types
      if (r.type === CONST.TABLE_RESULT_TYPES.ENTITY) {
        const config = r.collection ? CONFIG[r.collection] : null;
        const collection = config ? config.collection.instance : null;
        if (!collection) continue;

        // Get the original entity, if the name still matches - take no action
        const original = r.resultId ? collection.get(r.resultId) : null;
        if (original && (original.name === r.text)) continue;

        // Otherwise find the entity by ID or name (id preferred)
        const ent = collection.entities.find(e => (e._id === r.text) || (e.name === r.text)) || null;
        r.resultId = ent ? ent._id : null;
        r.text = ent ? ent.name : null;
        r.img = ent ? ent.img : null;
        r.img = ent ? (ent.data.thumb || ent.data.img) : null;
      }

      // Link Compendium types
      else if (r.type === CONST.TABLE_RESULT_TYPES.COMPENDIUM) {
        const pack = game.packs.find(p => p.collection === r.collection);
        if (pack) {
          const index = await pack.getIndex();

          // Get the original entry, if the name still matches - take no action
          const original = r.resultId ? index.find(i => i.id === r.resultId) : null;
          if (original && (original.name === r.text)) continue;

          // Otherwise find the entity by ID or name (id preferred)
          const ent = index.find(i => (i.id === r.text) || (i.name === r.text)) || null;
          r.resultId = ent ? ent.id : null;
          r.text = ent ? ent.name : null;
          r.img = ent ? ent.img : null;
        }
      }
    }

    // Update the object
    return this.entity.update(expanded);
  }

  /* -------------------------------------------- */

  /**
   * Display a roulette style animation when a Roll Table result is drawn from the sheet
   * @param {Object} result      The result which was drawn from the table
   * @return {Promise}
   * @private
   */
  async _animateRoll(result) {

    // Get the list of results and their indices
    const ol = this.element.find(".table-results")[0];
    let targetIdx = this.entity.results.findIndex(r => r._id === result._id) + 1;

    // Set the animation timing
    const nResults = this.object.results.length;
    const maxTime = 2000;
    let animTime = 50;
    let animOffset = Math.round(ol.offsetHeight / (ol.children[1].offsetHeight * 2));
    const nLoops = Math.min(Math.ceil(maxTime/(animTime * nResults)), 4);
    if ( nLoops === 1 ) animTime = maxTime / nResults;

    // Animate it
    return new Promise(resolve => {
      let loop = 0;
      let idx = 1;
      let item = null;
      let animId = setInterval(() => {
        if (idx === 1) loop++;
        if (item) item.classList.remove("roulette");
        item = ol.children[idx];
        if ((idx === targetIdx) && (loop === nLoops)) {
          clearInterval(animId);
          ol.scrollTop = (targetIdx - animOffset) * item.offsetHeight;
          return this._flashResult(item).then(resolve);
        }
        ol.scrollTop = (idx - animOffset) * item.offsetHeight;
        item.classList.add("roulette");
        idx = idx < ol.children.length - 1 ? idx+1 : 1;
      }, animTime);
    });
  }

  /* -------------------------------------------- */

  /**
   * Display a flashing animation on the selected result to emphasize the draw
   * @param {HTMLElement} item      The HTML <li> item of the winning result
   * @return {Promise}
   * @private
   */
  async _flashResult(item) {
    return new Promise(resolve => {
      let count = 0;
      let animId = setInterval(() => {
        if (count % 2) item.classList.remove("roulette");
        else item.classList.add("roulette");
        if (count === 7) {
          clearInterval(animId);
          resolve();
        }
        count++;
      }, 50);
    })
  }
}

/**
 * A Scene configuration sheet
 * @extends {BaseEntitySheet}
 * @see {@link Scene} The Scene Entity which is being configured
 */
class SceneConfig extends BaseEntitySheet {

  /** @override */
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      classes: ["sheet", "scene-sheet"],
      template: "templates/scene/config.html",
      width: 720
    });
  }

	/* -------------------------------------------- */

  /** @override */
  get id() {
    return `scene-config-${this.object._id}`;
  }

	/* -------------------------------------------- */

  /** @override */
  getData() {
    const data = super.getData();
    data.gridTypes = this.constructor._getGridTypes();
    data.weatherTypes = this._getWeatherTypes();
    data.playlists = this._getEntities(game.playlists);
    data.journals = this._getEntities(game.journal);
    return data;
  }

	/* -------------------------------------------- */

  /**
   * Get an enumeration of the available grid types which can be applied to this Scene
   * @return {Object}
   * @private
   */
	static _getGridTypes() {
	  const labels = {
	    "GRIDLESS": "SCENES.GridGridless",
      "SQUARE": "SCENES.GridSquare",
      "HEXODDR": "SCENES.GridHexOddR",
      "HEXEVENR": "SCENES.GridHexEvenR",
      "HEXODDQ": "SCENES.GridHexOddQ",
      "HEXEVENQ": "SCENES.GridHexEvenQ"
    };
    return Object.keys(CONST.GRID_TYPES).reduce((obj, t) => {
	    obj[CONST.GRID_TYPES[t]] = labels[t];
	    return obj;
    }, {});
  }

	/* -------------------------------------------- */

  /**
   * Get the available weather effect types which can be applied to this Scene
   * @return {Object}
   * @private
   */
  _getWeatherTypes() {
    const types = {};
    for ( let [k, v] of Object.entries(CONFIG.weatherEffects) ) {
      types[k] = v.label;
    }
    return types;
  }

	/* -------------------------------------------- */

  /**
   * Get the alphabetized entities which can be chosen as a configuration for the scene
   * @param {Collection} collection
   * @return {Array}
   * @private
   */
  _getEntities(collection) {
    const entities = collection.entities.map(e => {
      return {_id: e.data._id, name: e.data.name};
    });
    entities.sort((a, b) => a.name.localeCompare(b.name));
    return entities;
  }

	/* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("button.capture-position").click(this._onCapturePosition.bind(this));
    html.find("button.grid-config").click(this._onGridConfig.bind(this))
  }

	/* -------------------------------------------- */

  /**
   * Capture the current Scene position and zoom level as the initial view in the Scene config
   * @param {Event} event   The originating click event
   * @private
   */
  _onCapturePosition(event) {
    event.preventDefault();
    const btn = event.currentTarget;
    const form = btn.form;
    form["initial.x"].value = parseInt(canvas.stage.pivot.x);
    form["initial.y"].value = parseInt(canvas.stage.pivot.y);
    form["initial.scale"].value = canvas.stage.scale.x;
    ui.notifications.info("Captured canvas position as initial view in the Scene configuration form.")
  }

	/* -------------------------------------------- */

  /** @override */
  _onChangeRange(event) {
    super._onChangeRange(event);
    const rng = event.target;
    if ( (rng.name === "darkness") && this.object.isView ) {
      canvas.lighting.update(Number(rng.value));
    }
  }

	/* -------------------------------------------- */

  /**
   * Handle click events to open the grid configuration application
   * @param {Event} event   The originating click event
   * @private
   */
  async _onGridConfig(event) {
    event.preventDefault();
    if ( !this.object.isView ) await this.object.view();
    new GridConfig(this.object).render(true);
  }

	/* -------------------------------------------- */

  /** @override */
  _updateObject(event, formData) {
    const initialViewAttrs = ["initial.x", "initial.y", "initial.scale"];
    if ( initialViewAttrs.every(a => !formData[a]) ) {
      for ( let a of initialViewAttrs ) {
        delete formData[a];
      }
      formData.initial = null;
    }
    return super._updateObject(event, formData);
  }
}

/**
 * Entity Sheet Configuration Application
 * @type {FormApplication}
 * @param entity {Entity}      The Entity object for which the sheet is being configured
 * @param options {Object}     Additional Application options
 */
class EntitySheetConfig extends FormApplication {
	static get defaultOptions() {
	  const options = super.defaultOptions;
	  options.id = "sheet-config";
	  options.template = "templates/sheets/sheet-config.html";
	  options.width = 400;
	  return options;
  }

  /* -------------------------------------------- */

  /**
   * Add the Entity name into the window title
   * @type {String}
   */
  get title() {
    return `${this.object.name}: Sheet Configuration`;
  }

  /* -------------------------------------------- */

  /**
   * Construct and return the data object used to render the HTML template for this form application.
   * @return {Object}
   */
  getData() {
    const entityName = this.object.entity;
    const config = CONFIG[entityName];
    const type = this.object.data.type || CONST.BASE_ENTITY_TYPE;
    const classes = Object.values(config.sheetClasses[type]);
    const defcls = classes.find(c => c.default);
    return {
      entityName: entityName,
      isGM: game.user.isGM,
      object: duplicate(this.object.data),
      options: this.options,
      sheetClass: this.object.getFlag("core", "sheetClass"),
      sheetClasses: classes.map(c => c.id),
      defaultClass: defcls ? defcls.id : null
    }
  }

  /* -------------------------------------------- */

  /**
   * This method is called upon form submission after form data is validated
   * @param event {Event}       The initial triggering submission event
   * @param formData {Object}   The object of validated form data with which to update the object
   * @private
   */
  async _updateObject(event, formData) {
    event.preventDefault();
    const original = this.getData();

    // De-register the current sheet class
    const sheet = this.object.sheet;
    await sheet.close();
    this.object._sheet = null;
    delete this.object.apps[sheet.appId];

    // Update world settings
    if ( game.user.isGM && (formData.defaultClass !== original.defaultClass) ) {
      const setting = await game.settings.get("core", "sheetClasses") || {};
      mergeObject(setting, {[`${this.object.entity}.${this.object.data.type}`]: formData.defaultClass});
      await game.settings.set("core", "sheetClasses", setting);
    }

    // Update the Entity-specific override
    if ( formData.sheetClass !== original.sheetClass ) {
      await this.object.setFlag("core", "sheetClass", formData.sheetClass);
    }

    // Re-draw the updated sheet
    this.object.sheet.render(true);
  }

  /* -------------------------------------------- */
  /*  Configuration Methods
  /* -------------------------------------------- */

  /**
   * Initialize the configured Sheet preferences for Entities which support dynamic Sheet assignment
   * Create the configuration structure for supported entities
   * Process any pending sheet registrations
   * Update the default values from settings data
   */
  static initializeSheets() {

    // Create placeholder entity/type mapping
    const entities = [Actor, Item];
    for ( let ent of entities ) {
      const types = this._getEntityTypes(ent);
      CONFIG[ent.name].sheetClasses = types.reduce((obj, type) => {
        obj[type] = {};
        return obj;
      }, {});
    }

    // Register any pending sheets
    this._pending.forEach(p => {
      if ( p.action === "register" ) this._registerSheet(p);
      else if ( p.action === "unregister" ) this._unregisterSheet(p);
    });
    this._pending = [];

    // Update default sheet preferences
    const defaults = game.settings.get("core", "sheetClasses");
    this._updateDefaultSheets(defaults)
  }

  /* -------------------------------------------- */

  /**
   * Register a sheet class as a candidate which can be used to display entities of a given type
   * @param {Entity} entityClass      The Entity for which to register a new Sheet option
   * @param {String} scope            Provide a unique namespace scope for this sheet
   * @param {Application} sheetClass  A defined Application class used to render the sheet
   * @param {Object} options          Additional options used for sheet registration
   */
  static registerSheet(entityClass, scope, sheetClass, {types=[], makeDefault=false}={}) {
    const id = `${scope}.${sheetClass.name}`;
    const config = {entityClass, id, sheetClass, types, makeDefault};

    // If the game is ready, register the sheet with the configuration object, otherwise add to pending
    if ( (game instanceof Game) && game.ready ) this._registerSheet(config);
    else {
      config["action"] = "register";
      this._pending.push(config);
    }
  }

  static _registerSheet({entityClass, id, sheetClass, types, makeDefault}={}) {
    types = this._getEntityTypes(entityClass, types);
    let classes = CONFIG[entityClass.name].sheetClasses;
    for ( let t of types ) {
      classes[t][id] = {
        id: id,
        cls: sheetClass,
        default: makeDefault
      };
    }
  }

  /* -------------------------------------------- */

  /**
   * Unregister a sheet class, removing it from the list of available Applications to use for an Entity type
   * @param {Entity} entityClass      The Entity for which to register a new Sheet option
   * @param {String} scope            Provide a unique namespace scope for this sheet
   * @param {Application} sheetClass  A defined Application class used to render the sheet
   * @param {Array} types             An Array of types for which this sheet should be removed
   */
  static unregisterSheet(entityClass, scope, sheetClass, {types=[]}={}) {
    const id = `${scope}.${sheetClass.name}`;
    const config = {entityClass, id, types};

    // If the game is ready remove the sheet directly, otherwise remove from pending
    if ( (game instanceof Game) && game.ready ) this._unregisterSheet(config);
    else {
      config["action"] = "unregister";
      this._pending.push(config);
    }
  }

  static _unregisterSheet({entityClass, id, types}={}) {
    types = this._getEntityTypes(entityClass, types);
    let classes = CONFIG[entityClass.name].sheetClasses;
    for ( let t of types ) {
      delete classes[t][id];
    }
  }

  /* -------------------------------------------- */

  static _getEntityTypes(entityClass, types=[]) {
    if ( types.length ) return types;
    const systemTypes = game.system.entityTypes[entityClass.name];
    return systemTypes.length ? systemTypes : [CONST.BASE_ENTITY_TYPE];
  }

  /* -------------------------------------------- */

  /**
   * Update the currently default Sheets using a new core world setting
   * @param {Object} setting
   * @private
   */
  static _updateDefaultSheets(setting) {
    if ( !Object.keys(setting).length ) return;
    const entities = [Actor, Item];
    for ( let ent of entities ) {
      let classes = CONFIG[ent.name].sheetClasses;
      let defaults = setting[ent.name];
      if ( !defaults ) continue;

      // Update default preference for registered sheets
      for ( let [type, sheetId] of Object.entries(defaults) ) {
        const sheets = Object.values(classes[type]);
        let requested = sheets.find(s => s.id === sheetId);
        if ( requested ) sheets.forEach(s => s.default = s.id === sheetId);
      }

      // Close and de-register any existing sheets
      ent.collection.entities.forEach(e => {
        Object.values(e.apps).forEach(e => e.close());
        e.apps = {};
      });
    }
  }
}

EntitySheetConfig._pending = [];
/**
 * Abstraction of the Application interface to be used with the Draggable class as a substitute for the app
 * This class will represent one popout feed window and handle its positioning and draggability
 * @param {CameraViews} view      The CameraViews application that this popout belongs to
 * @param {String} userId         ID of the user this popout belongs to
 * @param {jQuery} element        The div element of this specific popout window
 */
class CameraPopoutAppWrapper {
    constructor(view, userId, element) {
        this.view = view;
        this.element = element;
        this.userId = userId;
        let setting = this.view.webrtc.settings.users[userId];
        this.setPosition({ left: setting.x, top: setting.y, width: setting.width });
        let draggable = new Draggable(this, element.find(".camera-view"),
            element.find(".video-container")[0], true);
        draggable._floatToTop = this.displayToFront.bind(this);
    }
    /**
     * Get the current position of this popout window
     */
    get position() {
        return mergeObject(this.element.position(), {
            width: this.element.outerWidth(),
            height: this.element.outerHeight(),
            scale: 1
        });
    }
    /**
     * Set the position of the popout window.
     * Note: scale is ignored here.
     */
    setPosition({ left, top, width, height, scale } = {}) {
        if (width || height) {
            // TODO: handle this better somehow? very ugly to have this here.
            // Height is based on the width of the camera with a 3:4 aspect ratio + 30px for the player name.
            if (width)
                height = Math.floor(width * 3 / 4 + 30);
            else if (height)
                width = Math.floor((height - 30) * 4 / 3);
            this.element.outerWidth(width);
            this.element.outerHeight(height);
            this.view.webrtc.settings.users[this.userId].width = width;
        }
        this.element.css({ left, top });
        if (left)
            this.view.webrtc.settings.users[this.userId].x = left;
        if (top)
            this.view.webrtc.settings.users[this.userId].y = top;
    }

    _onResize(event) { }

    /**
     * The z-index trick wouldn't work here because all popout divs are children of the
     * #camera-views div itself, which has its own z-index. The z-index that gets respected is the one
     * from camera-views, then the order of those popout windows are based on their position as children
     * of the #camera-views.
     * To float a window to the top, we need to add it to the end of the list of elements in the camera views
     */
    displayToFront() {
        let parent = this.element.parent();
        let children = parent.children();
        let lastElement = children[children.length - 1];
        if (lastElement != this.element[0]) {
            this.view.webrtc.settings.users[this.userId].z = ++this.view.maxZ;
            parent.append(this.element);
        }
    }
}

/**
 * The Camera UI View that displays all the camera feeds and provides the video elements to the WebRTC.
 * @type {Application}
 *
 * @param {WebRTC} webrtc     The WebRTC Implementation to display
 */
class CameraViews extends Application {
  constructor(webrtc, options) {
    super(options);

    /**
     * The WebRTCImplementation to display
     * @type {WebRTC}
     */
    this.webrtc = webrtc || game.webrtc;

    // Render the camera views app when Users change
    game.users.apps.push(this);
  }

  /* -------------------------------------------- */

  /**
   * Assign the default options which are supported by the CameraViews UI
   * @type {Object}
   */
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "camera-views",
      template: "templates/hud/camera-views.html",
      popOut: false
    });
  }

  /* -------------------------------------------- */
  /* Public API                                   */
  /* -------------------------------------------- */

  /**
   * Sets whether a user is currently speaking or not
   *
   * @param {string} userId     The ID of the user
   * @param {boolean} speaking  Whether the user is speaking
   */
  setUserIsSpeaking(userId, speaking) {
    let box = this._getCameraBox(userId)[0];
    if ( box ) box.classList.toggle("speaking", speaking);
  }

  /* -------------------------------------------- */

  /**
   * Obtain a reference to the div.camera-view which is used to portray a given Foundry User.
   * @param {string} userId     The ID of the User entity
   * @return {HTMLElement|null}
   */
  getUserCameraView(userId) {
    return this.element.find(`.camera-view[data-user=${userId}]`)[0] || null;
  }

  /* -------------------------------------------- */

  /**
   * Obtain a reference to the video.user-camera which displays the video channel for a requested Foundry User.
   * If the user is not broadcasting video this will return null.
   * @param {string} userId     The ID of the User entity
   * @return {HTMLVideoElement|null}
   */
  getUserVideoElement(userId) {
    return this.element.find(`.camera-view[data-user=${userId}] video.user-camera`)[0] || null;
  }

  /* -------------------------------------------- */
  /*  Application Rendering                       */
  /* -------------------------------------------- */

  /**
   * Extend the render logic to first check whether a render is necessary based on the context
   * If a specific context was provided, make sure an update to the navigation is necessary before rendering
   */
  render(force, context={}) {
    const { renderContext, renderData } = context;
    if (this.webrtc.settings.mode === WebRTCSettings.WEBRTC_MODE.DISABLED)
      return;
    if (renderContext) {
      if (renderContext !== "updateUser")
        return;
      const updateKeys = ["name", "permissions", "role", "active", "color", "sort", "character", "avatar"];
      if (!updateKeys.some(k => renderData.hasOwnProperty(k)))
        return;
    }
    return super.render(force, context);
  }

  /* -------------------------------------------- */

  /**
   * Render the CameraViews UI, set volumes and render accompanying objects.
   * After the app is rendered, update PlayerList visibility and render video frames
   */
  async _render(force = false, options = {}) {
    await super._render(force, options);
    this._setPlayerListVisibility();
    this.webrtc.render();
  }

  /* -------------------------------------------- */

  /**
   * Prepare the default data which is required to render the CameraViews ui
   */
  getData(options) {
    const settings = this.webrtc.settings;

    // Get the sorted array of connected users
    let users = game.users.entities.reduce((arr, user) => {
      const userData = this._getDataForUser(user);
      if (userData)
        arr.push(userData);
      return arr;
    }, []);
    users = this._sortUsersData(users);

    // Maximum Z of all user popout windows, so we can set correct Z when floating popouts to the front
    this.maxZ = Math.max(...users.map(u => this.webrtc.settings.users[u.id].z));

    // Define a dynamic class for the camera dock container which affects it's rendered style
    let dockClass = `camera-size-${settings.dockSize} camera-position-${settings.dockPosition}`;
    if (!users.some(u => !u.settings.popout)) dockClass += " webrtc-dock-empty";

    // Alter the body class depending on whether the players list is hidden
    if (settings.hidePlayerList) document.body.classList.add("players-hidden");
    else document.body.classList.remove("players-hidden");

    // Return data for rendering
    return {
      self: game.user,
      users: users,
      dockClass: dockClass,
      anyVideo: users.some(u => u.hasVideo),
      muteAll: settings.muteAll
    };
  }

  /* -------------------------------------------- */

  /**
   * Get rendering template daa for one user
   * @param {User} user      The User to transform into rendering data
   * @return {Object}
   * @private
   */
  _getDataForUser(user) {

    // Ensure the user has an active stream
    const stream = this.webrtc.client.getStreamForUser(user.id);
    const isConnected = !!stream;
    if (!isConnected && !user.active)
      return null;

    // Get data for the user
    const charname = user.character ? user.character.name.split(" ")[0] : "";
    const settings = this.webrtc.settings.users[user.id];
    const isMuted = !this.webrtc.isStreamAudioEnabled(stream);
    const isHidden = !this.webrtc.isStreamVideoEnabled(stream);
    const hasVideo = this.webrtc.streamHasVideo(stream);
    const hasAudio = this.webrtc.streamHasAudio(stream);

    // Return structured User data
    return {
      user: user,
      id: user.id,
      local: user.isSelf,
      name: user.name,
      color: user.data.color,
      colorAlpha: hexToRGBAString(colorStringToHex(user.data.color), 0.20),
      charname: user.isGM ? game.i18n.localize("GM") : charname,
      avatar: user.avatar,
      connected: isConnected,
      settings: settings,
      volume: AudioHelper.volumeToInput(settings.volume),
      hasVideo: hasVideo,
      hasAudio: hasAudio,
      isMuted: isMuted,
      isHidden: isHidden,
      canToggleSources: game.user.isGM && !user.isSelf && !user.isGM,
      videoToggleClass: this._getToggleIcon("toggle-video", !isHidden),
      videoToggleTooltip: this._getToggleTooltip("toggle-video", isHidden),
      audioToggleClass: this._getToggleIcon("toggle-audio", !isMuted),
      audioToggleTooltip: this._getToggleTooltip("toggle-audio", isMuted),
      videoToggleGlobalClass: this._getToggleIcon("block-video", settings.canBroadcastVideo),
      videoToggleGlobalTooltip: this._getToggleTooltip("block-video", !settings.canBroadcastVideo),
      audioToggleGlobalClass: this._getToggleIcon("block-audio", settings.canBroadcastAudio),
      audioToggleGlobalTooltip: this._getToggleTooltip("block-audio", !settings.canBroadcastAudio),
      cameraViewClass: [
        settings.popout ? "camera-box-popout" : "camera-box-dock",
        hasVideo && isHidden ? "no-video" : null,
        hasAudio && isMuted ? "no-audio" : null
      ].filterJoin(" ")
    };
  }

  /* -------------------------------------------- */

  /**
   * Returns user's data sorted according to sort order, but always show local user as the first one,
   * remote users with a camera before those without, and sort the popout windows according to their Z order.
   * @return {Array.Object}
   * @private
   */
  _sortUsersData(users) {
    const settings = this.webrtc.settings.users;
    return users.sort((a, b) => {
      if (settings[a.id].popout && settings[b.id].popout)   // Sort popouts by Z order
        return settings[a.id].z - settings[b.id].z;
      else if (settings[a.id].popout)                       // Show popout feeds first
        return -1;
      else if (settings[b.id].popout)                       // Show local feed first
        return 1;
      else if (a.user.isSelf)
        return -1;
      else if (b.user.isSelf)
        return 1;
      else if (a.hasVideo && !b.hasVideo)                   // Show remote users with a camera before those without
        return -1;
      else if (b.hasVideo && !a.hasVideo)
        return 1;
      else                                                  // Sort according to user order
        return a.user.data.sort - b.user.data.sort;
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Listen to UI events, mute/hide, volume controls, popout, etc..
   */
  activateListeners(html) {

    // Display controls when hovering over the video container
    let cvh = this._onCameraViewHover.bind(this);
    html.find('.camera-view').hover(cvh, cvh);

    // Handle clicks on AV control buttons
    html.find(".av-control").click(this._onClickControl.bind(this));

    // Handle volume changes
    html.find(".webrtc-volume-slider").change(this._onVolumeChange.bind(this));

    // Hide Global permission icons depending on the A/V mode
    if (this.webrtc.settings.mode === WebRTCSettings.WEBRTC_MODE.VIDEO)
      html.find('[data-action="toggle-audio"]').hide();
    if (this.webrtc.settings.mode === WebRTCSettings.WEBRTC_MODE.AUDIO)
      html.find('[data-action="toggle-video"]').hide();

    // Make each popout window draggable
    for (let popout of this.element.find(".app.camera-view-popout")) {
      let box = popout.querySelector(".camera-view");
      new CameraPopoutAppWrapper(this, box.dataset.user, $(popout));
    }

    // Listen to the video's srcObjectSet event to set the display mode of the user.
    for (let video of this.element.find("video")) {
      const view = video.closest(".camera-view");
      const userId = view.dataset.user;
      video.addEventListener('srcObjectSet', ev => {
        this._setVideoDisplayMode(view, this.webrtc.isStreamVideoEnabled(event.detail));
        this._setAudioDisplayMode(view, this.webrtc.isStreamAudioEnabled(event.detail));
      });

      // Adjust user volume attributes
      video.volume = this.webrtc.settings.users[userId].volume;
      if (userId !== game.user.id) video.muted = this.webrtc.settings.muteAll;
    }
  }

  /* -------------------------------------------- */

  /**
   * On hover in a camera container, show/hide the controls.
   * @event {Event} event   The original mouseover or mouseout hover event
   * @private
   */
  _onCameraViewHover(event) {
    this._toggleControlVisibility(event.currentTarget, event.type === "mouseenter", null);
  }

  /* -------------------------------------------- */

  /**
   * On clicking on a toggle, disable/enable the audio or video stream.
   * @event {Event} event   The originating click event
   * @private
   */
  _onClickControl(event) {
    event.preventDefault();
    const button = event.currentTarget;
    const view = button.closest(".camera-view");
    const userId = view.dataset.user;
    const action = button.dataset.action;
    const settings = this.webrtc.settings.users[userId];

    // Handle different actions
    let enabled = false;
    switch ( action ) {

      // Globally block video
      case "block-video":
        if ( !game.user.isGM ) break;
        enabled = settings["canBroadcastVideo"] = !settings.canBroadcastVideo;
        break;

      // Globally block audio
      case "block-audio":
        if ( !game.user.isGM ) break;
        enabled = settings["canBroadcastAudio"] = !settings.canBroadcastAudio;
        break;

      // Toggle video display
      case "toggle-video":
        enabled = settings.hidden;
        settings.hidden = !enabled;
        if ( userId === game.user.id ) {
          this.webrtc.enableCamera(enabled);
        } else {
          const stream = this.webrtc.client.getStreamForUser(userId);
          this.webrtc.enableStreamVideo(stream, enabled);
        }
        this._setVideoDisplayMode(view, enabled);
        view.classList.toggle("no-video", !enabled);
        break;

      // Toggle audio output
      case "toggle-audio":
        enabled = settings.muted;
        settings.muted = !enabled;
        if ( userId === game.user.id ) {
          this.webrtc.enableMicrophone(enabled);
        } else {
          const stream = this.webrtc.client.getStreamForUser(userId);
          this.webrtc.enableStreamAudio(stream, enabled);
        }
        this._setAudioDisplayMode(view, enabled);
        view.classList.toggle("no-audio", !enabled);
        break;

      // Toggle mute all peers
      case "mute-peers":
        enabled = this.webrtc.settings.muteAll = !this.webrtc.settings.muteAll;
        for (let video of this.element.find("video")) {
          let userId = video.closest(".camera-view").dataset.user;
          if (userId !== game.user.id) video.muted = enabled;
        }
        break;

      // Configure settings
      case "configure":
        this.webrtc.config.render(true);
        break;

      // Toggle popout
      case "toggle-popout":
        settings.popout = !settings.popout;
        return this.render();

      // Hide players
      case "toggle-players":
        this.webrtc.settings.hidePlayerList = !this.webrtc.settings.hidePlayerList;
        return this.render();

      // Change size
      case "change-size":
        const sizes = ["large", "medium", "small"];
        const size = sizes.indexOf(this.webrtc.settings.dockSize);
        const next = size+1 >= sizes.length ? 0 : size+1;
        this.webrtc.settings.dockSize = sizes[next];
        return this.render();
    }

    // Swap the toggle button icons and reset the tooltip according to the new values
    if ( !button.classList.contains("toggle") ) return;
    const icon = button.children[0];
    icon.classList.toggle(this._getToggleIcon(action, !enabled), false);
    icon.classList.toggle(this._getToggleIcon(action, enabled), true);
    button.setAttribute("title", this._getToggleTooltip(action, enabled));
  }

  /* -------------------------------------------- */

  /**
   * Change volume control for a stream
   * @param {Event} event   The originating change event from interaction with the range input
   * @private
   */
  _onVolumeChange(event) {
    const input = event.currentTarget;
    const box = input.closest(".camera-view");
    const userId = box.dataset.user;

    // Map volume control to logarithmic value and set it
    let exp_volume = AudioHelper.inputToVolume(input.value);
    box.getElementsByTagName("video")[0].volume = exp_volume;
    this.webrtc.settings.users[userId].volume = exp_volume;
  }

  /* -------------------------------------------- */
  /*  Internal Helpers                            */
  /* -------------------------------------------- */

  /**
   * Sets the display mode for a user's video box to toggle between
   * showing the player's avatar or video stream instead
   *
   * @param {HTMLElement} view    The camera view HTML container
   * @param {boolean} enabled     Is video output enabled?
   * @private
   */
  _setVideoDisplayMode(view, enabled) {
    const video = view.querySelector("video.user-camera");
    const avatar = view.querySelector("img.user-avatar");
    if (video && avatar) {
      video.style.visibility = enabled ? 'visible' : "hidden";
      video.style.display = enabled ? "block" : "none";
      avatar.style.display = enabled ? "none" : "unset";
    }
    const statusIcon = view.querySelector(".status-hidden");
    statusIcon.classList.toggle("hidden", enabled);
  }

  /* -------------------------------------------- */

  /**
   * Configure display of the camera view depending on the audio configuration
   *
   * @param {HTMLElement} view    The camera view HTML container
   * @param {boolean} enabled     Is audio output enabled?
   * @private
   */
  _setAudioDisplayMode(view, enabled) {
    const statusIcon = view.querySelector(".status-muted");
    statusIcon.classList.toggle("hidden", enabled);
  }

  /* -------------------------------------------- */

  /**
   * Render changes needed to the PlayerList ui.
   * Show/Hide players depending on option.
   * @private
   */
  _setPlayerListVisibility() {
    let players = document.getElementById("players");
    if (players)
      players.classList.toggle("hidden", this.webrtc.settings.hidePlayerList);
  }

  /* -------------------------------------------- */

  /**
   * Get the av-control icon that should be used for various action buttons with different toggled states
   * @param {string} action     The named av-control button action
   * @param {boolean} enabled   Is the action currently enabled?
   * @return {string}           The icon class to use
   * @private
   */
  _getToggleIcon(action, enabled) {
    const actionMapping = {
      "block-video": ["fa-video", "fa-video-slash"],
      "block-audio": ["fa-microphone", "fa-microphone-slash"],
      "toggle-video": ["fa-video", "fa-video-slash"],
      "toggle-audio": ["fa-microphone", "fa-microphone-slash"],
      "mute-peers": ["fa-volume-mute", "fa-volume-up"]
    };
    const icons = actionMapping[action];
    return icons ? icons[enabled ? 0 : 1] : null;
  }

  /* -------------------------------------------- */

  /**
   * Completement to getToggleClass(), returns the tooltip to show on the toggle icon
   * @param {string} action     The named av-control button action
   * @param {boolean} enabled   Is the action currently enabled?
   * @return {string}           The tooltip label to use
   * @private
   */
  _getToggleTooltip(action, enabled) {
    const actionMapping = {
      "block-video": ["AllowUserVideo", "BlockUserVideo"],
      "block-audio": ["AllowUserAudio", "BlockUserAudio"],
      "toggle-video": ["EnableMyVideo", "DisableMyVideo"],
      "toggle-audio": ["EnableMyAudio", "DisableMyAudio"],
      "mute-peers": ["MutePeers", "UnmutePeers"]
    };
    const labels = actionMapping[action];
    return game.i18n.localize(`WEBRTC.Tooltip${labels ? labels[enabled ? 0 : 1] : ""}`);
  }

  /* -------------------------------------------- */

  /**
   * Get the camera-view for a user
   * @param {String} userId     ID of the user to get its camera feed
   * @param {String} selector   (optional) A selector to further further select an element in the camera box
   * @return {jQuery}
   * @private
   */
  _getCameraBox(userId, selector = "") {
    return this.element.find(`.camera-view[data-user=${userId}] ${selector}`);
  }

  /* -------------------------------------------- */

  /**
   * Show or hide UI control elements
   * This replaces the use of jquery.show/hide as it simply adds a class which has display:none
   * which allows us to have elements with display:flex which can be hidden then shown without
   * breaking their display style.
   * This will show/hide the toggle buttons, volume controls and overlay sidebars
   * @param {jQuery} container    The container for which to show/hide control elements
   * @param {Boolean} show        Whether to show or hide the controls
   * @param {String} selector     Override selector to specify which controls to show or hide
   * @private
   */
  _toggleControlVisibility(container, show, selector) {
    selector = selector || `.control-bar`;
    container.querySelectorAll(selector).forEach(c => c.classList.toggle("hidden", !show));
  }
}

/**
 * The Chat Bubble Class
 * This application displays a temporary message sent from a particular Token in the active Scene.
 * The message is displayed on the HUD layer just above the Token.
 */
class ChatBubbles {
  constructor() {
    this.template = "templates/hud/chat-bubble.html";

    /**
     * Track active Chat Bubbles
     * @type {Object}
     */
    this.bubbles = {};

    /**
     * Track which Token was most recently panned to highlight
     * Use this to avoid repeat panning
     * @type {Token}
     * @private
     */
    this._panned = null;
  }

	/* -------------------------------------------- */

  /**
   * A reference to the chat bubbles HTML container in which rendered bubbles should live
   * @return {jQuery}
   */
  get container() {
    return $("#chat-bubbles");
  }

	/* -------------------------------------------- */

  /**
   * Speak a message as a particular Token, displaying it as a chat bubble
   * @param {Token} token       The speaking Token
   * @param {String} message    The spoken message text
   * @param {Boolean} emote     Whether to style the speech bubble as an emote
   * @return {Promise}          A Promise which resolves once the chat bubble has been created
   */
  async say(token, message, {emote=false}={}) {
    if ( !token || !message ) return;
    let allowBubbles = game.settings.get("core", "chatBubbles");
    if ( !allowBubbles ) return;
    const panToSpeaker = game.settings.get("core", "chatBubblesPan");

    // Clear any existing bubble for the speaker
    await this._clearBubble(token);

    // Create the HTML and call the chatBubble hook
    let html = $(await this._renderHTML({token, message, emote}));
    const allowed = Hooks.call("chatBubble", token, html, message, {emote});
    if ( allowed === false ) return;

    // Set initial dimensions
    let dimensions = this._getMessageDimensions(message);
    this._setPosition(token, html, dimensions);

    // Append to DOM
    this.container.append(html);

    // Pan to the speaker
    if ( panToSpeaker && (this._panned !== token) ) {
      canvas.animatePan({x: token.x, y: token.y, scale: Math.max(1, canvas.stage.scale.x), duration: 1000});
      this._panned = token;
    }

    // Get animation duration and settings
    let duration = this._getDuration(html),
        scroll = dimensions.unconstrained - dimensions.height;

    // Animate the bubble
    html.fadeIn(250, () => {

      // Animate scrolling the content
      if ( scroll > 0 ) {
        html.find(".bubble-content").animate({ top: -1 * scroll }, duration - 1000, 'linear');
      }

      // Set a timer to fade out and remove the bubble
      setTimeout(() => html.fadeOut(250, () => {
        html.remove();
      }), duration);
    });
  }

	/* -------------------------------------------- */

  /**
   * Clear any existing chat bubble for a certain Token
   * @param {Token} token
   * @private
   */
  async _clearBubble(token) {
    let existing = $(`.chat-bubble[data-token-id="${token.id}"]`);
    if ( !existing.length ) return;
    return new Promise(resolve => {
      existing.fadeOut(100, () => {
        existing.remove();
        resolve();
      });
    })
  }

	/* -------------------------------------------- */

  /**
   * Render the HTML template for the chat bubble
   * @param {Object} data     Template data
   * @return {Promise}        The rendered HTML
   * @private
   */
  async _renderHTML(data) {
    data.cssClasses = [
      data.emote ? "emote" : null
    ].filter(c => c !== null).join(" ");
    return renderTemplate(this.template, data);
  }

	/* -------------------------------------------- */

  /**
   * Before displaying the chat message, determine it's constrained and unconstrained dimensions
   * @param {String} message    The message content
   * @return {Object}           The rendered message dimensions
   * @private
   */
  _getMessageDimensions(message) {
    let div = $(`<div class="chat-bubble" style="visibility:hidden">${message}</div>`);
    $('body').append(div);
    let dims = {
      width: div[0].clientWidth + 8,
      height: div[0].clientHeight
    };
    div.css({maxHeight: "none"});
    dims.unconstrained = div[0].clientHeight;
    div.remove();
    return dims;
  }

	/* -------------------------------------------- */

  /**
   * Assign styling parameters to the chat bubble, toggling either a left or right display (randomly)
   * @private
   */
  _setPosition(token, html, dimensions) {
    let cls = Math.random() > 0.5 ? "left" : "right";
    html.addClass(cls);
    const pos = {
      height: dimensions.height,
      width: dimensions.width,
      top: token.y - dimensions.height - 8
    };
    if ( cls === "right" ) pos.left = token.x - (dimensions.width - token.w);
    else pos.left = token.x;
    html.css(pos);
  }

  /* -------------------------------------------- */

  /**
  * Determine the length of time for which to display a chat bubble.
  * Research suggests that average reading speed is 200 words per minute.
  * Since these are short-form messages, we multiply reading speed by 1.5.
  * Clamp the result between 1 second (minimum) and 20 seconds (maximum)
  * @param {jQuery}     The HTML message
  * @returns {Number}   The number of milliseconds for which to display the message
  */
  _getDuration(html) {
    let words = html.text().split(" ").map(w => w.trim()).length;
    let ms = (words * 60 * 1000) / 300;
    return Math.clamped(1000, ms, 20000);
  }
}








/**
 * Render the HUD container
 * @type {Application}
 */
class HeadsUpDisplay extends Application {
  constructor(...args) {
    super(...args);

    /**
     * Token HUD
     * @type {TokenHUD}
     */
    this.token = new TokenHUD();

    /**
     * Tile HUD
     * @type {TileHUD}
     */
    this.tile = new TileHUD();

    /**
     * Drawing HUD
     * @type {DrawingHUD}
     */
    this.drawing = new DrawingHUD();

    /**
     * Chat Bubbles
     * @type {ChatBubbles}
     */
    this.bubbles = new ChatBubbles();
  }

  /* -------------------------------------------- */

  /**
   * Define default options which configure the HUD
   */
	static get defaultOptions() {
	  const options = super.defaultOptions;
    options.id = "hud";
	  options.template = "templates/hud/hud.html";
    options.popOut = false;
    return options;
  }

  /* -------------------------------------------- */

  getData() {
    if ( !canvas.ready ) return {};
    return {
      width: canvas.dimensions.width,
      height: canvas.dimensions.height
    }
  }

  /* -------------------------------------------- */

  async _render(...args) {
    await super._render(...args);
    this.align();
  }

  /* -------------------------------------------- */

  align() {
    const hud = this.element[0];
    let {x, y} = canvas.background.getGlobalPosition();
    let scale = canvas.stage.scale.x;
    hud.style.left = x+"px";
    hud.style.top = y+"px";
    hud.style.transform = `scale(${scale})`;
  }
}

/**
 * Scene controls navigation menu
 * @type {Application}
 */
class SceneControls extends Application {
	constructor(options) {
	  super(options);

    /**
     * The name of the active Scene Control toolset
     * @type {String}
     */
	  this.activeControl = "token";

    /**
     * The Array of Scene Control buttons which are currently rendered
     * @type {Array}
     */
	  this.controls = null;
	}

	/* -------------------------------------------- */
  /*  Properties                                  */
	/* -------------------------------------------- */

  /**
   * Assign the default options which are supported by the SceneControls ui
   */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    width: 100,
      id: "controls",
      template: "templates/hud/controls.html",
      popOut: false
    });
  }

  /* -------------------------------------------- */

  /**
   * Return the active control set
   * @type {Object|null}
   */
  get control() {
	  if ( !this.controls ) return null;
	  return this.controls.find(c => c.name === this.activeControl) || null;
  }

  /* -------------------------------------------- */

  /**
   * Return the name of the active tool within the active control set
   * @type {String|null}
   */
	get activeTool() {
	  const control = this.control;
	  return control ? control.activeTool : null;
  }

  /* -------------------------------------------- */

  /**
   * Return the actively controled tool
   * @return {Object|null}
   */
  get tool() {
    const control = this.control;
    if ( !control ) return null;
    const tool = control.tools.find(t => t.name === control.activeTool);
    return tool || null;
  }

  /* -------------------------------------------- */

  /**
   * A convenience reference for whether the currently active tool is a Ruler
   * @return {Boolean}
   */
  get isRuler() {
	  return this.activeTool === "ruler";
  }

	/* -------------------------------------------- */
  /*  Methods                                     */
	/* -------------------------------------------- */

  /**
   * Initialize the Scene Controls by obtaining the set of control buttons and rendering the HTML
   * @param {String} control  An optional control set to set as active
   * @param {String} layer    An optional layer name to target as the active control
   */
  initialize({control=null, layer=null}={}) {
    if ( control ) this.activeControl = control;
    else if ( layer && this.controls ) {
      const control = this.controls.find(c => c.layer === layer);
      if ( control ) this.activeControl = control.name;
    }
    this.controls = this._getControlButtons();
    this.render(true);
  }

	/* -------------------------------------------- */

  /**
   * Get the set of Control sets and tools that are rendered as the Scene Controls.
   * These controls may be extended using the "getSceneControlButtons" Hook.
   * @return {Array}
   * @private
   */
	_getControlButtons() {
    const controls = [];
    const isGM = game.user.isGM;
    const isTrusted = game.user.isTrusted;

    // Token Controls
    controls.push({
      name: "token",
      title: "CONTROLS.GroupBasic",
      layer: "TokenLayer",
      icon: "fas fa-user-alt",
      tools: [
        {
          name: "select",
          title: "CONTROLS.BasicSelect",
          icon: "fas fa-expand"
        },
        {
          name: "target",
          title: "CONTROLS.TargetSelect",
          icon: "fas fa-bullseye"
        },
        {
          name: "ruler",
          title: "CONTROLS.BasicMeasure",
          icon: "fas fa-ruler"
        }
      ],
      activeTool: "select"
    });

    // Measurement Layer Tools
    controls.push({
      name: "measure",
      title: "CONTROLS.GroupMeasure",
      layer: "TemplateLayer",
      icon: "fas fa-ruler-combined",
      visible: isTrusted,
      tools: [
        {
          name: "circle",
          title: "CONTROLS.MeasureCircle",
          icon: "far fa-circle"
        },
        {
          name: "cone",
          title: "CONTROLS.MeasureCone",
          icon: "fas fa-angle-left"
        },
        {
          name: "rect",
          title: "CONTROLS.MeasureRect",
          icon: "far fa-square"
        },
        {
          name: "ray",
          title: "CONTROLS.MeasureRay",
          icon: "fas fa-arrows-alt-v"
        },
        {
          name: "clear",
          title: "CONTROLS.MeasureClear",
          icon: "fas fa-trash",
          visible: isGM,
          onClick: () => canvas.templates.deleteAll(),
          button: true
        }
      ],
      activeTool: "circle"
    });

    // Tiles Layer
    controls.push({
      name: "tiles",
      title: "CONTROLS.GroupTile",
      layer: "TilesLayer",
      icon: "fas fa-cubes",
      visible: isGM,
      tools: [
        {
          name: "select",
          title: "CONTROLS.TileSelect",
          icon: "fas fa-expand"
        },
        {
          name: "tile",
          title: "CONTROLS.TilePlace",
          icon: "fas fa-cube"
        }
      ],
      activeTool: "select"
    });

    // Drawing Tools
    controls.push({
      name: "drawings",
      title: "CONTROLS.GroupDrawing",
      layer: "DrawingsLayer",
      icon: "fas fa-pencil-alt",
      visible: game.user.can("DRAWING_CREATE"),
      tools: [
        {
          name: "select",
          title: "CONTROLS.DrawingSelect",
          icon: "fas fa-expand"
        },
        {
          name: "rect",
          title: "CONTROLS.DrawingRect",
          icon: "fas fa-square"
        },
        {
          name: "ellipse",
          title: "CONTROLS.DrawingEllipse",
          icon: "fas fa-circle"
        },
        {
          name: "polygon",
          title: "CONTROLS.DrawingPoly",
          icon: "fas fa-draw-polygon"
        },
        {
          name: "freehand",
          title: "CONTROLS.DrawingFree",
          icon: "fas fa-signature"
        },
        {
          name: "configure",
          title: "CONTROLS.DrawingConfig",
          icon: "fas fa-cog",
          onClick: () => canvas.drawings.configureDefault(),
          button: true
        },
        {
          name: "clear",
          title: "CONTROLS.DrawingClear",
          icon: "fas fa-trash",
          visible: isGM,
          onClick: () => canvas.drawings.deleteAll(),
          button: true
        }
      ],
      activeTool: "select"
    });

    // Walls Layer Tools
    controls.push({
      name: "walls",
      title: "CONTROLS.GroupWall",
      layer: "WallsLayer",
      icon: "fas fa-university",
      visible: isGM,
      tools: [
        {
          name: "walls",
          title: "CONTROLS.WallDraw",
          icon: "fas fa-bars"
        },
        {
          name: "invisible",
          title: "CONTROLS.WallInvisible",
          icon: "fas fa-eye-slash"
        },
        {
          name: "terrain",
          title: "CONTROLS.WallTerrain",
          icon: "fas fa-mountain"
        },
        {
          name: "ethereal",
          title: "CONTROLS.WallEthereal",
          icon: "fas fa-mask"
        },
        {
          name: "doors",
          title: "CONTROLS.WallDoors",
          icon: "fas fa-door-open"
        },
        {
          name: "secret",
          title: "CONTROLS.WallSecret",
          icon: "fas fa-user-secret"
        },
        {
          name: "clone",
          title: "CONTROLS.WallClone",
          icon: "far fa-clone"
        },
        {
          name: "clear",
          title: "CONTROLS.WallClear",
          icon: "fas fa-trash",
          onClick: () => canvas.walls.deleteAll(),
          button: true
        }
      ],
      activeTool: "walls"
    });

    // Lighting Layer Tools
    controls.push({
      name: "lighting",
      title: "CONTROLS.GroupLighting",
      layer: "LightingLayer",
      icon: "far fa-lightbulb",
      visible: isGM,
      tools: [
        {
          name: "light",
          title: "CONTROLS.LightDraw",
          icon: "fas fa-lightbulb"
        },
        {
          name: "clear",
          title: "CONTROLS.LightClear",
          icon: "fas fa-trash",
          onClick: () => canvas.lighting.deleteAll(),
          button: true
        },
        {
          name: "day",
          title: "CONTROLS.LightDay",
          icon: "fas fa-sun",
          onClick: () => canvas.scene.update({darkness: 0.0}, {animateDarkness: true}),
          button: true
        },
        {
          name: "night",
          title: "CONTROLS.LightNight",
          icon: "fas fa-moon",
          onClick: () => canvas.scene.update({darkness: 1.0}, {animateDarkness: true}),
          button: true
        },
        {
          name: "reset",
          title: "CONTROLS.LightReset",
          icon: "fas fa-cloud",
          onClick: () => {
            new Dialog({
              title: game.i18n.localize("CONTROLS.FOWResetTitle"),
              content: `<p>${game.i18n.localize("CONTROLS.FOWResetDesc")}</p>`,
              buttons: {
                yes: {
                  icon: '<i class="fas fa-check"></i>',
                  label: "Yes",
                  callback: () => canvas.sight.resetFog()
                },
                no: {
                  icon: '<i class="fas fa-times"></i>',
                  label: "No"
                }
              }
            }).render(true);
          },
          button: true
        }
      ],
      activeTool: "light"
    });

    // Sounds Layer Tools
    controls.push({
      name: "sounds",
      title: "CONTROLS.GroupSound",
      layer: "SoundsLayer",
      icon: "fas fa-music",
      visible: isGM,
      tools: [
        {
          name: "sound",
          title: "CONTROLS.SoundDraw",
          icon: "fas fa-volume-up"
        },
        {
          name: "clear",
          title: "CONTROLS.SoundClear",
          icon: "fas fa-trash",
          onClick: () => canvas.sounds.deleteAll(),
          button: true
        }
      ],
      activeTool: "sound"
    });

    // Notes Layer Tools
    controls.push({
      name: "notes",
      title: "CONTROLS.GroupNotes",
      layer: "NotesLayer",
      icon: "fas fa-bookmark",
      tools: [
        {
          name: "select",
          title: "CONTROLS.NoteSelect",
          icon: "fas fa-expand"
        },
        {
          name: "toggle",
          title: "CONTROLS.NoteToggle",
          icon: "fas fa-map-pin",
          toggle: true,
          active: game.settings.get("core", NotesLayer.TOGGLE_SETTING),
          onClick: toggled => game.settings.set("core", NotesLayer.TOGGLE_SETTING, toggled)
        },
        {
          name: "clear",
          title: "CONTROLS.NoteClear",
          icon: "fas fa-trash",
          visible: isGM,
          onClick: () => canvas.notes.deleteAll(),
          button: true
        }
      ],
      activeTool: 'select'
    });

    // Pass the Scene Controls to a hook function to allow overrides or changes
    Hooks.callAll(`getSceneControlButtons`, controls);
    return controls;
  }

	/* -------------------------------------------- */

  /**
   * Provide data to the HTML template for rendering
   * @return {Object}
   */
	getData() {

	  // Filter to control tool sets which can be displayed
    let controls = this.controls.filter(s => s.visible !== false).map(s => {
      s = duplicate(s);

      // Add styling rules
      s.css = this.activeControl === s.name ? "active" : "";

      // Prepare contained tools
      s.tools = s.tools.filter(t => t.visible !== false).map(t => {
        let active = (s.activeTool === t.name) || (t.toggle && t.active);
        t.css = [
          t.toggle ? "toggle" : null,
          active ? "active" : null
        ].filter(t => !!t).join(" ");
        return t;
      });
      return s;
    });

    // Restrict to sets which have at least 1 tool remaining
    controls = controls.filter(s => s.tools.length);

    // Return data for rendering
	  return {
	    active: canvas.scene,
      controls: controls
    };
  }

	/* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
	/* -------------------------------------------- */

  activateListeners(html) {
    html.find('.scene-control').click(this._onClickLayer.bind(this));
    html.find('.control-tool').click(this._onClickTool.bind(this));
  }

	/* -------------------------------------------- */

  /**
   * Handle click events on a Control set
   * @param {Event} event   A click event on a tool control
   * @private
   */
  _onClickLayer(event) {
    event.preventDefault();
    const li = event.currentTarget;
    const controlName = li.dataset.control;
    this.activeControl = controlName;
    const control = this.controls.find(c => c.name === controlName);
    if ( control && control.layer ) canvas.getLayer(control.layer).activate();
  }

	/* -------------------------------------------- */

  /**
   * Handle click events on Tool controls
   * @param {Event} event   A click event on a tool control
   * @private
   */
  _onClickTool(event) {
    event.preventDefault();
    const li = event.currentTarget;
    const control = this.control;
    const toolName = li.dataset.tool;
    const tool = control.tools.find(t => t.name === toolName);

    // Handle Toggles
    if ( tool.toggle ) {
      tool.active = !tool.active;
      if ( tool.onClick instanceof Function ) tool.onClick(tool.active);
    }

    // Handle Buttons
    else if ( tool.button ) {
      if ( tool.onClick instanceof Function ) tool.onClick();
    }

    // Handle Tools
    else {
      control.activeTool = toolName;
      if ( tool.onClick instanceof Function ) tool.onClick();
    }

    // Render the controls
    this.render();
  }
}

/**
 * The global action bar displayed at the bottom of the game view.
 * The Hotbar is a UI element at the bottom of the screen which contains Macros as interactive buttons.
 * The Hotbar supports 5 pages of global macros which can be dragged and dropped to organize as you wish.
 *
 * Left clicking a Macro button triggers its effect.
 * Right clicking the button displays a context menu of Macro options.
 * The number keys 1 through 0 activate numbered hotbar slots.
 * Pressing the delete key while hovering over a Macro will remove it from the bar.
 *
 * @extends {Application}
 *
 * @see {@link Macros}
 * @see {@link Macro}
 */
class Hotbar extends Application {
	constructor(options) {
	  super(options);
	  game.macros.apps.push(this);

    /**
     * The currently viewed macro page
     * @type {number}
     */
    this.page = 1;

    /**
     * The currently displayed set of macros
     * @type {Array}
     */
    this.macros = [];

    /**
     * Track collapsed state
     * @type {boolean}
     */
    this._collapsed = false;

    /**
     * Track which hotbar slot is the current hover target, if any
     * @type {number|null}
     */
    this._hover = null;
  }

  /* -------------------------------------------- */

  /** @override */
	static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "hotbar",
      template: "templates/hud/hotbar.html",
      popOut: false
    });
  }

	/* -------------------------------------------- */

  /** @override */
	getData() {
	  this.macros = this._getMacrosByPage(this.page);
    return {
      page: this.page,
      macros: this.macros,
      barClass: this._collapsed ? "collapsed" : ""
    };
  }

	/* -------------------------------------------- */

  /**
   * Get the Array of Macro (or null) values that should be displayed on a numbered page of the bar
   * @param {number} page
   * @returns {Array}
   * @private
   */
  _getMacrosByPage(page) {
    const macros = game.user.getHotbarMacros(page);
    for ( let [i, m] of macros.entries() ) {
      m.key = i<9 ? i+1 : 0;
      m.cssClass = m.macro ? "active" : "inactive";
      m.icon = m.macro ? m.macro.data.img : null;
    }
    return macros;
  }

	/* -------------------------------------------- */

  /**
   * Collapse the Hotbar, minimizing its display.
   * @return {Promise}    A promise which resolves once the collapse animation completes
   */
  async collapse() {
    if ( this._collapsed ) return true;
    const toggle = this.element.find("#bar-toggle");
    const icon = toggle.children("i");
    const bar = this.element.find("#action-bar");
    return new Promise(resolve => {
      bar.slideUp(200, () => {
        bar.addClass("collapsed");
        icon.removeClass("fa-caret-down").addClass("fa-caret-up");
        this._collapsed = true;
        resolve(true);
      });
    });
  }

	/* -------------------------------------------- */

  /**
   * Expand the Hotbar, displaying it normally.
   * @return {Promise}    A promise which resolves once the expand animation completes
   */
  expand() {
    if ( !this._collapsed ) return true;
    const toggle = this.element.find("#bar-toggle");
    const icon = toggle.children("i");
    const bar = this.element.find("#action-bar");
    return new Promise(resolve => {
      bar.slideDown(200, () => {
        bar.css("display", "");
        bar.removeClass("collapsed");
        icon.removeClass("fa-caret-up").addClass("fa-caret-down");
        this._collapsed = false;
        resolve(true);
      });
    });
  }

	/* -------------------------------------------- */
  /*  Event Listeners and Handlers
	/* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    html.find('#bar-toggle').click(this._onToggleBar.bind(this));
    html.find("#macro-directory").click(ev => ui.macros.renderPopout(true));
    html.find(".macro").click(this._onClickMacro.bind(this)).hover(this._onHoverMacro.bind(this));
    html.find(".page-control").click(this._onClickPageControl.bind(this));

    // Activate context menu
    this._contextMenu(html);

    // Make the Hotbar and it's contained Macro buttons drag&drop-able
    html.find(".macro").each((i, li) => {
      li.setAttribute("draggable", true);
      li.addEventListener('dragstart', ev => this._onDragStart(ev), false);
    });
    const bar = html.find("#macro-list");
    bar[0].ondragover = this._onDragOver.bind(this);
    bar[0].ondrop = this._onDrop.bind(this);
  }

  /* -------------------------------------------- */

  /**
   * Create a Context Menu attached to each Macro button
   * @param html
   * @private
   */
  _contextMenu(html) {
    new ContextMenu(html, ".macro", [
      {
        name: "Edit",
        icon: '<i class="fas fa-edit"></i>',
        condition: li => {
          const macro = game.macros.get(li.data("macro-id"));
          return macro ? macro.owner : false;
        },
        callback: li => {
          const macro = game.macros.get(li.data("macro-id"));
          macro.sheet.render(true);
        }
      },
      {
        name: "Remove",
        icon: '<i class="fas fa-times"></i>',
        callback: li => {
          game.user.assignHotbarMacro(null, li.data("slot"));
        }
      },
      {
        name: "Delete",
        icon: '<i class="fas fa-trash"></i>',
        condition: li => {
          const macro = game.macros.get(li.data("macro-id"));
          return macro ? macro.owner : false;
        },
        callback: li => {
          const macro = game.macros.get(li.data("macro-id"));
          Dialog.confirm({
            title: `${game.i18n.localize("MACRO.Delete")} ${macro.name}`,
            content: game.i18n.localize("MACRO.DeleteConfirm"),
            yes: macro.delete.bind(macro)
          });
        }
      },
    ]);
  }

  /* -------------------------------------------- */

  /**
   * Handle left-click events to
   * @param event
   * @private
   */
  async _onClickMacro(event) {
    event.preventDefault();
    const li = event.currentTarget;

    // Case 1 - create a new Macro
    if ( li.classList.contains("inactive") ) {
      const macro = await Macro.create({name: "New Macro", type: "chat", scope: "global"});
      await game.user.assignHotbarMacro(macro, li.dataset.slot);
      macro.sheet.render(true);
    }

    // Case 2 - trigger a Macro
    else {
      const macro = game.macros.get(li.dataset.macroId);
      return macro.execute();
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle hover events on a macro button to track which slot is the hover target
   * @param {Event} event   The originating mouseover or mouseleave event
   * @private
   */
  _onHoverMacro(event) {
    event.preventDefault();
    const li = event.currentTarget;
    const hasAction = !li.classList.contains("inactive");

    // Remove any existing tooltip
    const tooltip = li.querySelector(".tooltip");
    if ( tooltip ) li.removeChild(tooltip);

    // Handle hover-in
    if ( event.type === "mouseenter" ) {
      this._hover = li.dataset.slot;
      if ( hasAction ) {
        const macro = game.macros.get(li.dataset.macroId);
        const tooltip = document.createElement("SPAN");
        tooltip.classList.add("tooltip");
        tooltip.textContent = macro.name;
        li.appendChild(tooltip);
      }
    }

    // Handle hover-out
    else {
      this._hover = null;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle pagination controls
   * @param {Event} event   The originating click event
   * @private
   */
  _onClickPageControl(event) {
    const a = event.currentTarget;
    if ( a.dataset.action === "page-up" ) {
      this.page = this.page < 5 ? this.page+1 : 1;
    } else {
      this.page = this.page > 1 ? this.page-1 : 5;
    }
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle the beginning of a drag operation for Macro buttons in the Hotbar
   * @param {Event} event   The originating dragstart event
   * @private
   */
  _onDragStart(event) {
    event.stopPropagation();
    const li = event.currentTarget.closest(".macro");
    const dragData = { type: "Macro", id: li.dataset.macroId, slot: li.dataset.slot };
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /**
   * Allow data transfer events to be dragged over this as a drop zone
   * @private
   */
  _onDragOver(event) {
    event.preventDefault();
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle data being dropped onto the Hotbar
   * @param {Event} event   The originating drop event
   * @private
   */
  _onDrop(event) {
    event.preventDefault();

    // Try to extract the data
    let data;
    try {
      data = JSON.parse(event.dataTransfer.getData('text/plain'));
    }
    catch (err) { return }

    // Get the drop target
    const li = event.target.closest(".macro");

    // Allow for a Hook function to handle the event
    if ( Hooks.call("hotbarDrop", this, data, li.dataset.slot) === false ) return;

    // Only handle Macro drops
    if ( data.type !== "Macro" ) return;
    const macro = game.macros.get(data.id);
    if ( !macro ) return;
    game.user.assignHotbarMacro(macro, li.dataset.slot, {fromSlot: data.slot});
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to toggle display of the macro bar
   * @param {Event} event
   * @private
   */
  _onToggleBar(event) {
    event.preventDefault();
    if ( this._collapsed ) this.expand();
    else this.collapse();
  }
}

/**
 * An abstract base class for displaying a heads-up-display interface bound to a Placeable Object on the canvas
 * @type {Application}
 */
class BasePlaceableHUD extends Application {
  constructor(...args) {
    super(...args);

    /**
     * Reference a PlaceableObject this HUD is currently bound to
     * @type {PlaceableObject}
     */
    this.object = null;

    /**
     * Track the HUD display state
     * @type {Number}
     */
    this._displayState = null;
  }

	/* -------------------------------------------- */

  /**
   * Define the default options which are supported by any BasePleaceableHUD subclass
   * @type {Object}
   */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    classes: ["placeable-hud"],
      popOut: false
    });
  }

	/* -------------------------------------------- */
  /*  Methods
	/* -------------------------------------------- */

  /**
   * Bind the HUD to a new PlaceableObject and display it
   * @param {PlaceableObject} object    A PlaceableObject instance to which the HUD should be bound
   */
	bind(object) {
	  this.clear();

	  // Record the new object
	  if ( !(object instanceof PlaceableObject) || object.scene !== canvas.scene ) {
	    throw new Error("You may only bind a HUD instance to a PlaceableObject in the currently viewed Scene.")
    }
	  this.object = object;

    // Render the HUD
    this.render(true);
    this._displayState = this.constructor.DISPLAY_STATES.RENDERING;
    this.element.hide().fadeIn(200, () => {
      this._displayState = this.constructor.DISPLAY_STATES.RENDERED;
    });
  }

	/* -------------------------------------------- */

  /**
   * Clear the HUD by fading out it's active HTML and recording the new display state
   */
	clear() {
	  const el = this.element;
	  let states = this.constructor.DISPLAY_STATES;
	  if ( !el.is(":visible") ) return;
	  this._displayState = states.CLEARING;
	  this.element.fadeOut(200, () => this._displayState = states.NONE);
  }


	/* -------------------------------------------- */

  /**
   * Set the position of the HUD element after rendering it and flag the latest display state
   * @private
   */
	async _render(...args) {
	  await super._render(...args);
	  this.setPosition();
	  this._displayState = this.constructor.DISPLAY_STATES.RENDERED;
  }

	/* -------------------------------------------- */

  getData() {
    const data = duplicate(this.object.data);
    return mergeObject(data, {
      id: this.id,
      classes: this.options.classes.join(" "),
      appId: this.appId,
      isGM: game.user.isGM,
      icons: CONFIG.controlIcons
    });
  }

	/* -------------------------------------------- */

	setPosition(options={}) {
	  const position = {
	    width: options.width || this.object.width,
      height: options.height || this.object.height,
      left: options.left || this.object.x,
      top: options.top || this.object.y
    };
    this.element.css(position);
  }

	/* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
	/* -------------------------------------------- */

  /**
   * Activate event listeners which provide interactivity for the Token HUD application
   * @param html
   */
  activateListeners(html) {
    html.find(".visibility").click(this._onToggleVisibility.bind(this));
    html.find(".locked").click(this._onToggleLocked.bind(this));
    html.find(".sort-up").click(this._onSort.bind(this, true));
    html.find(".sort-down").click(this._onSort.bind(this, false));
  }

	/* -------------------------------------------- */

  /**
   * Toggle Drawing visibility state
   * @private
   */
  async _onToggleVisibility(event) {
    event.preventDefault();
    await this.object.update({hidden: !this.object.data.hidden});
    $(event.currentTarget).toggleClass("active");
  }

	/* -------------------------------------------- */

  /**
   * Toggle Drawing locked state
   * @private
   */
  async _onToggleLocked(event) {
    event.preventDefault();
    await this.object.update({locked: !this.object.data.locked});
    $(event.currentTarget).toggleClass("active");
  }

	/* -------------------------------------------- */

  /**
   * Handle sorting the z-order of the object
   * @param event
   * @param up
   * @return {Promise<void>}
   * @private
   */
  async _onSort(up, event) {
    event.preventDefault();
    if ( up ) this.object.sortToFront();
    else this.object.sortToBack();
  }
}

/* -------------------------------------------- */


/**
 * BasePlaceableHUD display states
 * @type {Object}
 */
BasePlaceableHUD.DISPLAY_STATES = {
  NONE: 0,
  RENDERING: 1,
  RENDERED: 2,
  CLEARING: 3
};
/**
 * A simple main menu application
 * @type {Application}
 */
class MainMenu extends Application {
	static get defaultOptions() {
	  const options = super.defaultOptions;
	  options.id = "menu";
	  options.template = "templates/hud/menu.html";
	  options.popOut = false;
	  return options;
  }

  /* ----------------------------------------- */

  /**
   * The structure of menu items
   * @return {Object}
   */
  get items() {
    return {
       reload: {
        label: "MENU.Reload",
        icon: '<i class="fas fa-redo"></i>',
        enabled: true,
        onClick: () => window.location.reload()
      },
      logout: {
        label: "MENU.Logout",
        icon: '<i class="fas fa-user"></i>',
        enabled: true,
        onClick: () => game.logOut()
      },
      players: {
        label: "MENU.Players",
        icon: '<i class="fas fa-users"></i>',
        enabled: game.user.isGM,
        onClick: () => window.location.href = "./players"
      },
      world: {
        label: "MENU.Setup",
        icon: '<i class="fas fa-globe"></i>',
        enabled: game.user.isGM,
        onClick: () => game.shutDown()
      }
    }
  }

	/* -------------------------------------------- */

  /** @override */
  getData() {
    return {
      items: this.items
    }
  }

  /* ----------------------------------------- */

  /** @override */
  activateListeners(html) {
    for ( let [k, v] of Object.entries(this.items) ) {
      html.find('.menu-'+k).click(ev => v.onClick());
    }
  }

  /* ----------------------------------------- */

  /**
   * Toggle display of the menu (or render it in the first place)
   */
  toggle() {
    let menu = this.element;
    if ( !menu.length ) this.render(true);
    else menu.slideToggle(150);
  }
}
/**
 * Top menu scene navigation
 * @type {Application}
 */
class SceneNavigation extends Application {
	constructor(options) {
	  super(options);
	  game.scenes.apps.push(this);

    /**
     * Navigation collapsed state
     * @type {Boolean}
     */
    this._collapsed = false;
  }

  /* -------------------------------------------- */

  /**
   * Assign the default options which are supported by the SceneNavigation UI
   * @type {Object}
   */
	static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "navigation",
      template: "templates/hud/navigation.html",
      popOut: false
    });
  }

  /* -------------------------------------------- */

  /**
   * Return an Array of Scenes which are displayed in the Navigation bar
   * @return {Array}
   */
  get scenes() {
    const scenes = game.scenes.entities.filter(s => s.data.navigation || s.active || s.isView);
    scenes.sort((a, b) => a.data.navOrder - b.data.navOrder);
    return scenes;
  }


	/* -------------------------------------------- */
  /*  Application Rendering
	/* -------------------------------------------- */

  /**
   * Extend the Application.render logic to first check the rendering context to see what was changed
   * If a specific context was provided, make sure an update to the navigation is necessary before rendering
   */
  render(force, context={}) {
    let { renderContext, renderData} = context;
    if ( renderContext ) {
      const events = ["createScene", "updateScene", "deleteScene"];
      if ( !events.includes(renderContext) ) return;
      const updateKeys = ["name", "permission", "permission.default", "active", "navigation", "navName", "navOrder"];
      if ( renderContext === "updateScene" && !updateKeys.some(k => renderData.hasOwnProperty(k)) ) return;
    }
    return super.render(force, context);
  }

	/* -------------------------------------------- */

  /**
   * Prepare the default data which is required to render the SceneNavigation menu
   * @return {Object}
   */
	getData() {

    // Modify Scene data
    const scenes = this.scenes.map(s => {
      let data = duplicate(s.data);
      let users = game.users.entities.filter(u => u.active && (u.viewedScene === s._id));
      data.name = data.navName || data.name;
	    data.users = users.map(u => { return {letter: u.name[0], color: u.data.color} });
	    data.visible = (game.user.isGM || s.owner || s.active);
	    data.css = [
	      s.isView ? "view" : null,
        s.active ? "active" : null,
        data.permission.default === 0 ? "gm" : null
      ].filter(c => !!c).join(" ");
	    return data;
    });

    // Return data for rendering
    return {
      collapsed: this._collapsed,
      scenes: scenes
    }
  }

	/* -------------------------------------------- */

  /**
   * Expand the SceneNavigation menu, sliding it down if it is currently collapsed
   */
  expand() {
    if ( !this._collapsed ) return true;
    const nav = this.element;
    const icon = nav.find("#nav-toggle i.fas");
    const ul = nav.children("#scene-list");
    return new Promise(resolve => {
      ul.slideDown(200, () => {
        nav.removeClass("collapsed");
        icon.removeClass("fa-caret-down").addClass("fa-caret-up");
        this._collapsed = false;
        resolve(true);
      });
      Hooks.callAll("collapseSceneNavigation", this, this._collapsed);
    });
  }

	/* -------------------------------------------- */

  /**
   * Collapse the SceneNavigation menu, sliding it up if it is currently expanded
   */
  async collapse() {
    if ( this._collapsed ) return true;
    const nav = this.element;
    const icon = nav.find("#nav-toggle i.fas");
    const ul = nav.children("#scene-list");
    return new Promise(resolve => {
      ul.slideUp(200, () => {
        nav.addClass("collapsed");
        icon.removeClass("fa-caret-up").addClass("fa-caret-down");
        this._collapsed = true;
        resolve(true);
      });
      Hooks.callAll("collapseSceneNavigation", this, this._collapsed);
    });
  }

	/* -------------------------------------------- */
  /*  Event Listeners and Handlers
	/* -------------------------------------------- */

  /**
   * Activate Scene Navigation event listeners
   * @param html
   */
  activateListeners(html) {

    // Click event listener
    const scenes = html.find('.scene');
    scenes.click(this._onClickScene.bind(this));
    html.find('#nav-toggle').click(this._onToggleNav.bind(this));

    // Activate Context Menu
    const contextOptions = this._getContextMenuOptions();
    Hooks.call("getSceneNavigationContext", html, contextOptions);
    if ( contextOptions ) new ContextMenu(html, ".scene", contextOptions);

    // Make each Scene draggable and droppable
    scenes.each((i, li) => {
      li.setAttribute("draggable", true);
      li.addEventListener('dragstart', this._onDragStart.bind(this), false);
    });
    html[0].ondragover = this._onDragOver.bind(this);
    html[0].ondrop = this._onDrop.bind(this);
  }

  /* -------------------------------------------- */

  /**
   * Get the set of ContextMenu options which should be applied for Scenes in the menu
   * @return {Array}   The Array of context options passed to the ContextMenu instance
   * @private
   */
  _getContextMenuOptions() {
    return [
      {
        name: "SCENES.Activate",
        icon: '<i class="fas fa-bullseye"></i>',
        condition: game.user.isGM,
        callback: li => {
          let scene = game.scenes.get(li.attr("data-scene-id"));
          scene.activate();
        }
      },
      {
        name: "SCENES.Configure",
        icon: '<i class="fas fa-cogs"></i>',
        condition: game.user.isGM,
        callback: li => {
          let scene = game.scenes.get(li.attr("data-scene-id"));
          scene.sheet.render(true);
        }
      },
      {
        name: "SCENES.Notes",
        icon: '<i class="fas fa-scroll"></i>',
        condition: li => {
          if ( !game.user.isGM ) return false;
          const scene = game.scenes.get(li.data("sceneId"));
          return !!scene.journal;
        },
        callback: li => {
          const scene = game.scenes.get(li.data("sceneId"));
          const entry = scene.journal;
          if ( entry ) {
            const sheet = entry.sheet;
            sheet.options.sheetMode = "text";
            sheet.render(true);
          }
        }
      },
      {
        name: "SCENES.Preload",
        icon: '<i class="fas fa-download"></i>',
        condition: game.user.isGM,
        callback: li => {
          let sceneId = li.attr("data-scene-id");
          game.scenes.preload(sceneId, true);
        }
      },
      {
        name: "SCENES.ToggleNav",
        icon: '<i class="fas fa-compass"></i>',
        condition: game.user.isGM,
        callback: li => {
          const scene = game.scenes.get(li.attr("data-scene-id"));
          scene.update({navigation: !scene.data.navigation});
        }
      }
    ];
  }

  /* -------------------------------------------- */

  /**
   * Handle left-click events on the scenes in the navigation menu
   * @param {Event} event
   * @private
   */
  _onClickScene(event) {
    event.preventDefault();
    let sceneId = event.currentTarget.dataset.sceneId;
    game.scenes.get(sceneId).view();
  }

  /* -------------------------------------------- */

  /**
   * Begin a drag and drop workflow from the Scene navigation bar
   * @param {Event} event
   * @private
   */
  _onDragStart(event) {
    const sceneId = event.currentTarget.dataset.sceneId;
    event.dataTransfer.setData("text/plain", JSON.stringify({
      type: "SceneNavigation",
      id: sceneId,
    }));
  }

  /* -------------------------------------------- */

  /**
   * Allow drag/dropping on the navigation bar by preventing default and returning false
   * @param {Event} event
   * @private
   */
  _onDragOver(event) {
    event.preventDefault();
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle SceneNavigation sort drop to re-order the elements of the navigation bar
   * @param {Event} event
   * @private
   */
  async _onDrop(event) {
    event.preventDefault();

    // Process drop data
    let data;
    try {
      data = JSON.parse(event.dataTransfer.getData("text/plain"));
    } catch(err) {
      return false;
    }
    if ( data.type !== "SceneNavigation" ) return false;

    // Identify the entity, the drop target, and the set of siblings
    const entity = game.scenes.get(data.id);
    const dropTarget = event.target.closest(".scene") || null;
    const sibling = dropTarget ? game.scenes.get(dropTarget.dataset.sceneId) : null;
    if ( sibling && (sibling._id === entity._id) ) return;
    const siblings = this.scenes.filter(s => s._id !== entity._id);

    // Update the navigation sorting for each Scene
    entity.sortRelative({
      target: sibling,
      siblings: siblings,
      sortKey: "navOrder",
      sortBefore: true
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle navigation menu toggle click events
   * @param {Event} event
   * @private
   */
  _onToggleNav(event) {
    event.preventDefault();
    if ( this._collapsed ) this.expand();
    else this.collapse();
  }
}

/**
 * Pause notification in the HUD
 * @type {Application}
 */
class Pause extends Application {
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.id = "pause";
    options.template = "templates/hud/pause.html";
    options.popOut = false;
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the default data which is required to render the Pause UI
   */
  getData() {
    return {
      paused: game.paused
    };
  }
}

/* -------------------------------------------- */

/**
 * The active Player List application
 * @type {Application}
 */
class PlayerList extends Application {
  constructor(options) {
    super(options);
    game.users.apps.push(this);

    /**
     * An internal toggle for whether or not to show offline players or hide them
     * @type {boolean}
     * @private
     */
    this._showOffline = false;
  }

	/* -------------------------------------------- */

  /**
   * Assign the default options which are supported by the PlayerList UI
   * @type {Object}
   */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "players",
      template: "templates/user/players.html",
      popOut: false
    });
  }

	/* -------------------------------------------- */
  /*  Application Rendering
	/* -------------------------------------------- */

  /**
   * Extend the render logic to first check whether a render is necessary based on the context
   * If a specific context was provided, make sure an update to the navigation is necessary before rendering
   */
  render(force, context={}) {
    let { renderContext, renderData} = context;
    if ( renderContext ) {
      const events = ["createUser", "updateUser", "deleteUser"];
      if ( !events.includes(renderContext) ) return;
      const updateKeys = ["name", "permission", "permission.default", "active", "navigation"];
      if ( renderContext === "updateUser" && !updateKeys.some(k => renderData.hasOwnProperty(k)) ) return;
    }
    return super.render(force, context);
  }

  /* -------------------------------------------- */

  /**
   * Prepare the default data which is required to render the PlayerList ui
   */
  getData(options) {

    // Process user data by adding extra characteristics
    const users = game.users.entities.filter(u => this._showOffline || u.active).map(u => {
      u.charname = u.character ? u.character.name.split(" ")[0] : "";
      const color = u.active ? u.data.color : "#333333",
          rgb = PIXI.utils.hex2rgb(color.replace("#", "0x")),
          border = u.active ? PIXI.utils.hex2string(PIXI.utils.rgb2hex(rgb.map(c => Math.min(c * 2, 1)))) : "#000000";
      u.color = color;
      u.border = border;
      return u;
    });

    // Determine whether to hide the players list when using AV conferencing
    let hide = false;
    if ( game.webrtc && game.webrtc.settings.mode >= WebRTCSettings.WEBRTC_MODE.VIDEO ) {
      hide = game.webrtc.settings.hidePlayerList;
    }

    // Return the data for rendering
    return {
      users: users,
      showOffline: this._showOffline,
      hide: hide
    };
  }

	/* -------------------------------------------- */
  /*  Event Listeners and Handlers
	/* -------------------------------------------- */

  /**
   * Add a context menu to the players UI which allows players to control or release Actors that they own
   */
  activateListeners(html) {

    // Toggle online/offline
    html.find("h3").click(this._onToggleOfflinePlayers.bind(this));

    // Context menu
    new ContextMenu(html, ".player", [
      {
        name: game.i18n.localize("PLAYERS.ConfigTitle"),
        icon: '<i class="fas fa-male"></i>',
        callback: li => {
          let userId = li.data("user-id"),
              user = game.user.isGM ? game.users.get(userId) : game.user;
          new PlayerConfig(user).render(true)
        }
      },
      {
        name: game.i18n.localize("PLAYERS.ViewAvatar"),
        icon: '<i class="fas fa-image"></i>',
        condition: li => {
          let user = game.users.get(li.data("user-id"));
          return user.avatar !== CONST.DEFAULT_TOKEN;
        },
        callback: li => {
          let user = game.users.get(li.data("user-id"));
          new ImagePopout(user.avatar, {
            title: user.name,
            shareable: false,
            entity: {type: "User", id: user.id},
          }).render(true);
        }
      },
      {
        name: game.i18n.localize("PLAYERS.PullToScene"),
        icon: '<i class="fas fa-directions"></i>',
        condition: game.user.isGM,
        callback: li => game.socket.emit("pullToScene", canvas.scene._id, li.data("user-id"))
      }
    ]);
  }

	/* -------------------------------------------- */

  /**
   * Toggle display of the Players hud setting for whether or not to display offline players
   * @param {Event} event   The originating click event
   * @private
   */
  _onToggleOfflinePlayers(event) {
    event.preventDefault();
    this._showOffline = !this._showOffline;
    this.render();
  }
}

/**
 * The JournalEntry Configuration Sheet
 * @type {BaseEntitySheet}
 *
 * @param entity {JournalEntry}  The JournalEntry instance which is being edited
 * @param options {Object}       Application options
 */
class JournalSheet extends BaseEntitySheet {
  constructor(object, options={}) {
    super(object, options);
    this._sheetMode = this.options.sheetMode || this._inferDefaultMode();
  }

	/* -------------------------------------------- */

  _inferDefaultMode() {
    const hasImage = !!this.object.data.img;
    const otherwise = this.object.limited ? null : "text";
    return hasImage ? "image" : otherwise;
  }

	/* -------------------------------------------- */

  /** @override */
	static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      classes: ["sheet", "journal-sheet"],
      width: 720,
      height: 800,
      resizable: true,
      closeOnSubmit: false,
      submitOnClose: true
    });
  }

	/* -------------------------------------------- */

  /** @override */
	get id() {
	  return `journal-${this.object.id}`;
  }

  /* -------------------------------------------- */

  /** @override */
  get template() {
    if ( this._sheetMode === "image" ) return ImagePopout.defaultOptions.template;
    return "templates/journal/sheet.html";
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    if ( this.object.permission <= CONST.ENTITY_PERMISSIONS.LIMITED ) return "";
    return this.object.name;
  }

	/* -------------------------------------------- */

  /** @override */
  async _render(force, options={}) {

    // Determine the sheet rendering mode
    const mode = options.sheetMode || this._sheetMode;
    if ( mode === null ) return false;
    if ( (mode === this._sheetMode) && this.rendered ) return super._render(force, options);

    // Asynchronously begin closing the current sheet
    let promises = [this.close({submit: false})];

    // Update image sizing
    if ( mode === "image" ) {
      const img = this.object.data.img;
      if ( img ) promises.push(ImagePopout.getPosition(img).then(position => {
        this.position = position;
      }));
      options.classes = this.options.classes.concat(ImagePopout.defaultOptions.classes);
    }

    // Update text sizing
    else if ( mode === "text" ) {
      this.position = { width: this.options.width, height: this.options.height };
    }

    // Render the new sheet once things are processed
    return Promise.all(promises).then(() => {
      this._sheetMode = mode;
      return super._render(force, options);
    });
  }

	/* -------------------------------------------- */

  /** @override */
  _getHeaderButtons() {
    let buttons = super._getHeaderButtons();
    let isOwner = this.object.owner,
        atLeastLimited = this.object.hasPerm(game.user, "LIMITED"),
        atLeastObserver = this.object.hasPerm(game.user, "OBSERVER"),
        hasMultipleModes = this.object.data.img && this.object.data.content;

    // Image Mode
    if ( isOwner || (atLeastLimited && hasMultipleModes) ) {
      buttons.unshift({
        label: "Image",
        class: "entry-image",
        icon: "fas fa-image",
        onclick: ev => this._onSwapMode(ev, "image")
      })
    }

    // Text Mode
    if ( isOwner || (atLeastObserver && hasMultipleModes) ) {
      buttons.unshift({
        label: "Text",
        class: "entry-text",
        icon: "fas fa-file-alt",
        onclick: ev => this._onSwapMode(ev, "text")
      })
    }

    // Share Entry
    if ( game.user.isGM ) {
      buttons.unshift({
        label: "Show Players",
        class: "share-image",
        icon: "fas fa-eye",
        onclick: ev => this._onShowPlayers(ev)
      });
    }
    return buttons;
  }

  /* -------------------------------------------- */

  /** @override */
  getData() {
    const data = super.getData();
    data.title = this.title; // Needed for image mode
    data.image = this.object.data.img;
    data.folders = game.data.folders.filter(f => f.type === "JournalEntry");
    return data
  }

  /* -------------------------------------------- */

  /** @override */
  _updateObject(event, formData) {
    if ( this._sheetMode === "image" ) {
      formData.name = formData.title;
      delete formData["title"];
      formData.img = formData.image;
      delete formData["image"];
    }
    super._updateObject(event, formData);
  }

  /* -------------------------------------------- */

  /**
   * Handle requests to switch the rendered mode of the Journal Entry sheet
   * Save the form before triggering the show request, in case content has changed
   * @param {Event} event   The triggering click event
   * @param {string} mode   The journal mode to display
   */
  async _onSwapMode(event, mode) {
    event.preventDefault();
    await this.submit();
    this.render(true, {sheetMode: mode});
  }

  /* -------------------------------------------- */

  /**
   * Handle requests to show the referenced Journal Entry to other Users
   * Save the form before triggering the show request, in case content has changed
   * @param {Event} event   The triggering click event
   */
  async _onShowPlayers(event) {
    event.preventDefault();
    await this.submit();
    return this.object.show(this._sheetMode, true);
  }
}

/**
 * Placeable Note configuration sheet
 * @type {FormApplication}
 * @param note {Note}          The Note object for which settings are being configured
 * @param options {Object}     Additional Application options
 */
class NoteConfig extends FormApplication {
	static get defaultOptions() {
	  const options = super.defaultOptions;
	  options.id = "note-config";
    options.title = "Journal Note Configuration";
	  options.template = "templates/scene/note-config.html";
	  options.width = 400;
	  return options;
  }

  /* -------------------------------------------- */

  /**
   * Construct and return the data object used to render the HTML template for this form application.
   * @return {Object}
   */
  getData() {
    const entry = game.journal.get(this.object.data.entryId) || {};
    return {
      entryId: entry._id,
      entries: game.journal.entities,
      object: duplicate(this.object.data),
      options: this.options,
      entryName: entry.name,
      entryIcons: CONFIG.JournalEntry.noteIcons,
      textAnchors: Object.entries(CONST.TEXT_ANCHOR_POINTS).reduce((obj, e) => {
        obj[e[1]] = e[0].titleCase();
        return obj;
      }, {})
    }
  }

  /* -------------------------------------------- */

  /**
   * This method is called upon form submission after form data is validated
   * @param event {Event}       The initial triggering submission event
   * @param formData {Object}   The object of validated form data with which to update the object
   * @private
   */
  _updateObject(event, formData) {
    if ( this.object.id ) {
      formData["id"] = this.object.id;
      this.object.update(formData);
    }
    else {
      this.object.constructor.create(formData);
      canvas.notes.preview.removeChildren();
    }
  }

  /* -------------------------------------------- */

  /**
   * Extend the logic applied when the application is closed to clear any preview notes
   * @return {Promise}
   */
  async close() {
    if ( !this.object.id ) canvas.notes.preview.removeChildren();
    return super.close();
  }
}

/**
 * Configuration sheet for the Drawing object
 * @type {FormApplication}
 *
 * @param drawing {Drawing}          The Drawing object being configured
 * @param options {Object}           Additional application rendering options
 * @param options.preview {Boolean}  Configure a preview version of the Drawing which is not yet saved
 */
class DrawingConfig extends FormApplication {
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "drawing-config",
      classes: ["sheet"],
      template: "templates/scene/drawing-config.html",
      width: 480,
      height: 360,
      configureDefault: false,
      tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "position"}]
    });
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    let title = "Drawing Configuration";
    return this.options.configureDefault ? "Default " + title : title;
  }

  /* -------------------------------------------- */

  /** @override */
  getData() {
    const author = game.users.get(this.object.data.author);

    // Submit text
    let submit;
    if ( this.options.configureDefault ) submit = "Configure Default";
    else submit = this.options.preview ? "Create" : "Update";

    // Return data
    return {
      author: author ? author.name : "",
      isDefault: this.options.configureDefault,
      fillTypes: this.constructor._getFillTypes(),
      fontFamilies: CONFIG.fontFamilies,
      object: duplicate(this.object.data),
      options: this.options,
      submitText: submit
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the names and labels of fill type choices which can be applied
   * @return {Object}
   * @private
   */
  static _getFillTypes() {
    return Object.entries(CONST.DRAWING_FILL_TYPES).reduce((obj, v) => {
      obj[v[1]] = v[0].titleCase();
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /** @override */
  _updateObject(event, formData) {
    if ( !this.object.owner ) throw new Error("You do not have the ability to configure this Drawing object.");

    // Configure the default Drawing settings
    if ( this.options.configureDefault ) {
      return game.settings.set("core", DrawingsLayer.DEFAULT_CONFIG_SETTING, formData);
    }

    // Create or update a Drawing
    if ( this.object.id ) {
      formData["id"] = this.object.id;
      this.object.update(formData);
    }
    else this.object.constructor.create(formData);
  }

  /* -------------------------------------------- */

  /** @override */
  close() {
    super.close();
    if ( this.preview ) {
      this.preview.removeChildren();
      this.preview = null;
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
	activateListeners(html) {
	  super.activateListeners(html);
    html.find('button[name="resetDefault"]').click(this._onResetDefaults.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Reset the user Drawing configuration settings to their default values
   * @param event
   * @private
   */
  _onResetDefaults(event) {
    event.preventDefault();
	  game.settings.set("core", DrawingsLayer.DEFAULT_CONFIG_SETTING, {});
	  this.object.data = canvas.drawings._getNewDrawingData({});
    this.render();
  }
}

/* -------------------------------------------- */

/**
 * An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Drawing objects.
 * @type {BasePlaceableHUD}
 */
class DrawingHUD extends BasePlaceableHUD {
  /**
   * Assign the default options which are supported by the entity edit sheet
   * @type {Object}
   */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "drawing-hud",
      template: "templates/hud/drawing-hud.html"
    });
  }

	/* -------------------------------------------- */

  /**
   * Extend the data object provided to render HTML for the Drawing HUD
   * @return {Object}
   */
  getData() {
    const data = super.getData();
    return mergeObject(data, {
      lockedClass: data.locked ? "active" : "",
      visibilityClass: data.hidden ? "active" : "",
    });
  }

	/* -------------------------------------------- */

	setPosition() {
	  let {x, y, width, height} = this.object.frame.getLocalBounds();
	  const position = {
	    width: width + 150,
      height: height + 20,
      left: x + this.object.data.x - 70,
      top: y + this.object.data.y + 20
    };
    this.element.css(position);
  }
}

/**
 * Light Source Configuration Sheet
 * @extends {FormApplication}
 *
 * @param light {AmbientLight} The AmbientLight object for which settings are being configured
 * @param options {Object}     LightConfig ui options (see Application)
 */
class LightConfig extends FormApplication {

  /** @override */
	static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "light-config",
      classes: ["sheet", "light-sheet"],
      title: "Light Source Configuration",
      template: "templates/scene/light-config.html",
      width: 480
    });
  }

  /* -------------------------------------------- */

  /** @override */
  getData() {
    return {
      object: duplicate(this.object.data),
      options: this.options,
      submitText: this.options.preview ? "Create" : "Update"
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _updateObject(event, formData) {
    if (!game.user.isGM) throw "You do not have the ability to configure an AmbientLight object.";
    if ( this.object.id ) {
      formData["id"] = this.object.id;
      this.object.update(formData);
    }
    else this.object.constructor.create(formData);
  }
}

/**
 * Ambient Sound Config Sheet
 * @extends {FormApplication}
 *
 * @param sound {AmbientSound}       The sound object being configured
 * @param options {Object}           Additional application rendering options
 * @param options.preview {Boolean}  Configure a preview version of a sound which is not yet saved
 */
class AmbientSoundConfig extends FormApplication {

  /** @override */
	static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "sound-config",
      classes: ["sheet", "sound-sheet"],
      title: "Ambient Sound Configuration",
      template: "templates/scene/sound-config.html"
    });
  }

  /* -------------------------------------------- */

  /** @override */
  getData() {
    return {
      object: duplicate(this.object.data),
      options: this.options,
      submitText: this.options.preview ? "Create" : "Update"
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _updateObject(event, formData) {
    if (!game.user.isGM) throw "You do not have the ability to configure an AmbientSound object.";
    if ( this.object.id ) {
      formData["id"] = this.object.id;
      this.object.update(formData);
    }
    else this.object.constructor.create(formData);
  }

  /* -------------------------------------------- */

  /** @override */
  close() {
    super.close();
    if ( this.preview ) {
      this.preview.removeChildren();
      this.preview = null;
    }
  }
}

/* -------------------------------------------- */

/**
 * Tile Config Sheet
 * @extends {FormApplication}
 *
 * @param {Tile} tile                The Tile object being configured
 * @param {Object} options           Additional application rendering options
 * @param {boolean} options.preview  Configure a preview version of a tile which is not yet saved
 */
class TileConfig extends FormApplication {

  /** @override */
	static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "tile-config",
      classes: ["sheet", "tile-sheet"],
      title: "Tile Configuration",
      template: "templates/scene/tile-config.html",
      width: 400
    });
  }

  /* -------------------------------------------- */

  /** @override */
  getData() {
    return {
      object: duplicate(this.object.data),
      options: this.options,
      submitText: this.options.preview ? "Create" : "Update"
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _updateObject(event, formData) {
    if (!game.user.isGM) throw "You do not have the ability to configure a Tile object.";
    if ( this.object.id ) {
      formData["id"] = this.object.id;
      this.object.update(formData);
    }
    else this.object.constructor.create(formData);
  }

  /* -------------------------------------------- */

  /** @override */
  close() {
    super.close();
    if ( this.preview ) {
      this.preview.removeChildren();
      this.preview = null;
    }
  }
}

/**
 * An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Tile objects.
 * @type {BasePlaceableHUD}
 */
class TileHUD extends BasePlaceableHUD {
  /**
   * Assign the default options which are supported by the entity edit sheet
   * @type {Object}
   */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "tile-hud",
      template: "templates/hud/drawing-hud.html"
    });
  }

	/* -------------------------------------------- */

  /**
   * Extend the data object provided to render HTML for the Tile HUD
   * @return {Object}
   */
  getData() {
    const data = super.getData();
    return mergeObject(data, {
      lockedClass: data.locked ? "active" : "",
      visibilityClass: data.hidden ? "active" : "",
    });
  }

	/* -------------------------------------------- */

	setPosition() {
	  const position = {
	    width: this.object.data.width + 140,
      height: this.object.data.height + 10,
      left: this.object.x - 70,
      top: this.object.y - 5
    };
    this.element.css(position);
  }
}

/**
 * A Token Configuration Application
 * @type {FormApplication}
 *
 * @param token {Token}      The Token object for which settings are being configured
 * @param options {Object}   TokenConfig ui options (see Application)
 *
 * @param options.configureDefault {Boolean}   Configure the default actor token on submit
 */
class TokenConfig extends FormApplication {

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "token-config",
      classes: ["sheet", "token-sheet"],
      template: "templates/scene/token-config.html",
      width: 480,
      height: "auto",
      tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "character"}]
    });
  }

	/* -------------------------------------------- */

  /**
   * Convenience access for the Token object
   * @type {Token}
   */
  get token() {
	  return this.object;
  }

  /* -------------------------------------------- */

  /**
   * Convenience access for the Token's linked Actor, if any
   * @type {Actor|null}
   */
  get actor() {
    return this.token.actor;
  }

	/* -------------------------------------------- */


  /** @override */
  get title() {
    if ( this.options.configureDefault ) return `[${game.i18n.localize("TOKEN.TitlePrototype")}] ${this.actor.name}`;
    return `${this.token.name}: ${game.i18n.localize("TOKEN.Title")}`;
  }

  /* -------------------------------------------- */

  /**
   * Convert Token display modes to an object of values and labels
   * @return {Object}
   */
  get displayModes() {
    const modes = {};
    for ( let [k, v] of Object.entries(CONST.TOKEN_DISPLAY_MODES) ) {
      modes[v] = k.replace("_", " ").titleCase();
    }
    return modes;
  }

  /* -------------------------------------------- */

  /** @override */
  async getData() {
    const actor = this.token.actor;
    let hasAlternates = actor ? actor.data.token.randomImg : false;
    return {
      cssClasses: [this.options.configureDefault ? "prototype" : null].filter(c => !!c).join(" "),
      isPrototype: this.options.configureDefault,
      hasAlternates: hasAlternates,
      alternateImages: hasAlternates ? await this._getAlternateTokenImages() : [],
      object: duplicate(this.token.data),
      options: this.options,
      gridUnits: canvas.ready ? canvas.scene.data.gridUnits : game.system.gridUnits,
      barAttributes: this.getBarAttributeChoices(),
      bar1: this.object.getBarAttribute("bar1"),
      bar2: this.object.getBarAttribute("bar2"),
      displayModes: this.displayModes,
      actors: game.actors.entities.reduce((actors, a) => {
        if ( !a.owner ) return actors;
        actors.push({'_id': a._id, 'name': a.name});
        return actors;
      }, []).sort((a, b) => a.name.localeCompare(b.name)),
      dispositions: Object.keys(CONST.TOKEN_DISPOSITIONS).reduce((obj, key) => {
        obj[game.i18n.localize(`TOKEN.${key}`)] = CONST.TOKEN_DISPOSITIONS[key];
        return obj;
      }, {}),
    };
  }

  /* -------------------------------------------- */

  /**
   * Inspect the Actor data model and identify the set of attributes which could be used for a Token Bar
   * @return {Array}
   */
  getBarAttributeChoices() {
    if ( !this.actor ) return [];
    const attributes = this._getBarAttributes(this.actor.data.data, []);
    attributes.bar = attributes.bar.map(v => v.join("."));
    attributes.bar.sort((a, b) => a.localeCompare(b));
    attributes.value = attributes.value.map(v => v.join("."));
    attributes.value.sort((a, b) => a.localeCompare(b));
    return {
      [game.i18n.localize("TOKEN.BarAttributes")]: attributes.bar,
      [game.i18n.localize("TOKEN.BarValues")]: attributes.value
    }
  }

  /* -------------------------------------------- */

  /**
   * Test whether an individual data object is a valid attribute - containing both a numeric value and max
   * @param {Object} data     The data object to search
   * @param {Array} path      The attribute path being recursed
   * @return {Object}         An object containing both bar and value attribute paths
   * @private
   */
  _getBarAttributes(data, path) {

    // Track the path and record found attributes
    path = path || [];
    const attributes = {
      "bar": [],
      "value": []
    };

    // Recursively explore the object
    for ( let [k, v] of Object.entries(data) ) {
      let p  = path.concat([k]);

      // Check objects for both a "value" and a "max"
      if ( v instanceof Object ) {
        const isBar = Number.isFinite(parseFloat(v.value)) && Number.isFinite(parseFloat(v.max));
        if ( isBar ) attributes.bar.push(p);
        else {
          const inner = this._getBarAttributes(data[k], p);
          attributes.bar.push(...inner.bar);
          attributes.value.push(...inner.value);
        }
      }

      // Otherwise identify numeric values
      else if ( Number.isFinite(v) ) {
        attributes.value.push(p);
      }
    }
    return attributes;
  }

  /* -------------------------------------------- */

  /**
   * Get an Object of image paths and filenames to display in the Token sheet
   * @return {Promise}
   * @private
   */
  async _getAlternateTokenImages() {
    const images = await this.actor.getTokenImages();
    return images.reduce((obj, i) => {
      obj[i] = i.split("/").pop();
      return obj;
    }, {});
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
	activateListeners(html) {
	  super.activateListeners(html);

    // Update bar data when the target attribute is changed
    html.find(".bar-attribute").change(this._onBarChange.bind(this));

    // Alternate image assignment
    html.find(".alternate-images").change(ev => ev.target.form.img.value = ev.target.value);

    // Handle Token assignment
    html.find('button.assign-token').click(this._onAssignToken.bind(this));
  }

  /* -------------------------------------------- */

  /** @override */
  _updateObject(event, formData) {

    // Verify the user has the ability to update a Token configuration
    if (!game.user.isTrusted || !this.token.owner) {
      throw new Error("You do not have permission to configure this token");
    }

    // Configure prototype Token data
    if ( formData.actorId && this.options.configureDefault ) {
      this._updateActorData(formData);
    }

    // Update a token on the canvas
    if ( this.token.parent !== null ) {
      this.token.update(formData);
    }
  }

  /* -------------------------------------------- */

  /**
   * Update certain fields of a linked actor token when token configuration is changed
   * @param tokenData {Object}    The new token data
   */
  _updateActorData(tokenData) {

    // Get the actor to update
    let actor = this.token.actor;
    if ( !actor ) return;
    let actorData = {};

    // Only update certain default token fields
    let update = {};
    for ( let [k, v] of Object.entries(tokenData) ) {
      if ( this.options.configureDefault || ["name", "img"].includes(k) || k.startsWith("bar.") ) {
        update[k] = v;
      }
    }
    actorData['token'] = mergeObject(actor.data.token, update, {inplace: false});

    // Update linked attribute bar values
    for ( let bar of ["bar1", "bar2"].filter(b => tokenData[b+".attribute"]) ) {
      let attr = tokenData[bar+'.attribute'];
      if ( hasProperty(actor.data.data, attr) ) {
        actorData[`data.${attr}.value`] = tokenData[bar+'.value'];
        actorData[`data.${attr}.max`] = tokenData[bar+'.max'];
      }
    }

    // Update the Actor
    actor.update(actorData);
  }

  /* -------------------------------------------- */

  /**
   * Handle Token assignment requests to update the default prototype Token
   * @private
   */
  _onAssignToken(event) {
    event.preventDefault();
    let tokens = canvas.ready ? canvas.tokens.controlled : [];
    if ( tokens.length !== 1 ) {
      html.shake(2, 20, 50);
      return;
    }

    // Update the controlled token
    const actor = this.actor;
    let token = duplicate(tokens.pop().data);
    token.tokenId = token.x = token.y = null;
    actor.update({token: token}).then(a => {
      ui.notifications.info(`Updated prototype Token configuration for ${actor.name}`);
    });
    this.close();
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the attribute bar in the drop-down selector to update the default current and max value
   * @private
   */
  async _onBarChange(ev) {
    const form = ev.target.form;
    const attr = this.object.getBarAttribute(null, {alternative: ev.target.value});
    const bar = ev.target.name.split(".").shift();
    form.querySelector(`input.${bar}-value`).value = attr.value;
    form.querySelector(`input.${bar}-max`).value = attr.type === "bar" ? attr.max : "";
  }
}

/**
 * An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Token objects.
 * This interface provides controls for visibility, attribute bars, elevation, status effects, and more.
 * @type {BasePlaceableHUD}
 */
class TokenHUD extends BasePlaceableHUD {
  /**
   * Assign the default options which are supported by the entity edit sheet
   * @type {Object}
   */
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "token-hud",
      template: "templates/hud/token-hud.html"
    });
  }

  /* -------------------------------------------- */

  /** @override */
  bind(object) {
    this._statusEffects = false;
    super.bind(object);
  }

  /* -------------------------------------------- */

  /** @override */
  setPosition() {
    const td = this.object.data;
    const ratio = canvas.dimensions.size / 100;
    const position = {
      width: td.width * 100,
      height: td.height * 100,
      left: this.object.x,
      top: this.object.y,
    };
    if ( ratio !== 1 ) position.transform = `scale(${ratio})`;
    this.element.css(position);
  }

  /* -------------------------------------------- */

  /** @override */
  getData() {
    const data = super.getData();
    const bar1 = this.object.getBarAttribute("bar1");
    const bar2 = this.object.getBarAttribute("bar2");
    return mergeObject(data, {
      canConfigure: game.user.isTrusted,
      canToggleCombat: ui.combat !== null,
      displayBar1: bar1 && (bar1.type !== "none"),
      bar1Data: bar1,
      displayBar2: bar2 && (bar2.type !== "none"),
      bar2Data: bar2,
      visibilityClass: data.hidden ? "active" : "",
      effectsClass: this._statusEffects ? "active" : "",
      combatClass: this.object.inCombat ? "active" : "",
      targetClass: this.object.targeted.has(game.user) ? "active" : "",
      statusEffects: CONFIG.statusEffects.map(src => {
        return {
          src: src,
          cssClass: [
            data.effects.includes(src) ? "active" : null,
            data.overlayEffect === src ? "overlay" : null
          ].filter(c => !!c)
        }
      })
    });
  }

  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {

    // Attribute Bars
    let attributeUpdate = this._onAttributeUpdate.bind(this);
    html.find(".attribute input").click(this._onAttributeClick).change(attributeUpdate);

    // Token Control Icons
    html.find(".config").click(this._onTokenConfig.bind(this));
    html.find(".combat").click(this._onToggleCombat.bind(this));
    html.find(".effects > img").click(this._onTokenEffects.bind(this));
    html.find(".visibility").click(this._onToggleVisibility.bind(this));
    html.find(".target").click(this._onToggleTarget.bind(this));

    // Status Effects Controls
    let effects = html.find(".status-effects");
    effects.on("click", ".effect-control", this._onToggleEffect.bind(this))
      .on("contextmenu", ".effect-control", this._onToggleOverlay.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle initial click to focus an attribute update field
   * @private
   */
  _onAttributeClick(event) {
    event.currentTarget.select();
  }

  /* -------------------------------------------- */

  /**
   * Handle attribute bar update
   * @private
   */
  _onAttributeUpdate(event) {
    event.preventDefault();

    // Acquire string input
    const input = event.currentTarget;
    let strVal = input.value.trim();
    let isDelta = strVal.startsWith("+") || strVal.startsWith("-");
    if (strVal.startsWith("=")) strVal = strVal.slice(1);
    let value = Number(strVal);

    // For attribute bar values, update the associated Actor
    let bar = input.dataset.bar;
    if (bar) {
      const actor = this.object.actor;
      const attr = this.object.getBarAttribute(bar);
      actor.modifyTokenAttribute(attr.attribute, value, isDelta, attr.type === "bar");
    }

    // Otherwise update the Token
    else {
      const current = this.object.data[input.name];
      this.object.update({[input.name]: isDelta ? current + value : value});
    }

    // Clear the HUD
    this.clear();
  }

  /* -------------------------------------------- */

  /**
   * Toggle Token combat state
   * @private
   */
  async _onToggleCombat(event) {
    event.preventDefault();
    await this.object.toggleCombat();
    event.currentTarget.classList.toggle("active", this.object.inCombat);
  }

  /* -------------------------------------------- */

  /**
   * Toggle Token visibility state
   * @private
   */
  async _onToggleVisibility(event) {
    event.preventDefault();
    await this.object.toggleVisibility();
    event.currentTarget.classList.toggle("active", this.object.data.hidden);
  }

  /* -------------------------------------------- */

  /**
   * Handle Token configuration button click
   * @private
   */
  _onTokenConfig(event) {
    event.preventDefault();
    this.object.sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Assign Token status effects
   * @private
   */
  _onTokenEffects(event) {
    event.preventDefault();
    this._statusEffects = !this._statusEffects;
    let btn = $(event.currentTarget.parentElement);
    let fx = btn.find(".status-effects");
    btn.toggleClass("active");
    fx.toggleClass("active");
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a token status effect icon
   * @private
   */
  _onToggleEffect(event) {
    event.preventDefault();
    let f = $(event.currentTarget);
    this.object.toggleEffect(f.attr("src"));
    f.toggleClass("active");
  }

  /* -------------------------------------------- */

  /**
   * Handle assigning a status effect icon as the overlay effect
   * @private
   */
  _onToggleOverlay(event) {
    event.preventDefault();
    let f = $(event.currentTarget);
    this.object.toggleOverlay(f.attr("src"));
    f.siblings().removeClass("overlay");
    f.toggleClass("overlay");
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the target state for this Token
   * @private
   */
  _onToggleTarget(event) {
    event.preventDefault();
    const btn = event.currentTarget;
    const token = this.object;
    const targeted = !token.isTargeted;
    token.setTarget(targeted, {releaseOthers: false});
    btn.classList.toggle("active", targeted);
  }
}

/**
 * Wall Configuration Sheet
 * @type {FormApplication}
 * @param object {Wall}        The Wall object for which settings are being configured
 * @param options {Object}     Additional options which configure the rendering of the configuration sheet.
 */
class WallConfig extends FormApplication {
	static get defaultOptions() {
	  const options = super.defaultOptions;
	  options.id = "wall-config";
    options.title = "Wall Configuration";
	  options.template = "templates/scene/wall-config.html";
	  options.width = 400;
	  options.editTargets = [];
	  return options;
  }

  /* -------------------------------------------- */

  /**
   * Provide a dynamically rendered title for the Wall Configuration sheet
   * @type {String}
   */
  get title() {
    let title = this.options.editTargets.length ? "WALLS.TitleMany" : "WALLS.Title";
    return game.i18n.localize(title);
  }

  /* -------------------------------------------- */

  /**
   * Construct and return the data object used to render the HTML template for this form application.
   * @return {Object}
   */
  getData() {
    return {
      object: duplicate(this.object.data),
      options: this.options,
      moveTypes: Object.keys(CONST.WALL_MOVEMENT_TYPES).reduce((obj, key) => {
        let k = CONST.WALL_MOVEMENT_TYPES[key];
        obj[k] = key.titleCase();
        return obj;
      }, {}),
      senseTypes: Object.keys(CONST.WALL_SENSE_TYPES).reduce((obj, key) => {
        let k = CONST.WALL_SENSE_TYPES[key];
        obj[k] = key.titleCase();
        return obj;
      }, {}),
      dirTypes: Object.keys(CONST.WALL_DIRECTIONS).reduce((obj, key) => {
        let k = CONST.WALL_DIRECTIONS[key];
        obj[k] = key.titleCase();
        return obj;
      }, {}),
      doorTypes: Object.keys(CONST.WALL_DOOR_TYPES).reduce((obj, key) => {
        let k = CONST.WALL_DOOR_TYPES[key];
        obj[k] = key.titleCase();
        return obj;
      }, {}),
      doorStates: Object.keys(CONST.WALL_DOOR_STATES).reduce((obj, key) => {
        let k = CONST.WALL_DOOR_STATES[key];
        obj[k] = key.titleCase();
        return obj;
      }, {}),
      isDoor: this.object.data.door > CONST.WALL_DOOR_TYPES.NONE
    }
  }

  /* -------------------------------------------- */

  /**
   * This method is called upon form submission after form data is validated
   * @param event {Event}       The initial triggering submission event
   * @param formData {Object}   The object of validated form data with which to update the object
   * @private
   */
  _updateObject(event, formData) {

    // Update many walls
    const wallIds = this.options.editTargets;
    if ( wallIds.length ) {
      const updateData = canvas.scene.data.walls.reduce((arr, w) => {
        if ( wallIds.includes(w._id) ) arr.push(mergeObject(w, formData, {inplace: false}));
        return arr;
      }, []);
      return canvas.scene.updateManyEmbeddedEntities("Wall", updateData);
    }

    // Update single wall
    else this.object.update(formData);
  }
}

/**
 * The End User License Agreement
 * Display the license agreement and prompt the user to agree before moving forwards
 * @type {Application}
 */
class EULA extends Application {
	static get defaultOptions() {
	  const options = super.defaultOptions;
	  options.id = "eula";
	  options.template = "templates/setup/eula.html";
	  options.title = "End User License Agreement";
	  options.width = 600;
	  return options;
  }

  /* -------------------------------------------- */

  /** @override */
  async getData() {
	  let html = await fetch("license.html").then(r => r.text());
	  return {
      html: html
    }
  }

  /* -------------------------------------------- */

  /** @override */
	async _renderOuter(options) {
	  const id = this.id;
	  const classes = Array.from(options.classes || this.options.classes).join(" ");

	  // Override the normal window app wrapper so it cannot be closed or minimized
	  const parsed = $.parseHTML(`<div id="${id}" class="app window-app ${classes}" data-appid="${this.appId}">
      <header class="window-header flexrow">
          <h4 class="window-title">${this.title}</h4>
      </header>
      <section class="window-content"></section>
    </div>`);
	  const html = $(parsed[0]);

    // Make the outer window draggable
    const header = html.find('header')[0];
    new Draggable(this, html, header, this.options.resizable);

    // Set the outer frame z-index
    if ( Object.keys(ui.windows).length === 0 ) _maxZ = 100;
    html.css({zIndex: Math.min(++_maxZ, 9999)});
    return html;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("#sign").click(this._onAccept.bind(this));
    html.find("#decline").click(this._onDecline.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle acceptance of the EULA by checking the agreement box and clicking the agree button
   * @param {MouseEvent} event    The orginating click event
   */
  async _onAccept(event) {
    event.preventDefault();
    const button = event.currentTarget;
    button.disabled = true;

    // Require the agreement box to have been checked
    if ( !event.target.form.agree.checked ) {
      ui.notifications.error(`You must agree to the ${this.options.title} before proceeding.`);
      button.disabled = false;
      return false;
    }

    // Submit the acceptance
    const request = await SetupConfiguration.post({action: "eulaSign", signed: true});
    const response = await request.json();

    // Display an error message if one occurred
    if ( response.error ) {
      return ui.notifications.error(response.error);
    }

    // Display a notification and close the window
    ui.notifications.info(response.message);
    return this.close();
  }

  /* -------------------------------------------- */

  /**
   * Handle refusal of the EULA by checking the decline button
   * @param {MouseEvent} event    The originating click event
   */
  _onDecline(event) {
    event.preventDefault();
    event.currentTarget.disabled = true;
    const quitURL = ROUTE_PREFIX ? `/${ROUTE_PREFIX}/quit` : "/quit";
    fetch(quitURL, {method: "POST"}).then(response => {
      window.location.href = CONST.WEBSITE_URL;
    });
  }
}

/**
 * The Package Configuration setup application
 * @type {Application}
 */
class SetupConfigurationForm extends FormApplication {
  constructor({systems, modules, worlds, currentWorld=null}={}) {
    super({});

    /**
     * Valid Game Systems to choose from
     * @type {Array}
     */
    this.systems = systems;

    /**
     * Install Modules to configure
     * @type {Array}
     */
    this.modules = modules;

    /**
     * The Array of available Worlds to load
     * @type {Array}
     */
    this.worlds = worlds;

    /**
     * The currently inspected World
     * @type {String}
     */
    this.currentWorld = currentWorld;

    /**
     * The currently viewed tab
     * @type {String}
     */
    this._tab = "worlds";

    /**
     * Track the button elements which represent updates for different named packages
     * @type {HTMLElement|null}
     */
    this._progressButton = null;

    // Activate socket listeners used in the setup view
    this.activateSocketListeners();
  }

	/* -------------------------------------------- */

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "setup-configuration",
      classes: ["dark"],
      template: "templates/setup/setup-config.html",
      width: 960,
      height: 720,
      popOut: false,
      scrollY: ["#world-list", "#system-list", "#module-list"],
      tabs: [{navSelector: ".tabs", contentSelector: ".content", initial: "worlds"}]
    });
  }

	/* -------------------------------------------- */

  /** @override */
  getData() {

    // Configuration options
    const options = game.data.options;
    options.upnp = options.upnp !== false;

    return {
      systems: this.systems,
      modules: this.modules,
      options: options,
      adminKeyPlaceholder: options.adminKey ? CONST.PASSWORD_SAFE_STRING : "",
      worlds: this.worlds.map(w => {
        w.active = w.id === this.current;
        w.shortDesc = w.data.description.length > 250 ? w.data.description.slice(0, 250)+" ..." : w.data.description;
        return w;
      }),
      world: this.worlds.find(w => w.id === this.current),
      current: this.current,
      availabilityLabels: {
        [CONST.PACKAGE_AVAILABILITY_CODES.REQUIRES_UPDATE]: game.i18n.localize("SETUP.RequireUpdate"),
        [CONST.PACKAGE_AVAILABILITY_CODES.REQUIRES_SYSTEM]: game.i18n.localize("SETUP.RequireSystem"),
        [CONST.PACKAGE_AVAILABILITY_CODES.REQUIRES_DEPENDENCY]: game.i18n.localize("SETUP.RequireDep"),
        [CONST.PACKAGE_AVAILABILITY_CODES.REQUIRES_CORE]: game.i18n.localize("SETUP.RequireCore")
      }
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);

    // Confirm Admin Changes
    html.find("button#admin-save").click(this._onSaveAdmin.bind(this));

    // Create or Edit World
    html.find("button#create-world, button.edit-world").click(this._onWorldConfig.bind(this));

    // Generic Submission Button
    html.find('button[type="submit"]').click(this._onSubmitButton.bind(this));

    // Install Package
    html.find("button.install-package").click(this._onInstallPackage.bind(this));

    // Update Package
    html.find("button.update").click(this._onUpdatePackage.bind(this));

    // Update All Packages
    html.find("button.update-packages").click(this._onUpdatePackages.bind(this));

    // Uninstall Package
    html.find("button.uninstall").click(this._onUninstallPackage.bind(this));

    // Update Core
    html.find("button#update-core").click(this._onCoreUpdate.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Post the setup configuration form
   * @param {Object} data
   * @return {Promise}
   * @private
   */
  async _post(data) {
    const formData = new FormData(this.form);
    for ( let [k, v] of Object.entries(data) ) {
      formData.set(k, v);
    }
    const request = await fetch(SetupConfiguration.setupURL, { method: "POST", body: formData });

    // Handle redirects
    if ( request.redirected ) window.location.href = request.url;

    // Process response
    const response = await request.json();
    if ( response.error ) {
      const err = new Error(response.error);
      err.stack = response.stack;
      ui.notifications.error(response.error);
      throw err;
    }
    return response
  }

  /* -------------------------------------------- */

  /**
   * Reload the setup view by re-acquiring setup data and re-rendering the form
   * @private
   */
  _reload() {
    Game.getSetupData(game.socket).then(setupData => {
      mergeObject(game.data, setupData);
      mergeObject(this, setupData);
      this.render();
    });
  }

  /* -------------------------------------------- */

  /**
   * Generic button handler for the setup form which submits a POST request including any dataset on the button itself
   * @param {MouseEvent} event    The originating mouse click event
   * @return {Promise}
   * @private
   */
  async _onSubmitButton(event) {
    event.preventDefault();
    const button = event.currentTarget;
    button.disabled = true;
    const data = duplicate(button.dataset);
    data.action = button.value;
    const response = await this._post(data);
    button.disabled = false;
    return response;
  }

  /* -------------------------------------------- */

  /**
   * Confirm user intent when saving admin changes to the application configuration
   * @param {MouseEvent} event    The originating mouse click event
   * @return {Promise}
   * @private
   */
  async _onSaveAdmin(event) {
    event.preventDefault();
    event.stopImmediatePropagation();
    return Dialog.confirm({
      title: "Save Application Configuration",
      content: `<p class="notification">Modifying these configuration options will cause the server to be shut down and 
            require that you restart the server manually. Do you wish to continue?</p>`,
      yes: () => {
        this._post({action: "adminConfigure"}).then(() => {
          this.element.html(`<p>The Foundry VTT server has been terminated to apply changes. You must restart manually.</p>`);
        })
      },
      defaultYes: false
    });
  }

  /* -------------------------------------------- */

  /**
   * Begin creation of a new World using the config form
   * @param {MouseEvent} event    The originating mouse click event
   * @private
   */
  _onWorldConfig(event) {
    event.preventDefault();
    const button = event.currentTarget;
    let data = {};
    const options = {};
    if ( button.dataset.world ) {
      data = game.data.worlds.find(w => w.data.name === button.dataset.world);
    } else {
      options.create = true;
    }
    new WorldConfig(data, options).render(true)
  }

  /* -------------------------------------------- */
  /*  Package Management                          */
  /* -------------------------------------------- */

  /**
   * Handle install button clicks to add new packages
   * @param {Event} event
   * @private
   */
  _onInstallPackage(event) {
    event.preventDefault(event);

    // Get the package type
    let button = event.currentTarget;
    const list = button.closest(".tab").querySelector(".package-list");
    const type = list.dataset.packageType;

    // Create a simple installation form
    const form = `
    <form>
      <p class="notes">${game.i18n.localize("SETUP.InstallHint")}</p>
      <div class="form-group">
        <label>${game.i18n.localize("SETUP.ManifestURL")}:</label>
        <input type="text" name="manifestURL" placeholder="https://path/to/manifest.json" required/>
      </div>
    </form>`;

    // Display the form as a dialog
    new Dialog({
      title: game.i18n.localize("SETUP.Install"+type.titleCase()),
      content: form,
      buttons: {
        install: {
          icon: '<i class="fas fa-download"></i>',
          label: game.i18n.localize("Install"),
          callback: async html => {
            let manifestURL = html.find('input[name="manifestURL"]').val();
            this._progressButton = button;
            const response = await SetupConfiguration.installPackage({type, manifest: manifestURL});
            this._progressButton = null;
            if ( response.error ) {
              ui.notifications.error(game.i18n.localize("SETUP.InstallFailure"));
              const err = new Error(response.error);
              err.stack = response.stack;
              console.error(err);
            } else {
              ui.notifications.info(`${type.titleCase()} ${response.name} ${game.i18n.localize("SETUP.InstallSuccess")}`);
            }
            this._reload();
          }
        },
        cancel: {
          icon: '<i class="fas fa-times"></i>',
          label: game.i18n.localize("Cancel")
        },
      },
      default: "install"
    }).render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle update button press for a single Package
   * @param {Event} event
   * @private
   */
  _onUpdatePackage(event) {
    event.preventDefault();

    // Disable the button
    const button = event.currentTarget;
    button.disabled = true;

    // Obtain the package metadata
    let li = button.closest("li.package");
    let id = li.dataset.packageId;
    let type = li.closest("ul.package-list").dataset.packageType;

    // Inquire with the server for updates
    if ( button.dataset.state === "check" ) {
      return this._updateCheckOne(type, id, button);
    }

    // Execute upon an update
    if ( button.dataset.state === "update" ) {
      return this._updateDownloadOne(type, id, button);
    }
  }

  /* -------------------------------------------- */

  /**
   * Execute upon an update check for a single Package
   * @param {string} type         The package type to check
   * @param {string} name         The package name to check
   * @param {HTMLElement} button  The update button for the package
   * @return {Promise<boolean>}
   * @private
   */
  async _updateCheckOne(type, name, button) {
    const manifest = await SetupConfiguration.checkPackage({type, name});
    if ( manifest.isUpgrade ) {
      button.innerHTML = '<i class="fas fa-download"></i><label>Update</label>';
      button.dataset.state = "update";
      button.dataset.manifest = manifest.manifest;
      button.disabled = false;
    } else {
      button.innerHTML = '<i class="fas fa-check"></i><label>Updated</label>';
      button.dataset.state = "none";
      button.disabled = true;
    }
    return manifest.isUpgrade;
  }

  /* -------------------------------------------- */

  /**
   * Execute upon an update download for a single Package
   * Returns a Promise which resolves once the download has successfully started
   * @param {string} type         The package type to install
   * @param {string} name         The package name to install
   * @param {HTMLElement} button  The Download button
   * @return {Promise}
   * @private
   */
  async _updateDownloadOne(type, name, button) {
    this._progressButton = button;
    const manifest = await SetupConfiguration.installPackage({type, name, manifest: button.dataset.manifest});
    this._progressButton = null;
    if ( manifest.error ) {
      ui.notifications.error(game.i18n.localize("SETUP.UpdateFailure"));
      const err = new Error(manifest.error);
      err.stack = manifest.stack;
      console.error(err);
      button.innerHTML = '<i class="fas fa-times"></i><label>Failed</label>';
    }
    else if ( manifest.installed ) {
      const pack = game.data[type+"s"].find(p => p.data.name === manifest.name);
      mergeObject(pack.data, manifest);
      ui.notifications.info(`${type.titleCase()} ${name} ${game.i18n.localize("SETUP.UpdateSuccess")}.`);
      this.render();
    }
    return manifest;
  }

  /* -------------------------------------------- */

  /**
   * Handle uninstall button clicks to remove existing packages
   * @param {Event} event
   * @private
   */
  _onUninstallPackage(event) {
    event.preventDefault();

    // Disable the button
    let button = event.currentTarget;
    button.disabled = true;

    // Obtain the package metadata
    let li = $(button).parents("li.package");
    let name = li.data("packageId");
    let type = li.parents("ul.package-list").data("packageType");

    // Get the target package
    let collection = game.data[type+"s"];
    let idx = collection.findIndex(p => p.id === name);
    let pack = collection[idx];

    // Confirm deletion request
    let msg = `<p>Are you sure you want to delete <strong>${pack.data.title}</strong>?</p>
               <p><strong>Warning:</strong> This operation cannot be un-done.</p>`;
    Dialog.confirm({
      title: `Delete ${type.titleCase()}: ${pack.data.title}`,
      content: msg,
      yes: async () => {
        const response = await SetupConfiguration.uninstallPackage({type, name});
        if ( response.error ) {
          ui.notifications.error(game.i18n.localize("SETUP.UninstallFailure"));
          const err = new Error(response.error);
          err.stack = response.stack;
          console.error(err);
        } else {
          ui.notifications.info(`${type.titleCase()} ${name} ${game.i18n.localize("SETUP.UninstallSuccess")}.`);
          collection.splice(idx, 1);
        }
        this.render();
      },
      no: () => button.disabled = false
    });
  }

  /* -------------------------------------------- */

  /**
   * Execute upon an update-all workflow to update all packages of a certain type
   * @param {Event} event
   * @private
   */
  async _onUpdatePackages(event) {
    event.preventDefault();
    let button = event.currentTarget;
    button.disabled = true;
    let ol = $(".tab.active .package-list");
    let type = ol.data("packageType");

    // Get Packages
    let packages = [];
    ol.children(".package").each((i, li) => {
      packages.push({id: li.dataset.packageId, status: "none", button: li.querySelector("button.update")});
    });

    // Check updates
    let checks = packages.map(p => {
      return this._updateCheckOne(type, p.id, p.button).then(available => p.available = available);
    });
    await Promise.all(checks);

    // Execute updates one at a time
    for (let p of packages.filter(p => p.available)) {
      await this._updateDownloadOne(type, p.id, p.button).catch(err => {});
    }
  }

  /* -------------------------------------------- */
  /*  Core Software Update                        */
  /* -------------------------------------------- */

  /**
   * Handle button clicks to update the core VTT software
   * @param {Event} event
   * @private
   */
  async _onCoreUpdate(event) {
    const button = event.currentTarget;
    const form = button.form;
    const label = button.children[1];
    button.disabled = true;
    form.updateKey.disabled = true;

    // Condition next step based on action
    if ( button.value === "updateDownload" ) {
      this._progressButton = button;
    }

    // Post the update request
    const response = await this._post({action: button.value, updateKey: form.updateKey.value}).catch(err => {
      button.disabled = false;
      form.updateKey.disabled = false;
      throw err;
    });

    // Proceed to download step
    if ( button.value === "updateCheck" ) {
      ui.notifications.info(`An update to Foundry Virtual Tabletop version ${response.version} is available!`);
      label.textContent = `Download Update ${response.version}`;
      button.value = "updateDownload";
      button.disabled = false;
      if ( response.notes ) new UpdateNotes(response).render(true);
    } else {
      ui.notifications.info(response.message);
      this.close();
    }
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /**
   * Activate socket listeners related to the Setup Configuration form
   */
  activateSocketListeners() {
    const socket = game.socket;
    socket.on("progress", data => {
      this._updateProgressBar(data);
      this._updateProgressButton(data);
    })
  }

  /* -------------------------------------------- */

  _updateProgressBar(data) {
    const tabName = data.type === "core" ? "configuration" : data.type+"s";
    const tab = this.element.find(`.tab[data-tab="${tabName}"]`);
    if ( !tab.hasClass("active") ) return;
    const progress = tab.find(".progress-bar");
    progress.css("visibility", "visible");

    // Update bar and label position
    let pl = `${data.pct}%`;
    let bar = progress.children(".bar");
    bar.css("width", pl);
    let barLabel = progress.children(".pct");
    barLabel.text(pl);
    barLabel.css("left", pl);
  }

  /* -------------------------------------------- */

  /**
   * Update installation progress for a particular button which triggered the action
   * @param {Object} data   The progress update data
   * @private
   */
  _updateProgressButton(data) {
    const button = this._progressButton;
    if ( !button ) return;
    button.disabled = data.pct < 100;

    // Update Icon
    const icon = button.querySelector("i");
    if ( data.pct < 100 ) icon.className = "fas fa-spinner fa-pulse";

    // Update label
    const label = button.querySelector("label");
    if ( label ) label.textContent = data.step;
    else button.textContent = " " + data.step;
  }
}

/**
 * The client side Updater application
 * This displays the progress of patching/update progress for the VTT
 * @type {Application}
 */
class UpdateNotes extends Application {
  constructor(target, options) {
    super(options);
    this.target = target;
  }

  /* ----------------------------------------- */

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "update-notes",
      template: "templates/setup/update-notes.html",
      width: 600
    });
  }

  /* ----------------------------------------- */

  /** @override */
  get title() {
    return `Update Notes - Foundry Virtual Tabletop ${this.target.version}`;
  }

  /* ----------------------------------------- */

  /** @override */
  async getData() {
    return {
      notes: this.target.notesHTML
    }
  }

  /* ----------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("#return").click(ev => this.close());
  }
}

/**
 * The User Management setup application
 * @type {Application}
 */
class UserManagement extends FormApplication {

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "manage-players",
      classes: ["dark"],
      template: "templates/setup/users.html",
      popOut: false
    });
  }

	/* -------------------------------------------- */

  /** @override */
  getData() {
    return {
      user: game.user,
      users: this.object.entities.map(u => u.data),
      roles: CONST.USER_ROLES,
      options: this.options
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);

    // Add new user to the form
    html.find('.create-user').click(ev => this._onUserCreate(ev));

    // Remove user from the form
    html.on('click', '.user-delete', ev => this._onUserDelete(ev));
  }

  /* -------------------------------------------- */

  /** @override */
  _onSubmit(event) {}

  /* -------------------------------------------- */

  /**
   * Handle new user creation event
   * @private
   */
  async _onUserCreate(event) {
    event.preventDefault();
    event.currentTarget.disabled = true;

    // Create the new User
    const user = await User.create({
      name: "Player "+$('.player').length,
      role: CONST.USER_ROLES.PLAYER
    });

    // Render the User's HTML
    const html = await renderTemplate('templates/setup/player-create.html', {
      user: user.data,
      roles: CONST.USER_ROLES
    });

    // Append the player to the list and restore the button
    $("#player-list").append(html);
    event.currentTarget.disabled = false;
  }

  /* -------------------------------------------- */

  /**
   * Handle user deletion event
   * @private
   */
  _onUserDelete(event) {
    event.preventDefault();
    let button = $(event.currentTarget),
      li = button.parents('.player'),
      user = game.users.get(li.attr("data-user-id"));

    // Craft a message
    let message = "<h3>Are you sure?</h3><p>This user will be deleted from the game world.</p>";
    if (user.isGM) message += '<p class="warning"><strong>You are about to delete a Game-Master user!</strong></p>';

    // Render a confirmation dialog
    new Dialog({
      title: `Delete User ${user.name}?`,
      content: message,
      buttons: {
        yes: {
          icon: '<i class="fas fa-trash"></i>',
          label: "Delete",
          callback: () => {
            user.delete();
            li.slideUp(200, () => li.remove());
          }
        },
        no: {
          icon: '<i class="fas fa-times"></i>',
          label: "Cancel"
        },
      },
      default: 'yes'
    }).render(true);
  }
}

/**
 * The World Management setup application
 * @type {Application}
 */
class WorldConfig extends FormApplication {
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "world-config",
      template: "templates/setup/world-config.html",
      width: 600,
      height: 600,
      create: false
    });
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return this.options.create ? "Create New World" : `Edit World: ${this.object.data.title}`;
  }

  /* -------------------------------------------- */

  /** @override */
  getData() {
    return {
      world: this.object,
      systems: game.data.systems.filter(s => (this.object.data && (this.object.data.system === s)) || !s.data.unavailable),
      isCreate: this.options.create,
      submitText: this.options.create ? "Create World" : "Update World"
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  async _onSubmit(event) {
    event.preventDefault();
    const FD = this._getFormData(event.target || this.form);
    if ( this.options.create ) FD.set("action", "createWorld");
    else {
      FD.set("action", "editWorld");
      FD.set("id", this.object.data.name);    // TODO: Remove the "id" any version after 0.5.2
      FD.set("name", this.object.data.name);
    }

    // Dispatch the POST request
    const response = await fetch(window.location.pathname, {
      method: "POST",
      body: FD
    }).then(r => r.json());

    // Display error messages
    if ( response.error ) return ui.notifications.error(response.error);

    // Handle successful creation
    const world = game.data.worlds.find(w => w.id === response.id);
    if ( world ) mergeObject(world.data, response.data);
    else game.data.worlds.push(response);
    ui.setup.render();
    this.close();
  }

  /* -------------------------------------------- */

  /** @override */
  _onEditorSave(target, element, content) {
    element.innerHTML = content;
    let event = new Event("mcesave");
    this._onSubmit(event);
    this.editors[target].active = false;
    this.editors[target].mce.destroy();
    this.editors[target].mce = null;
  }
}

/**
 * Render the Sidebar container, and after rendering insert Sidebar tabs
 */
class Sidebar extends Application {
  constructor(...args) {
    super(...args);

    /**
     * Sidebar application instances
     * @type {Array}
     */
    this.apps = [];

    /**
     * Track whether the sidebar container is currently collapsed
     * @type {Boolean}
     */
    this._collapsed = false;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "sidebar",
      template: "templates/sidebar/sidebar.html",
      popOut: false,
      width: 300,
      tabs: [{navSelector: ".tabs", contentSelector: "#sidebar", initial: "chat"}]
    });
  }

  /* -------------------------------------------- */

  /**
   * Return the name of the active Sidebar tab
   * @type {string}
   */
  get activeTab() {
    return this._tabs[0].active;
  }

  /* -------------------------------------------- */

  /**
   * Return an Array of pop-out sidebar tab Application instances
   * @type {Array}
   */
  get popouts() {
    return this.apps.map(a => a._popout).filter(p => p);
  }

	/* -------------------------------------------- */
  /*  Rendering
	/* -------------------------------------------- */

  /** @override */
  getData() {
    return {user: game.user};
  }

 	/* -------------------------------------------- */

  /** @override */
	async _render(...args) {

	  // Extend the default rendering function to re-render all contained sidebar tabs
	  await super._render(...args);

    // Define the sidebar tab names to render
	  const tabs = ["chat", "combat", "actors", "items", "journal", "tables", "playlists", "compendium", "settings"];
	  if ( game.user.isGM ) tabs.push("scenes");

    // Render sidebar Applications
    for ( let name of tabs ) {
      const app = ui[name];
      try {
        await app._render(true, {})
      } catch(err) {
        console.error(`Failed to render Sidebar tab ${name}`);
        console.error(err);
      }
    }

    // Activate the chat log
    this.activateTab("chat");
  }

	/* -------------------------------------------- */
  /*  Methods
	/* -------------------------------------------- */

  /**
   * Activate a Sidebar tab by it's name
   * @param {String} tabName      The tab name corresponding to it's "data-tab" attribute
   */
  activateTab(tabName) {
    this._tabs[0].activate(tabName, {triggerCallback: true});
  }

	/* -------------------------------------------- */

  /**
   * Expand the Sidebar container from a collapsed state.
   * Take no action if the sidebar is already expanded.
   */
  expand() {
    if ( !this._collapsed ) return;
    const sidebar = this.element;
    const tab = sidebar.find(".sidebar-tab.active");
    const icon = sidebar.find("#sidebar-tabs a.collapse i");

    // Animate the sidebar expansion
    tab.hide();
    sidebar.animate({width: this.options.width, height: this.position.height}, 150, () => {
      sidebar.css({width: "", height: ""});
      icon.removeClass("fa-caret-left").addClass("fa-caret-right");
      tab.fadeIn(250, () => tab.css("display", ""));
      this._collapsed = false;
      sidebar.removeClass("collapsed");
      Hooks.callAll("sidebarCollapse", this, this._collapsed);
    })
  }

	/* -------------------------------------------- */

  /**
   * Collapse the sidebar to a minimized state.
   * Take no action if the sidebar is already collapsed.
   */
  collapse() {
    if ( this._collapsed ) return;
    const sidebar = this.element;
    const tab = sidebar.find(".sidebar-tab.active");
    const icon = sidebar.find("#sidebar-tabs a.collapse i");

    // Animate the sidebar collapse
    tab.fadeOut(250, () => {
      sidebar.animate({width: 30, height: 370}, 150, () => {
        icon.removeClass("fa-caret-right").addClass("fa-caret-left");
        this._collapsed = true;
        sidebar.addClass("collapsed");
        tab.css("display", "");
        Hooks.callAll("sidebarCollapse", this, this._collapsed);
      })
    })
  }

	/* -------------------------------------------- */
  /*  Event Listeners and Handlers
	/* -------------------------------------------- */

  /** @inheritdoc */
	activateListeners(html) {
	  super.activateListeners(html);

    // Right click pop-out
    const nav = this._tabs[0]._nav;
    nav.addEventListener('contextmenu', this._onRightClickTab.bind(this));

    // Toggle Collapse
    const collapse = nav.querySelector(".collapse");
    collapse.addEventListener("click", this._onToggleCollapse.bind(this));
  }

	/* -------------------------------------------- */

  /** @override */
  _onChangeTab(event, tabs, active) {
    const app = ui[active];
    if ( (active === "chat") && app ) app.scrollBottom();
    if ( this._collapsed ) {
      if ( active !== "chat") app.renderPopout(app);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle right-click events on tab controls to trigger pop-out containers for each tab
   * @param {Event} event     The originating contextmenu event
   * @private
   */
  _onRightClickTab(event) {
    const li = event.target.closest(".item");
    if ( !li ) return;
    event.preventDefault();
    const tabName = li.dataset.tab;
    const tabApp = ui[tabName];
    if ( tabName !== "chat" ) tabApp.renderPopout(tabApp);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling of the Sidebar container's collapsed or expanded state
   * @param {Event} event
   * @private
   */
  _onToggleCollapse(event) {
    event.preventDefault();
    if ( this._collapsed ) this.expand();
    else this.collapse();
  }
}

/**
 * An abstract pattern followed by the different tabs of the sidebar
 * @type {Application}
 * @abstract
 */
class SidebarTab extends Application {
  constructor(...args) {
    super(...args);

    /**
     * The base name of this sidebar tab
     * @type {string}
     */
    this.tabName = this.constructor.defaultOptions.id;

    /**
     * A reference to the pop-out variant of this SidebarTab, if one exists
     * @type {SidebarTab}
     * @private
     */
    this._popout = null;

    /**
     * Denote whether or not this is the original version of the sidebar tab, or a pop-out variant
     * @type {SidebarTab}
     */
    this._original = null;
  }

	/* -------------------------------------------- */

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    popOut: false,
      width: 300,
      baseApplication: "SidebarTab"
    });
  }

	/* -------------------------------------------- */
  /*  Rendering                                   */
	/* -------------------------------------------- */

  /** @override */
	async _renderInner(data) {
	  let html = await super._renderInner(data);
	  if ( ui.sidebar && ui.sidebar.activeTab === this.options.id ) html.addClass('active');
	  if ( this.popOut ) html.removeClass("tab");
	  return html;
  }

	/* -------------------------------------------- */

  /** @override */
	async _render(...args) {

	  // Trigger rendering of pop-out tabs
    if ( this._popout ) {
      this._popout.render(...args);
    }

	  // Resize pop-out tabs
	  if ( this._original ) {
	    this.position.height = "auto";
    }

	  // Parent rendering logic
    return super._render(...args);
  }

	/* -------------------------------------------- */
	/*  Methods                                     */
	/* -------------------------------------------- */

  /**
   * Activate this SidebarTab, switching focus to it
   */
  activate() {
    ui.sidebar.activateTab(this.tabName);
  }

	/* -------------------------------------------- */

  /** @override */
	async close() {
	  if ( this.popOut ) {
	    const base = this._original;
	    base._popout = null;
	    return super.close();
    }
    return false;
  }

	/* -------------------------------------------- */

  /**
   * Create a second instance of this SidebarTab class which represents a singleton popped-out container
   * @return {SidebarTab}   The popped out sidebar tab instance
   */
  createPopout() {
    if ( this._popout ) return this._popout;
    const pop = new this.constructor({
      id: `${this.options.id}-popout`,
      classes: this.options.classes.concat([["sidebar-popout"]]),
      popOut: true
    });
    this._popout = pop;
    pop._original = this;
    return pop;
  }

	/* -------------------------------------------- */

  /**
   * Render the SidebarTab as a pop-out container
   */
	renderPopout() {
	  const pop = this.createPopout();
	  pop.render(true);
  }
}

/**
 * A shared pattern for the sidebar directory which Actors, Items, and Scenes all use
 * @extends {SidebarTab}
 */
class SidebarDirectory extends SidebarTab {
  constructor(options) {
    super(options);

    /**
     * References to the set of Entities which are displayed in the Sidebar
     * @type {Array}
     */
    this.entities = null;

    /**
     * Reference the set of Folders which exist in this Sidebar
     * @type {Array}
     */
    this.folders = null;

    /**
     * The search string currently being filtered for
     * @type {String}
     */
    this.searchString = null;

    /**
     * The timestamp of the previous search character entry
     * @type {Number}
     * @private
     */
    this._searchTime = 0;

    // Initialize sidebar content
    this.initialize();

    // Record the directory as an application of the collection if it is not a popout
    if ( !this.options.popOut ) this.constructor.collection.apps.push(this);
  }

	/* -------------------------------------------- */

  /** @override */
	static get defaultOptions() {
	  const el = this.entity.toLowerCase();
	  return mergeObject(super.defaultOptions, {
      id: `${el}s`,
      template: `templates/sidebar/${el}-directory.html`,
      title: `${this.entity}s Directory`,
      dragItemSelector: ".directory-item",
      renderUpdateKeys: ["name", "img", "permission", "sort", "folder"],
      canDrag: game.user.isGM,
      height: "auto",
      scrollY: ["ol.directory-list"]
    });
  }

	/* -------------------------------------------- */

  /**
   * The named entity which this Sidebar Directory contains
   * @type {string}
   */
	static get entity() {
	  throw "A SidebarDirectory subclass must define the entity name which it displays."
  }

	/* -------------------------------------------- */

  /**
   * The Entity collection which this Sidebar Directory contains
   * @type {Collection}
   */
  static get collection() {
    throw "A SidebarDirectory subclass must define the Collection it displays."
  }

	/* -------------------------------------------- */
  /*  Initialization Helpers
	/* -------------------------------------------- */

  initialize() {

    // Assign Folders
    this.folders = game.folders.entities.filter(f => f.type === this.constructor.entity);

    // Assign Entities
    this.entities = this.constructor.collection.entities.filter(e => e.visible);

    // Build Tree
    const sortMode = 'n';
    this.tree = this.constructor.setupFolders(this.folders, this.entities, sortMode);
  }

	/* -------------------------------------------- */

  /**
   * Given an entity type and a list of entities, set up the folder tree for that entity
   * @param {Array} folders     The Array of Folder objects to organize
   * @param {Array} entities    The Array of Entity objects to organize
   * @param {String} sortMode   How should entities or Folders be sorted? (a)lphabetic or (n)umeric
   * @return {Object}           A tree structure containing the folders and entities
   */
  static setupFolders(folders, entities, sortMode="n") {
	  entities = entities.filter(a => a.visible);
    const depths = [];

    // Track which folders we have handled to prevent recursive or circular structures
    const handled = new Set();

    // First sort both Entities and Folders
    const sortCompare = sortMode === "n" ?
      (a, b) => a.data.sort - b.data.sort :
      (a, b) => a.name.localeCompare(b.name);
    folders.sort(sortCompare);
    entities.sort(sortCompare);

    // Iterate parent levels
    const root = {_id: null};
    let batch = [root];
    for ( let i = 0; i < CONST.FOLDER_MAX_DEPTH; i++ ) {
      depths[i] = [];
      for ( let folder of batch ) {
        if ( handled.has(folder.id) ) continue;
        try {
          [folders, entities] = this._populate(folder, folders, entities);
          depths[i].push(...folder.children);
          handled.add(folder.id);
        } catch(err) {
          console.error(err);
        }
      }
      batch = depths[i];
    }

    // Populate content to any remaining folders and assign them to the root level
    const remaining = depths[CONST.FOLDER_MAX_DEPTH-1].concat(folders);
    for ( let f of remaining ) {
      [folders, entities] = this._populate(f, folders, entities, {allowChildren: false});
    }
    depths[0] = depths[0].concat(folders);

    // Filter folder visibility
    for ( let i = CONST.FOLDER_MAX_DEPTH - 1; i >= 0; i-- ) {
      for ( let f of depths[i] ) {
        f.children = f.children.filter(c => c.displayed);
      }
      depths[i] = depths[i].filter(f => f.displayed);
    }

    // Flag folder depth
    for ( let [i, folders] of depths.entries() ) {
      folders.forEach(f => f.depth = i+1);
    }

    // Return the root level contents of folders and entities
    return {
      root: true,
      content: root.content.concat(entities),
      children: depths[0]
    };
  }

  /* -------------------------------------------- */

  /**
   * Populate a single folder with child folders and content
   * This method is called recursively when building the folder tree
   * @private
   */
  static _populate(folder, folders, entities, {allowChildren=true}={}) {
    const id = folder._id;

    // Partition folders into children and unassigned folders
    let [u, children] = folders.partition(f => allowChildren && (f.data.parent === id));
    folder.children = children;
    folders = u;

    // Partition entities into contents and unassigned entities
    const [e, content] = entities.partition(e => e.data.folder === id);
    folder.content = content;
    entities = e;

    // Return the remainder
    return [folders, entities];
  }

	/* -------------------------------------------- */
  /*  Application Rendering
	/* -------------------------------------------- */

  /** @override */
	render(force, context={}) {
	  const {renderContext, renderData} = context;
	  const e = this.constructor.entity;
	  const events = ["create"+e, "createMany"+e, "update"+e, "updateMany"+e, "delete"+e, "deleteMany"+e,
      "createFolder", "updateFolder", "deleteFolder"];
	  if ( events.includes(renderContext) ) {
	    if ( renderContext === "update"+e ) {
	      const updateKeys = this.options.renderUpdateKeys;
        if ( !updateKeys.some(k => renderData.hasOwnProperty(k)) ) return;
      }
	    this.initialize();
    }
	  return super.render(force, context);
  }

  /* -------------------------------------------- */

  /** @override */
  getData(options) {
    return {
      user: game.user,
      tree: this.tree,
      search: this.searchString,
      canCreate: game.user.can(`${this.constructor.entity.toUpperCase()}_CREATE`)
    };
  }

  /* -------------------------------------------- */

  /**
   * Perform a search of the SidebarDirectory using a filtering string
   * Only display matching entities and their parent folders within the sidebar
   * @private
   */
  search(searchString) {
    const isSearch = !!searchString;
    let entityIds = null;
    let folderIds = null;

    // Match entities and folders
    if ( isSearch ) {
      let rgx = new RegExp(searchString, "i");

      // Match entity names
      const matched = this.entities.filter(e => rgx.test(e.name));
      entityIds = new Set(matched.map(e => e._id));

      // Match folder tree
      folderIds = new Set(matched.filter(e => e.data.folder).map(e => e.data.folder));
      const includeFolders = fids => {
        const folders = this.folders.filter(f => fids.has(f._id));
        const pids = new Set(folders.filter(f => f.data.parent).map(f => f.data.parent));
        if ( pids.size ) {
          pids.forEach(p => folderIds.add(p));
          includeFolders(pids);
        }
      };
      includeFolders(folderIds);
    }

    // Show or hide entities
    this.element.find('li.entity').each((i, el) => {
      el.style.display = (!isSearch || entityIds.has(el.dataset.entityId)) ? "flex" : "none";
    });

    // Show or hide folders
    this.element.find('li.folder').each((i, el) => {
      let li = $(el);
      let match = isSearch && folderIds.has(el.dataset.folderId);
      el.style.display = (!isSearch || match) ? "flex" : "none";

      // Always show a matched folder
      if ( isSearch && match ) li.removeClass("collapsed");

      // Otherwise, show the tracked expanded state
      else {
        let expanded = game.folders._expanded[el.dataset.folderId];
        if ( expanded ) li.removeClass("collapsed");
        else li.addClass("collapsed");
      }
    });

    // Assign the search string
    this.searchString = searchString;
    this.element.find('input[name="search"]').val(searchString);
  }

  /* -------------------------------------------- */

  /**
   * Collapse all subfolders in this directory
   */
  collapseAll() {
    this.element.find('li.folder').addClass("collapsed");
    for ( let f of this.folders ) {
      game.folders._expanded[f._id] = false;
    }
    if ( this.popOut ) this.setPosition();
  }

	/* -------------------------------------------- */
	/*  Event Listeners and Handlers                */
	/* -------------------------------------------- */

  /**
   * Activate event listeners triggered within the Actor Directory HTML
   */
	activateListeners(html) {
	  super.activateListeners(html);

	  // Maybe attach drag start listeners
    if ( this.options.canDrag ) {
      html.find(this.options.dragItemSelector).each((i, li) => {
        li.setAttribute("draggable", true);
        li.addEventListener('dragstart', ev => this._onDragStart(ev), false);
      });
    }

    // Create new entity
    html.find('.create-entity').click(ev => this._onCreate(ev));

	  // Directory click events
	  html.find('.entity-name').click(this._onClickEntityName.bind(this));
    html.find('.folder-header').click(this._toggleFolder.bind(this));
    html.find('.collapse-all').click(this.collapseAll.bind(this));

    // Entity context menu
    this._contextMenu(html);

    // Search filtering
    html.find('input[name="search"]').keyup(this._onSearchKeyup.bind(this));

    // GM only can perform other admin actions
    if ( game.user.isGM ) {
      html[0].ondragover = this._onDragOver.bind(this);
      html[0].ondrop = this._onDrop.bind(this);

      // Create new folder - eliminate buttons below a certain depth
      html.find(".folder .folder .folder .create-folder").remove();
      html.find('.create-folder').click(ev => this._onCreateFolder(ev));
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking on an Entity name in the Sidebar directory
   * @param {Event} event   The originating click event
   * @private
   */
  _onClickEntityName(event) {
    event.preventDefault();
    const element = event.currentTarget;
    const entityId = element.parentElement.dataset.entityId;
    const entity = this.constructor.collection.get(entityId);
    const sheet = entity.sheet;
    if ( sheet._minimized ) return sheet.maximize();
    else return sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle new creation request
   * @param event
   * @private
   */
  async _onCreate(event) {

    // Do not allow the creation event to bubble to other listeners
    event.preventDefault();
    event.stopPropagation();

    // Collect data
    const ent = this.constructor.entity;
    const cls = CONFIG[ent].entityClass;
    const types = game.system.entityTypes[ent];

    // Setup entity data
    const createData = {
      name: `New ${ent}`,
      type: types[0],
      folder: event.currentTarget.getAttribute("data-folder")
    };

    // Only a single type
    if ( types.length <= 1 ) {
      createData.types = types[0];
      return cls.create(createData, {renderSheet: true});
    }

    // Render the entity creation form
    let templateData = {upper: ent, lower: ent.toLowerCase(), types: types},
        dlg = await renderTemplate(`templates/sidebar/entity-create.html`, templateData);

    // Render the confirmation dialog window
    new Dialog({
      title: `Create New ${ent}`,
      content: dlg,
      buttons: {
        create: {
          icon: '<i class="fas fa-check"></i>',
          label: `Create ${ent}`,
          callback: dlg => {
            mergeObject(createData, validateForm(dlg[0].children[0]));
            cls.create(createData, {renderSheet: true});
          }
        }
      },
      default: "create"
    }).render(true);
  }

	/* -------------------------------------------- */

  /**
   * Handle events to create a new Actors folder through a creation dialog
   * @private
   */
	_onCreateFolder(event) {
	  event.preventDefault();
	  event.stopPropagation();
	  let button = $(event.currentTarget),
        parent = button.attr("data-parent-folder");
	  Folder.createDialog({parent: parent ? parent : null, type: this.constructor.entity});
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the collapsed or expanded state of a folder within the directory tab
   * @param {MouseEvent} event    The originating click event
   * @private
   */
  _toggleFolder(event) {
    let folder = $(event.currentTarget.parentElement);
    let collapsed = folder.hasClass("collapsed");
    game.folders._expanded[folder.attr("data-folder-id")] = collapsed;

    // Expand
    if ( collapsed ) folder.removeClass("collapsed");

    // Collapse
    else {
      folder.addClass("collapsed");
      const subs = folder.find('.folder').addClass("collapsed");
      subs.each((i, f) => game.folders._expanded[f.dataset.folderId] = false);
    }

    // Resize container
    if ( this.popOut ) this.setPosition();
  }

	/* -------------------------------------------- */

  /**
   * Begin a a data transfer drag event with default handling
   * @private
   */
	_onDragStart(event) {
	  event.stopPropagation();
	  let li = event.currentTarget.closest("li.directory-item");
    const dragData = li.classList.contains("folder") ?
      { type: "Folder", id: li.dataset.folderId, entity: this.constructor.entity } :
      { type: this.constructor.entity, id: li.dataset.entityId };
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
    this._dragType = dragData.type;
  }

	/* -------------------------------------------- */

  /**
   * Allow data transfer events to be dragged over this as a drop zone
   */
  _onDragOver(event) {
    event.preventDefault();
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle data being dropped onto the sidebar
   * @private
   */
  _onDrop(event) {
    event.preventDefault();
    const cls = this.constructor.entity;

    // Try to extract the data
    let data;
    try {
      data = JSON.parse(event.dataTransfer.getData('text/plain'));
    }
    catch (err) {
      return false;
    }
    let correctType = (data.type === cls) || ((data.type === "Folder") && (data.entity === cls));
    if ( !correctType ) return false;

    // Call the drop handler
    this._handleDropData(event, data);
  }

  /* -------------------------------------------- */

  /**
   * Handle a keyup press in the Search filter bar of the directory sidebar
   * @param {Event} event
   * @private
   */
  _onSearchKeyup(event) {
    event.preventDefault();
    const input = event.target;
    this._searchTime = Date.now();
    setTimeout(() => {
      let dt = Date.now() - this._searchTime;
      if ( dt > 250) this.search(input.value);
    }, 251)
  }

  /* -------------------------------------------- */

  /**
   * Define the behavior of the sidebar tab when it received a dropped data object
   * @param {Event} event   The original drop event
   * @param {Object} data   The data being dropped
   * @private
   */
  _handleDropData(event, data) {

    // Determine the drop target
    const collection = this.constructor.collection;
    const dt = event.target.closest(this.options.dragItemSelector) || null;
    const isFolder = dt && dt.classList.contains("folder");
    const targetId = dt ? (isFolder ? dt.dataset.folderId : dt.dataset.entityId) : null;

    // Determine the closest folder ID
    const closestFolder = dt ? dt.closest(".folder") : null;
    const closestFolderId = closestFolder ? closestFolder.dataset.folderId : null;

    // Case 1 - New data is explicitly provided
    if ( data.data ) {
      const createData = mergeObject(data.data, { folder: closestFolderId });
      if ( collection.get(createData._id ) ) throw new Error("An Entity with this _id already exists!");
      return collection.object.create(createData);
    }

    // Case 2 - Data to import from a Compendium pack
    else if ( data.pack ) {
      const updateData = { folder: closestFolderId };
      const options = { renderSheet: true };
      return this.constructor.collection.importFromCollection(data.pack, data.id, updateData, options);
    }

    // Case 3 - Sort a Folder
    if ( data.type === "Folder" ) {
      const folder = game.folders.get(data.id);
      const sortData = {sortKey: "sort", sortBefore: true};
      if (isFolder && dt.classList.contains("collapsed") ) {    // Dropped on a collapsed Folder
        sortData.target = game.folders.get(targetId);
        sortData.parentId = sortData.target.data.parent;
      } else if ( isFolder )  {                                 // Dropped on an expanded Folder
        if ( Number(dt.dataset.folderDepth) === CONST.FOLDER_MAX_DEPTH ) return; // Prevent going beyond max depth
        sortData.target = null;
        sortData.parentId = targetId;
        if ( data.id === targetId ) return; // Don't drop folders on yourself
      } else {                                                  // Dropped on an Entity
        sortData.parentId = closestFolderId;
        sortData.target = closestFolder && closestFolder.classList.contains("collapsed") ? closestFolder : null;
      }

      // Determine Folder siblings
      sortData.siblings = game.folders.entities.filter(f => {
        return (f.data.parent === sortData.parentId) && (f.data.type === folder.data.type) && (f !== folder);
      });
      sortData.updateData = {parent: sortData.parentId};
      folder.sortRelative(sortData);
    }

    // Case 4 - Sort an Entity
    else {
      const entity = collection.get(data.id);
      const isEntity = dt && dt.classList.contains("entity");
      const sortData = {sortKey: "sort", sortBefore: true};

      // Handle different targets
      if ( isEntity ) {   // Drop on an Entity
        sortData.target = collection.get(targetId);
        sortData.folderId = sortData.target.data.folder;
      } else {            // Drop on a Folder or null
        sortData.target = null;
        sortData.folderId = closestFolderId;
      }

      // Determine Entity siblings and updateData
      sortData.siblings = collection.entities.filter(e => {
        return (e.data.folder === sortData.folderId) && (e._id !== data.id);
      });
      sortData.updateData = { folder: sortData.folderId };
      entity.sortRelative(sortData);
    }
  }

  /* -------------------------------------------- */

  /**
   * Default folder context actions
   * @param html {jQuery}
   * @private
   */
  _contextMenu(html) {

    // Folder Context
    const folderOptions = this._getFolderContextOptions();
    Hooks.call(`get${this.constructor.name}FolderContext`, html, folderOptions);
    if (folderOptions) new ContextMenu(html, ".folder .folder-header", folderOptions);

    // Entity Context
    const entryOptions = this._getEntryContextOptions();
    Hooks.call(`get${this.constructor.name}EntryContext`, html, entryOptions);
    if (entryOptions) new ContextMenu(html, ".entity", entryOptions);
  }

  /* -------------------------------------------- */

  /**
   * Get the set of ContextMenu options which should be used for Folders in a SidebarDirectory
   * @return {Array}   The Array of context options passed to the ContextMenu instance
   * @private
   */
  _getFolderContextOptions() {
    return [
      {
        name: "FOLDER.Edit",
        icon: '<i class="fas fa-edit"></i>',
        condition: game.user.isGM,
        callback: header => {
          const folder = game.folders.get(header.parent().data("folderId"));
          new FolderConfig(folder).render(true);
        }
      },
      {
        name: "FOLDER.Remove",
        icon: '<i class="fas fa-trash"></i>',
        condition: game.user.isGM,
        callback: header => {
          const li = header.parent();
          const folder = game.folders.get(li.data("folderId"));
          Dialog.confirm({
            title: `${game.i18n.localize("FOLDER.Remove")} ${folder.name}`,
            content: game.i18n.localize("FOLDER.RemoveConfirm"),
            yes: () => folder.delete({deleteSubfolders: false, deleteContents: false})
          }, {
            top: Math.min(li[0].offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 720,
            width: 400
          });
        }
      },
      {
        name: "FOLDER.Delete",
        icon: '<i class="fas fa-dumpster"></i>',
        condition: game.user.isGM,
        callback: header => {
          const li = header.parent();
          const folder = game.folders.get(li.data("folderId"));
          Dialog.confirm({
            title: `${game.i18n.localize("FOLDER.Delete")} ${folder.name}`,
            content: game.i18n.localize("FOLDER.DeleteConfirm"),
            yes: () => folder.delete({deleteSubfolders: true, deleteContents: true})
          }, {
            top: Math.min(li[0].offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 720,
            width: 400
          });
        }
      }
    ];
  }

  /* -------------------------------------------- */

  /**
   * Get the set of ContextMenu options which should be used for Entities in a SidebarDirectory
   * @return {Array}   The Array of context options passed to the ContextMenu instance
   * @private
   */
  _getEntryContextOptions() {
    return [
      {
        name: "FOLDER.Clear",
        icon: '<i class="fas fa-folder"></i>',
        condition: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          return game.user.isGM && !!entity.data.folder;
        },
        callback: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          entity.update({folder: null});
        }
      },
      {
        name: "SIDEBAR.Delete",
        icon: '<i class="fas fa-trash"></i>',
        condition: () => game.user.isGM,
        callback: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          Dialog.confirm({
            title: `${game.i18n.localize("SIDEBAR.Delete")} ${entity.name}`,
            content: game.i18n.localize("SIDEBAR.DeleteConfirm"),
            yes: entity.delete.bind(entity)
          }, {
            top: Math.min(li[0].offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 720
          });
        }
      },
      {
        name: "SIDEBAR.Duplicate",
        icon: '<i class="far fa-copy"></i>',
        condition: () => game.user.isGM,
        callback: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          return entity.clone({name: `${entity.name} (Copy)`});
        }
      },
      {
        name: "SIDEBAR.Permissions",
        icon: '<i class="fas fa-lock"></i>',
        condition: () => game.user.isGM,
        callback: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          new PermissionControl(entity, {
            top: Math.min(li[0].offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 720,
            width: 400
          }).render(true);
        }
      },
      {
        name: "SIDEBAR.Export",
        icon: '<i class="fas fa-file-export"></i>',
        condition: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          return entity.owner;
        },
        callback: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          entity.exportToJSON();
        }
      },
      {
        name: "SIDEBAR.Import",
        icon: '<i class="fas fa-file-import"></i>',
        condition: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          return entity.owner;
        },
        callback: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          entity.importFromJSONDialog();
        }
      }
    ];
  }
}

/**
 * The Collection of Actor entities.
 * @extends {Collection}
 *
 * @see {@link Actor} The Actor entity.
 * @see {@link ActorDirectory} All Actors which exist in the world are rendered within the ActorDirectory sidebar tab.
 *
 * @example <caption>Retrieve an existing Actor by its id</caption>
 * let actor = game.actors.get(actorId);
 */
class Actors extends Collection {
  constructor(...args) {
    super(...args);

    /**
     * A mapping of synthetic Token Actors which are currently active within the viewed Scene.
     * Each Actor is referenced by the Token.id.
     * @type {Object}
     */
    this.tokens = {};
  }

  /* -------------------------------------------- */

  /** @override */
  get object() {
    return CONFIG.Actor.entityClass;
  }

  /* -------------------------------------------- */
  /*  Sheet Registration Methods                  */
  /* -------------------------------------------- */

  /**
   * Register an Actor sheet class as a candidate which can be used to display Actors of a given type
   * See EntitySheetConfig.registerSheet for details
   * @static
   *
   * @example <caption>Register a new ActorSheet subclass for use with certain Actor types.</caption>
   * Actors.registerSheet("dnd5e", ActorSheet5eCharacter, { types: ["character"], makeDefault: true });
   */
  static registerSheet(...args) {
    EntitySheetConfig.registerSheet(Actor, ...args);
  }

  /* -------------------------------------------- */

  /**
   * Unregister an Actor sheet class, removing it from the list of avaliable sheet Applications to use
   * See EntitySheetConfig.unregisterSheet for details
   * @static
   *
   * @example <caption>Deregister the default ActorSheet subclass to replace it with others.</caption>
   * Actors.unregisterSheet("core", ActorSheet);
   */
  static unregisterSheet(...args) {
    EntitySheetConfig.unregisterSheet(Actor, ...args)
  }

  /* -------------------------------------------- */

  /**
   * Return an Array of currently registered sheet classes for this Entity type
   * @type {Array}
   */
  static get registeredSheets() {
    return Object.values(CONFIG.Item.sheetClasses).reduce((arr, classes) => {
      return arr.concat(Object.values(classes).map(c => c.cls));
    }, []);
  }
}


/* -------------------------------------------- */


/**
 * The Actor Entity which represents the protagonists, characters, enemies, and more that inhabit and take actions
 * within the World.
 * @extends {Entity}
 *
 * @see {@link Actors} Each Actor belongs to the Actors collection.
 * @see {@link ActorSheet} Each Actor is edited using the ActorSheet application or a subclass thereof.
 * @see {@link ActorDirectory} All Actors which exist in the world are rendered within the ActorDirectory sidebar tab.
 *
 *
 * @example <caption>Create a new Actor</caption>
 * let actor = await Actor.create({
 *   name: "New Test Actor",
 *   type: "character",
 *   img: "artwork/character-profile.jpg",
 *   folder: folder.data._id,
 *   sort: 12000,
 *   data: {},
 *   token: {},
 *   items: [],
 *   flags: {}
 * });
 *
 * @example <caption>Retrieve an existing Actor</caption>
 * let actor = game.actors.get(actorId);
 */
class Actor extends Entity {
  constructor(...args) {
    super(...args);

    /**
     * A reference to a placed Token which creates a synthetic Actor
     * @type {Token}
     */
    this.token = this.options.token || null;

    /**
     * Construct the Array of Item instances for the Actor
     * Items are prepared by the Actor.prepareEmbeddedEntities() method
     * @type {Array.<Item>}
     */
    this.items = this.items || [];

    /**
     * Cache an Array of allowed Token images if using a wildcard path
     * @type {Array}
     * @private
     */
    this._tokenImages = null;
  }

  /* -------------------------------------------- */

  /** @override */
  static get config() {
    return {
      baseEntity: Actor,
      collection: game.actors,
      embeddedEntities: {"OwnedItem": "items"}
    };
  }

  /* -------------------------------------------- */

  /** @override */
  prepareData() {
    super.prepareData();
    if (!this.data.img) this.data.img = CONST.DEFAULT_TOKEN;
  }

  /* -------------------------------------------- */

  /** @override */
  prepareEmbeddedEntities() {

    // Index existing item instances - do this to avoid re-creating Item instances if possible
    const existing = (this.items || []).reduce((obj, i) => {
      obj[i.id] = i;
      return obj;
    }, {});

    // Prepare the new Item index
    const items = this.data.items.map(i => {
      if ( i._id in existing ) {
        const item = existing[i._id];
        item.data = i;
        item.prepareData();
        return item;
      }
      else return Item.createOwned(i, this);
    });
    this.items = items;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A convenient reference to the file path of the Actor's profile image
   * @type {string}
   */
  get img() {
    return this.data.img;
  }

  /* -------------------------------------------- */

  /**
   * A boolean flag for whether this Actor is a player-owned character.
   * True if any User who is not a GM has ownership rights over the Actor entity.
   * @type {boolean}
   */
  get isPC() {
    const nonGM = game.users.entities.filter(u => !u.isGM);
    return nonGM.some(u => {
      if (this.data.permission["default"] >= CONST.ENTITY_PERMISSIONS["OWNER"]) return true;
      return this.data.permission[u._id] >= CONST.ENTITY_PERMISSIONS["OWNER"]
    });
  }

  /* -------------------------------------------- */

  /**
   * Test whether an Actor entity is a synthetic representation of a Token (if true) or a full Entity (if false)
   * @type {boolean}
   */
  get isToken() {
    if (!this.token) return false;
    return !this.token.data.actorLink;
  }

  /* -------------------------------------------- */

  /**
   * Create a synthetic Actor using a provided Token instance
   * If the Token data is linked, return the true Actor entity
   * If the Token data is not linked, create a synthetic Actor using the Token's actorData override
   * @param {Token} token
   * @return {Actor}
   */
  static fromToken(token) {
    let actor = game.actors.get(token.data.actorId);
    if (!actor) return null;
    if (!token.data._id) return actor;
    if (!token.data.actorLink) actor = actor.constructor.createTokenActor(actor, token);
    return actor;
  }

  /* -------------------------------------------- */

  /**
   * Create a synthetic Token Actor instance which is used in place of an actual Actor.
   * Cache the result in Actors.tokens.
   * @param {Actor} baseActor
   * @param {Token} token
   * @return {Actor}
   */
  static createTokenActor(baseActor, token) {
    let actor = this.collection.tokens[token.id];
    if ( actor ) return actor;
    const actorData = mergeObject(baseActor.data, token.data.actorData, {inplace: false});
    actor = new this(actorData, {token: token});
    return this.collection.tokens[token.id] = actor;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve an Array of active tokens which represent this Actor in the current canvas Scene.
   * If the canvas is not currently active, or there are no linked actors, the returned Array will be empty.
   *
   * @param [linked] {Boolean}  Only return tokens which are linked to the Actor. Default (false) is to return all
   *                            tokens even those which are not linked.
   *
   * @return {Array}            An array of tokens in the current Scene which reference this Actor.
   */
  getActiveTokens(linked = false) {
    if (!canvas.tokens) return [];
    return canvas.tokens.placeables.filter(t => {
      if (!(t instanceof Token)) return false;
      if (linked) return t.data.actorLink && t.data.actorId === this._id;
      return t.data.actorId === this._id
    });
  }

  /* -------------------------------------------- */

  /**
   * Get an Array of Token images which could represent this Actor
   * @return {Promise}
   */
  async getTokenImages() {
    if (!this.data.token.randomImg) return [this.data.token.img];
    if (this._tokenImages) return this._tokenImages;
    const content = await FilePicker.browse("user", this.data.token.img, {wildcard: true});
    return this._tokenImages = content.files;
  }

  /* -------------------------------------------- */

  /**
   * Handle how changes to a Token attribute bar are applied to the Actor.
   * This allows for game systems to override this behavior and deploy special logic.
   * @param {string} attribute    The attribute path
   * @param {number} value        The target attribute value
   * @param {boolean} isDelta     Whether the number represents a relative change (true) or an absolute change (false)
   * @param {boolean} isBar       Whether the new value is part of an attribute bar, or just a direct value
   * @return {Promise}
   */
  async modifyTokenAttribute(attribute, value, isDelta=false, isBar=true) {
    const current = getProperty(this.data.data, attribute);
    if ( isBar ) {
      if (isDelta) value = Math.clamped(0, Number(current.value) + value, current.max);
      return this.update({[`data.${attribute}.value`]: value});
    } else {
      if ( isDelta ) value = Number(current) + value;
      return this.update({[`data.${attribute}`]: value});
    }
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers
  /* -------------------------------------------- */

  /** @override */
  async update(data, options = {}) {
    if ( this.isToken ) return ActorTokenHelpers.prototype.update.bind(this)(data, options);
    if (data.img && !hasProperty(data, "token.img") && (this.data.token.img === CONST.DEFAULT_TOKEN)) {
      setProperty(data, "token.img", data.img);
    }
    return super.update(data, options);
  }

  /* -------------------------------------------- */

  /** @override */
  async delete(options) {
    if ( this.isToken ) return this.token.delete(options);
    return super.delete(options);
  }

  /* -------------------------------------------- */

  /** @override */
  async createEmbeddedEntity(...args) {
    if ( this.isToken ) return ActorTokenHelpers.prototype.createEmbeddedEntity.call(this, ...args);
    return super.createEmbeddedEntity(...args);
  }

  /* -------------------------------------------- */

  /** @override */
  async createManyEmbeddedEntities(...args) {
    if ( this.isToken ) return ActorTokenHelpers.prototype.createManyEmbeddedEntities.call(this, ...args);
    return super.createManyEmbeddedEntities(...args);
  }

  /* -------------------------------------------- */

  /** @override */
  async updateEmbeddedEntity(...args) {
    if ( this.isToken ) return ActorTokenHelpers.prototype.updateEmbeddedEntity.call(this, ...args);
    return super.updateEmbeddedEntity(...args);
  }

  /* -------------------------------------------- */

  /** @override */
  async updateManyEmbeddedEntities(...args) {
    if ( this.isToken ) return ActorTokenHelpers.prototype.updateManyEmbeddedEntities.call(this, ...args);
    return super.updateManyEmbeddedEntities(...args);
  }

  /* -------------------------------------------- */

  /** @override */
  async deleteEmbeddedEntity(...args) {
    if ( this.isToken ) return ActorTokenHelpers.prototype.deleteEmbeddedEntity.call(this, ...args);
    return super.deleteEmbeddedEntity(...args);
  }

  /* -------------------------------------------- */

  /** @override */
  async deleteManyEmbeddedEntities(...args) {
    if ( this.isToken ) return ActorTokenHelpers.prototype.deleteManyEmbeddedEntities.call(this, ...args);
    return super.deleteManyEmbeddedEntities(...args);
  }

  /* -------------------------------------------- */

  /** @override */
  _onUpdate(data, options, userId, context) {

    // Get the changed attributes
    const keys = Object.keys(data).filter(k => k !== "_id");
    const changed = new Set(keys);

    // Re-prepare Actor data
    if (changed.has("items")) this.prepareEmbeddedEntities();
    this.prepareData();

    // Render associated applications
    this.render(false, context);

    // Additional options only apply to Actors which are not synthetic Tokens
    if (this.isToken) return;

    // Update default token data
    const token = this.data.token;
    if (data.img && data.img !== token.img && (!token.img || token.img === CONST.DEFAULT_TOKEN)) {
      data["token.img"] = data.img;
    }
    if (data.name && data.name !== token.name && (!token.name || token.name === "New Actor")) {
      data["token.name"] = data.name;
    }

    // If the prototype token was changed, expire any cached token images
    if (changed.has("token")) this._tokenImages = null;

    // Update Token representations of this Actor
    this.getActiveTokens().forEach(token => token._onUpdateBaseActor(this.data, data));

    // If ownership changed for an actor with an active token, re-initialize sight
    if (changed.has("permission")) {
      if (this.getActiveTokens().length) {
        canvas.tokens.releaseAll();
        canvas.tokens.cycleTokens(1, true);
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onCreateEmbeddedEntity({embeddedName, created, options, userId}) {
    const item = Item.createOwned(created, this);
    this.items.push(item);
    if (options.renderSheet && (userId === game.user._id)) {
      item.sheet.render(true, {
        renderContext: "create" + embeddedName,
        renderData: created
      });
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onUpdateEmbeddedEntity({embeddedName, data, options, userId}) {
    const item = this.getOwnedItem(data._id);
    item.prepareData();
  }

  /* -------------------------------------------- */

  /** @override */
  _onDeleteEmbeddedEntity({embeddedName, deleted, options, userId}) {
    const item = this.items.findSplice(i => i.data._id === deleted._id);
    item.sheet.close({submit: false});
  }

  /* -------------------------------------------- */
  /*  Owned Item Management                       */
  /* -------------------------------------------- */

  /**
   * Import a new owned Item from a compendium collection
   * The imported Item is then added to the Actor as an owned item.
   *
   * @param collection {String}     The name of the pack from which to import
   * @param entryId {String}        The ID of the compendium entry to import
   */
  importItemFromCollection(collection, entryId) {
    const pack = game.packs.find(p => p.collection === collection);
    if (pack.metadata.entity !== "Item") return;
    return pack.getEntity(entryId).then(ent => {
      console.log(`${vtt} | Importing Item ${ent.name} from ${collection}`);
      delete ent.data._id;
      return this.createOwnedItem(ent.data);
    });
  }

  /* -------------------------------------------- */

  /**
   * Get an Item instance corresponding to the Owned Item with a given id
   * @param {string} itemId   The OwnedItem id to retrieve
   * @return {Item}           An Item instance representing the Owned Item within the Actor entity
   */
  getOwnedItem(itemId) {
    return this.items.find(i => i.id === itemId);
  }

  /* -------------------------------------------- */

  /**
   * Create a new item owned by this Actor. This redirects its arguments to the createEmbeddedEntity method.
   * @see {Entity#createEmbeddedEntity}
   *
   * @param {Object} itemData     Data for the newly owned item
   * @param {Object} options      Item creation options
   * @param {boolean} options.renderSheet Render the Item sheet for the newly created item data
   * @return {Promise.<Object>}   A Promise resolving to the created Owned Item data
   */
  async createOwnedItem(itemData, options = {}) {
    return this.createEmbeddedEntity("OwnedItem", itemData, options);
  }

  /* -------------------------------------------- */

  /**
   * Update an owned item using provided new data. This redirects its arguments to the updateEmbeddedEntity method.
   * @see {Entity#updateEmbeddedEntity}
   *
   * @param {Object} itemData     Data for the item to update
   * @param {Object} options      Item update options
   * @return {Promise.<Object>}   A Promise resolving to the updated Owned Item data
   */
  async updateOwnedItem(itemData, options = {}) {
    return this.updateEmbeddedEntity("OwnedItem", itemData, options);
  }

  /* -------------------------------------------- */

  /**
   * @extends {Entity#createEmbeddedEntity}
   * @deprecated since 0.4.4 in favor of Entity.updateManyEmbeddedEntities()
   */
  async updateManyOwnedItems(data, options = {}) {
    console.warn("You are using Actor.updateManyOwnedItems() which is deprecated in favor of Actor.updateManyEmbeddedEntities()")
    return this.updateManyEmbeddedEntities("OwnedItem", data, options);
  }

  /* -------------------------------------------- */

  /**
   * Delete an owned item by its id. This redirects its arguments to the deleteEmbeddedEntity method.
   * @see {Entity#deleteEmbeddedEntity}
   *
   * @param {string} itemId       The ID of the item to delete
   * @param {Object} options      Item deletion options
   * @return {Promise.<Object>}   A Promise resolving to the deleted Owned Item data
   */
  async deleteOwnedItem(itemId, options = {}) {
    return this.deleteEmbeddedEntity("OwnedItem", itemId, options);
  }
}


/* -------------------------------------------- */


/**
 * A collection of replacement functions which are used in Actor socket workflows to replace default behaviors.
 * @ignore
 */
class ActorTokenHelpers {

  /** @override */
  async update(data, options = {}) {
    const token = this.token;
    const changed = diffObject(this.data, expandObject(data));
    if ( isObjectEmpty(changed ) ) return this;
    return token.update({actorData: changed}, options);
  }

  /* -------------------------------------------- */

  /** @override */
  async createEmbeddedEntity(embeddedName, data, options={}) {
    if ( options.temporary ) return null;
    const item = await Entity.prototype.createEmbeddedEntity.call(this, "OwnedItem", data, {temporary: true});
    const items = duplicate(this.data.items).concat([item]);
    return this.token.update({"actorData.items": items}, options);
  }

  /* -------------------------------------------- */

  /** @override */
  async createManyEmbeddedEntities(embeddedName, data, options={}) {
    if ( embeddedName !== "OwnedItem" ) return;
    const created = await Entity.prototype.createManyEmbeddedEntities.call(this, "OwnedItem", data, {temporary: true});
    const items = duplicate(this.data.items).concat(created);
    return this.token.update({"actorData.items": items}, options);
  }

  /* -------------------------------------------- */

  /** @override */
  updateEmbeddedEntity(embeddedName, data, options={}) {
    if ( embeddedName !== "OwnedItem" ) return;
    const items = duplicate(this.data.items);
    const item = items.find(i => i._id === data._id);
    if (item) mergeObject(item, data);
    return this.token.update({"actorData.items": items}, options);
  }

  /* -------------------------------------------- */

  /** @override */
  async updateManyEmbeddedEntities(embeddedName, data, options={}) {
    if ( embeddedName !== "OwnedItem" ) return;
    const items = duplicate(this.data.items);
    for ( let update of data ) {
      const item = items.find(i => i._id === update._id);
      mergeObject(item, update, {inplace: true});
    }
    return this.token.update({"actorData.items": items}, options);
  }

  /* -------------------------------------------- */

  /** @override */
  async deleteEmbeddedEntity(embeddedName, id, options = {}) {
    if ( embeddedName !== "OwnedItem" ) return;
    const items = duplicate(this.data.items);
    items.findSplice(i => i._id === id);
    return this.token.update({"actorData.items": items}, options);
  }

  /* -------------------------------------------- */

  /** @override */
  async deleteManyEmbeddedEntities(embeddedName, data, options={}) {
    if ( embeddedName !== "OwnedItem" ) return;
    const items = duplicate(this.data.items).filter(i => !data.includes(i._id));
    return this.token.update({"actorData.items": items}, options);
  }
}
/**
 * The Collection of Combat entities
 * @type {Collection}
 */
class CombatEncounters extends Collection {
  constructor(...args) {
    super(...args);

    /**
     * A reference to the world combat configuration settings
     * @type {Object}
     */
    this.settings = game.settings.get("core", Combat.CONFIG_SETTING);
  }

  /* -------------------------------------------- */

  /** @override */
  get object() {
    return Combat;
  }

  /* -------------------------------------------- */

  /**
   * Get an Array of Combat instances which apply to the current canvas scene
   * @type {Array}
   */
  get combats() {
    let scene = game.scenes.active;
    if ( !scene ) return [];
    return this.entities.filter(c => c.data.scene === scene._id);
  }

  /* -------------------------------------------- */

  /**
   * The currently active Combat instance
   * @return {Combat}
   */
  get active() {
    return this.combats.find(c => c.data.active);
  }

	/* -------------------------------------------- */

  /**
   * The currently viewed Combat encounter
   * @return {Combat|null}
   */
  get viewed() {
    return ui.combat.combat;
  }

	/* -------------------------------------------- */

  /** @override */
  static get instance() {
    return game.combats;
  }

  /* -------------------------------------------- */

  /**
   * When a Token is deleted, remove it as a combatant from any combat encounters which included the Token
   * @param {string} sceneId
   * @param {string} tokenId
   * @private
   */
  async _onDeleteToken(sceneId, tokenId) {
    const combats = game.combats.entities.filter(c => c.sceneId = sceneId);
    for ( let c of combats ) {
      let combatant = c.getCombatantByToken(tokenId);
      if ( combatant ) await c.deleteCombatant(combatant.id);
    }
  }
}


/* -------------------------------------------- */
/*  Combat Entity
/* -------------------------------------------- */


/**
 * The Combat Entity defines a particular combat encounter which can occur within the game session
 * Combat instances belong to the CombatEncounters collection
 * @type {Entity}
 */
class Combat extends Entity {
  constructor(...args) {
    super(...args);

    /**
     * Track the sorted turn order of this combat encounter
     * @type {Array}
     */
    this.turns;

    /**
     * Record the current round, turn, and tokenId to understand changes in the encounter state
     * @type {Object}
     * @private
     */
    this.current = {
      round: null,
      turn: null,
      tokenId: null
    };

    /**
     * Track the previous round, turn, and tokenId to understand changes in the encounter state
     * @type {Object}
     * @private
     */
    this.previous = {
      round: null,
      turn: null,
      tokenId: null
    };

    /**
     * Track whether a sound notification is currently being played to avoid double-dipping
     * @type {Boolean}
     * @private
     */
    this._soundPlaying = false;
  }

  /* -------------------------------------------- */

  /**
   * Configure the attributes of the Folder Entity
   *
   * @returns {Entity} baseEntity       The parent class which directly inherits from the Entity interface.
   * @returns {Collection} collection   The Collection class to which Entities of this type belong.
   * @returns {Array} embeddedEntities  The names of any Embedded Entities within the Entity data structure.
   */
  static get config() {
    return {
      baseEntity: Combat,
      collection: game.combats,
      embeddedEntities: { "Combatant": "combatants" }
    };
  }

	/* -------------------------------------------- */

  /**
   * Prepare Embedded Entities which exist within the parent Combat.
   * For example, in the case of an Actor, this method is responsible for preparing the Owned Items the Actor contains.
   */
	prepareEmbeddedEntities() {
	  this.turns = this.setupTurns();
  }

  /* -------------------------------------------- */

  /**
   * Acquire the default dice formula which should be used to roll initiative for a particular combatant.
   * Modules or systems could choose to override or extend this to accommodate special situations.
   * @private
   *
   * @param {Object} combatant      Data for the specific combatant for whom to acquire an initiative formula. This
   *                                is not used by default, but provided to give flexibility for modules and systems.
   * @return {string}               The initiative formula to use for this combatant.
   */
  _getInitiativeFormula(combatant) {
    return CONFIG.Combat.initiative.formula || game.system.data.initiative;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A convenience reference to the Array of combatant data within the Combat entity
   * @type {Array.<Object>}
   */
  get combatants() {
    return this.data.combatants;
  }

  /* -------------------------------------------- */

  /**
   * Get the data object for the Combatant who has the current turn
   * @type {Object}
   */
  get combatant() {
    return this.turns[this.data.turn];
  }

  /* -------------------------------------------- */

  /**
   * The numeric round of the Combat encounter
   * @type {number}
   */
  get round() {
    return this.data.round;
  }

  /* -------------------------------------------- */

  /**
   * The numeric turn of the combat round in the Combat encounter
   * @type {number}
   */
  get turn() {
    return this.data.turn;
  }

  /* -------------------------------------------- */

  /**
   * Get the Scene entity for this Combat encounter
   * @return {Scene}
   */
  get scene() {
    return game.scenes.get(this.data.scene);
  }

  /* -------------------------------------------- */

  /**
   * Return the object of settings which modify the Combat Tracker behavior
   * @return {Object}
   */
  get settings() {
    return this.collection.settings;
  }

  /* -------------------------------------------- */

  /**
   * Has this combat encounter been started?
   * @type {Boolean}
   */
  get started() {
    return ( this.turns.length > 0 ) && ( this.round > 0 );
  }

  /* -------------------------------------------- */
  /*  Combat Control Methods                      */
  /* -------------------------------------------- */

  /**
   * Set the current Combat encounter as active within the Scene.
   * Deactivate all other Combat encounters within the viewed Scene and set this one as active
   * @return {Promise.<Combat>}
   */
  async activate() {
    const scene = game.scenes.viewed;
    const updates = this.collection.entities.reduce((arr, c) => {
      if ( (c.data.scene === scene.id) && c.data.active ) arr.push({_id: c.data._id, active: false});
      return arr;
    }, []);
    updates.push({_id: this.id, active: true});
    return this.constructor.updateMany(updates);
  }

  /* -------------------------------------------- */

  /**
   * Return the Array of combatants sorted into initiative order, breaking ties alphabetically by name
   * @return {Array}
   */
  setupTurns() {
    const scene = game.scenes.get(this.data.scene);
    const players = game.users.players;

    // Populate additional data for each combatant
    let turns = this.data.combatants.map(c => {
      c.token = scene.getEmbeddedEntity("Token", c.tokenId);
      if ( !c.token ) return c;
      c.actor = Actor.fromToken(new Token(c.token, scene));
      c.players = c.actor ? players.filter(u => c.actor.hasPerm(u, "OWNER")) : [];
      c.owner = game.user.isGM || (c.actor ? c.actor.owner : false);
      c.visible = c.owner || !c.hidden;
      return c;
    }).filter(c => c.token);

    // Sort turns into initiative order: (1) initiative, (2) name, (3) tokenId
    turns = turns.sort((a, b) => {
      const ia = Number.isNumeric(a.initiative) ? a.initiative : -9999;
      const ib = Number.isNumeric(b.initiative) ? b.initiative : -9999;
      let ci = ib - ia;
      if ( ci !== 0 ) return ci;
      let [an, bn] = [a.token.name || "", b.token.name || ""];
      let cn = an.localeCompare(bn);
      if ( cn !== 0 ) return cn;
      return a.tokenId - b.tokenId;
    });

    // Ensure the current turn is bounded
    this.data.turn = Math.clamped(this.data.turn, 0, turns.length-1);
    this.turns = turns;

    // When turns change, tracked resources also change
    if ( ui.combat ) ui.combat.updateTrackedResources();
    return this.turns;
  }

  /* -------------------------------------------- */

  /**
   * Begin the combat encounter, advancing to round 1 and turn 1
   * @return {Promise}
   */
  async startCombat() {
    return this.update({round: 1, turn: 0});
  }

  /* -------------------------------------------- */

  /**
   * Advance the combat to the next turn
   * @return {Promise}
   */
  async nextTurn() {
    let turn = this.turn;
    let skip = this.settings.skipDefeated;
    let next = null;

    // Determine the next turn number
    if ( skip ) {
      for ( let [i, t] of this.turns.entries() ) {
        if ( i <= turn ) continue;
        if ( !t.defeated ) {
          next = i;
          break;
        }
      }
    } else next = turn + 1;

    // Maybe advance the round instead
    if ( this.round === 0 || next === null || next >= this.turns.length ) return this.nextRound();
    return this.update({turn: next});
  }

  /* -------------------------------------------- */

  /**
   * Rewind the combat to the previous turn
   * @return {Promise}
   */
  async previousTurn() {
    if ( this.turn === 0 && this.round === 0 ) return Promise.resolve();
    else if ( this.turn === 0 ) return this.previousRound();
    return this.update({turn: this.turn - 1});
  }

  /* -------------------------------------------- */

  /**
   * Advance the combat to the next round
   * @return {Promise}
   */
  async nextRound() {
    return this.update({round: this.round + 1, turn: 0});
  }

  /* -------------------------------------------- */

  /**
   * Rewind the combat to the previous round
   * @return {Promise}
   */
  async previousRound() {
    let turn = ( this.round === 0 ) ? 0 : this.turns.length - 1;
    return this.update({round: Math.max(this.round - 1, 0), turn: turn});
  }

  /* -------------------------------------------- */

  /**
   * Reset all combatant initiative scores, setting the turn back to zero
   * @return {Promise}
   */
  async resetAll() {
    const updates = this.data.combatants.map(c => { return {
      _id: c._id,
      initiative: null
    }});
    await this.updateManyEmbeddedEntities("Combatant", updates);
    return this.update({turn: 0});
  }

  /* -------------------------------------------- */

  /**
   * Display a dialog querying the GM whether they wish to end the combat encounter and empty the tracker
   * @return {Promise}
   */
  async endCombat() {
    return Dialog.confirm({
      title: "End Combat Encounter?",
      content: "<p>End this combat encounter and empty the turn tracker?</p>",
      yes: () => this.delete()
    });
  }

  /* -------------------------------------------- */
  /*  Combatant Management Methods                */
  /* -------------------------------------------- */

  /** @extends {Entity.getEmbeddedEntity} */
  getCombatant(id) {
    return this.getEmbeddedEntity("Combatant", id);
  }

  /* -------------------------------------------- */

  /**
   * Get a Combatant using its Token id
   * {string} tokenId   The id of the Token for which to acquire the combatant
   */
  getCombatantByToken(tokenId) {
    return this.turns.find(c => c.tokenId === tokenId);
  }

  /* -------------------------------------------- */

  /**
   * Set initiative for a single Combatant within the Combat encounter.
   * Turns will be updated to keep the same combatant as current in the turn order
   * @param {string} id         The combatant ID for which to set initiative
   * @param {Number} value      A specific initiative value to set
   */
  async setInitiative(id, value) {
    const currentId = this.combatant._id;
    await this.updateCombatant({_id: id, initiative: value}, {});
    await this.update({turn: this.turns.findIndex(c => c._id === currentId)});
  }

  /* -------------------------------------------- */

  /**
   * Roll initiative for one or multiple Combatants within the Combat entity
   * @param {Array|string} ids        A Combatant id or Array of ids for which to roll
   * @param {string|null} formula     A non-default initiative formula to roll. Otherwise the system default is used.
   * @param {Object} messageOptions   Additional options with which to customize created Chat Messages
   * @return {Promise.<Combat>}       A promise which resolves to the updated Combat entity once updates are complete.
   */
  async rollInitiative(ids, formula=null, messageOptions={}) {

    // Structure input data
    ids = typeof ids === "string" ? [ids] : ids;
    const currentId = this.combatant._id;

    // Iterate over Combatants, performing an initiative roll for each
    const [updates, messages] = ids.reduce((results, id, i) => {
      let [updates, messages] = results;

      // Get Combatant data
      const c = this.getCombatant(id);
      if ( !c ) return results;
      const actorData = c.actor ? c.actor.data.data : {};


      // Roll initiative
      const cf = formula || this._getInitiativeFormula(c);
      const roll = new Roll(cf, actorData).roll();
      updates.push({_id: id, initiative: roll.total});

      // Construct chat message data
      const rollMode = messageOptions.rollMode || (c.token.hidden || c.hidden) ? "gmroll" : "roll";
      let messageData = mergeObject({
        speaker: {
          scene: canvas.scene._id,
          actor: c.actor ? c.actor._id : null,
          token: c.token._id,
          alias: c.token.name
        },
        flavor: `${c.token.name} rolls for Initiative!`
      }, messageOptions);
      const chatData = roll.toMessage(messageData, {rollMode, create:false});
      if ( i > 0 ) chatData.sound = null;   // Only play 1 sound for the whole set
      messages.push(chatData);

      // Return the Roll and the chat data
      return results;
    }, [[], []]);
    if ( !updates.length ) return this;

    // Update multiple combatants
    await this.updateManyEmbeddedEntities("Combatant", updates);

    // Ensure the turn order remains with the same combatant
    await this.update({turn: this.turns.findIndex(t => t._id === currentId)});

    // Create multiple chat messages
    await ChatMessage.createMany(messages);

    // Return the updated Combat
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Roll initiative for all non-player actors who have not already rolled
   * @param {...*}  args    Additional arguments forwarded to the Combat.rollInitiative method
   */
  async rollNPC(...args) {
    const npcs = this.turns.filter(t => (!t.actor || !t.players.length) && !t.initiative);
    return this.rollInitiative(npcs.map(t => t._id), ...args);
  }

  /* -------------------------------------------- */

  /**
   * Roll initiative for all combatants which have not already rolled
   * @param {...*} args     Additional arguments forwarded to the Combat.rollInitiative method
   */
  async rollAll(...args) {
    const unrolled = this.turns.filter(t => !t.initiative);
    return this.rollInitiative(unrolled.map(t => t._id), ...args);
  }

  /* -------------------------------------------- */

  /** @extends {Entity.createEmbeddedEntity} */
  async createCombatant(data, options) {
    return this.createEmbeddedEntity("Combatant", data, options);
  }

  /* -------------------------------------------- */

  /** @extends {Entity.updateEmbeddedEntity} */
  async updateCombatant(data, options) {
    return this.updateEmbeddedEntity("Combatant", data, options);
  }

  /* -------------------------------------------- */

  /** @extends {Entity.deleteEmbeddedEntity} */
  async deleteCombatant(id, options) {
    return this.deleteEmbeddedEntity("Combatant", id, options);
  }

  /* -------------------------------------------- */
  /*  Socket Events and Handlers
  /* -------------------------------------------- */

  /** @override */
  _onCreate(...args) {
    if ( !this.collection.viewed ) ui.combat.initialize({combat: this});
  }

  /* -------------------------------------------- */

  /** @override */
	_onUpdate(data, ...args) {
	  super._onUpdate(data, ...args);

	  // Update state tracking
    this.previous = this.current;
    let c = this.combatant;
    this.current = {round: this.data.round, turn: this.data.turn, tokenId: c ? c.tokenId : null};

	  // If the Combat was set as active, initialize the sidebar
    if ( (data.active === true) && ( this.data.scene === game.scenes.viewed._id ) ) {
      ui.combat.initialize({combat: this});
    }

    // Render the sidebar
    if ( ["combatants", "round", "turn"].some(k => data.hasOwnProperty(k)) ) {
      if ( data.combatants ) this.setupTurns();
      ui.combat.scrollToTurn();
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onDelete(...args) {
    if ( this.collection.viewed === this ) ui.combat.initialize();
  }

  /* -------------------------------------------- */

  /** @override */
  _onModifyEmbeddedEntity(response) {
    this.setupTurns();
    if ( this === this.collection.viewed ) this.collection.render();
  }
}
Combat.CONFIG_SETTING = "combatTrackerConfig";

/**
 * The Folders Collection
 * @extends {Collection}
 */
class Folders extends Collection {
  constructor(...args) {
    super(...args);

    /**
     * This tracks which folders are currently expanded in the UI
     * @private
     */
    this._expanded = {};
  }

  /* -------------------------------------------- */

  /** @override */
  get object() {
    return Folder;
  }

  /* -------------------------------------------- */

  /** @override */
  render(force, context) {
	  if ( context.renderContext ) {
	    const folder = context.entity;
      folder.entityCollection.render(force, context);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _deleteEntity({deleted, newParent, deleteFolderIds, moveFolderIds, deleteEntityIds, moveEntityIds, options, userId}) {
    const folder = this.get(deleted);
    const collection = folder.entityCollection;

    // Move Entities
    for (let id of moveEntityIds) {
      let ent = collection.get(id);
      ent.data.folder = newParent;
    }

    // Delete Entities
    for (let id of deleteEntityIds) {
      collection.remove(id);
    }

    // Move Folders
    for (let id of moveFolderIds) {
      let f = this.get(id);
      f.data.parent = newParent;
    }

    // Delete Folders
    for (let id of deleteFolderIds) {
      this.remove(id);
    }

    // Render the Entity collection
    collection.render(false, {
      renderContext: 'deleteFolder',
      renderData: deleted,
      entity: folder
    });
    return folder;
  }
}


/* -------------------------------------------- */


/**
 * The Folder Entity
 * @extends {Entity}
 */
class Folder extends Entity {

  /**
   * Configure the attributes of the Folder Entity
   *
   * @returns {Entity} baseEntity       The parent class which directly inherits from the Entity interface.
   * @returns {Collection} collection   The Collection class to which Entities of this type belong.
   * @returns {Array} embeddedEntities  The names of any Embedded Entities within the Entity data structure.
   */
  static get config() {
    return {
      baseEntity: Folder,
      collection: game.folders,
      embeddedEntities: {}
    };
  }

  /* -------------------------------------------- */

  /**
   * Return whether the folder is displayed in the sidebar to the current user
   * @type {boolean}
   */
  get displayed() {
    return game.user.isGM || !!this.children.length || !!this.content.length;
  }

  /* -------------------------------------------- */

  /**
   * Return whether the folder is currently expanded within the sidebar interface
   * @type {boolean}
   */
  get expanded() {
    return game.folders._expanded[this.id] || false;
  }

  /* -------------------------------------------- */

  /**
   * A reference to the parent Folder if one is set, otherwise null
   * @type {Folder|null}
   */
  get parent() {
    return this.constructor.collection.get(this.data.parent);
  }

  /* -------------------------------------------- */

  /**
   * Return the named Entity type for elements in this folder.
   * @return {string}
   */
  get type() {
    return this.data.type;
  }

  /* -------------------------------------------- */

  /**
   * A reference to the Collection of Entities for this folder type.
   * @return {Collection}
   */
  get entityCollection() {
    return Object.values(game).find(c => c.object && c.object.entity === this.type);
  }

  /* -------------------------------------------- */

  /**
   * Return an Array of the Entities which are contained within this Folder
   * @type {Array}
   */
  get entities() {
    const cls = CONFIG[this.data.type].entityClass;
    return cls.collection.filter(e => e.data.folder === this._id);
  }

  /* -------------------------------------------- */

  /**
   * Create a new Folder by rendering a dialog window to provide basic creation details
   * @param data {Object}   Initial data with which to populate the creation form
   */
  static createDialog(data) {
    new FolderConfig(new Folder(data)).render(true);
  }
}

/**
 * The Collection of Item entities
 * The items collection is accessible within the game as game.items
 *
 * @type {Collection}
 */
class Items extends Collection {

  /**
   * Elements of the Items collection are instances of the Item class, or a subclass thereof
   * @return {Item}
   */
  get object() {
    return CONFIG.Item.entityClass;
  }

  /* -------------------------------------------- */
  /*  Methods
  /* -------------------------------------------- */

  /**
   * Register an Actor sheet class as a candidate which can be used to display Actors of a given type
   * See EntitySheetConfig.registerSheet for details
   */
  static registerSheet(...args) {
    EntitySheetConfig.registerSheet(Item, ...args);
  }

  /* -------------------------------------------- */

  /**
   * Unregister an Actor sheet class, removing it from the list of avaliable sheet Applications to use
   * See EntitySheetConfig.unregisterSheet for details
   */
  static unregisterSheet(...args) {
    EntitySheetConfig.unregisterSheet(Item, ...args)
  }

  /* -------------------------------------------- */

  /**
   * Return an Array of currently registered sheet classes for this Entity type
   * @type {Array}
   */
  static get registeredSheets() {
    return Object.values(CONFIG.Item.sheetClasses).reduce((arr, classes) => {
      return arr.concat(Object.values(classes).map(c => c.cls));
    }, []);
  }
}


/* -------------------------------------------- */


/**
 * The Item entity.
 * This base Item refers primarily to items which are not currently owned.
 * @type {Entity}
 */
class Item extends Entity {

  /**
   * Configure the attributes of the ChatMessage Entity
   *
   * @returns {Entity} baseEntity       The parent class which directly inherits from the Entity interface.
   * @returns {Collection} collection   The Collection class to which Entities of this type belong.
   * @returns {Array} embeddedEntities  The names of any Embedded Entities within the Entity data structure.
   */
  static get config() {
    return {
      baseEntity: Item,
      collection: game.items,
      embeddedEntities: {}
    };
  }

	/* -------------------------------------------- */

  /** @override */
  get uuid() {
    if ( this.actor ) return `Actor.${this.actor.id}.OwnedItem.${this.id}`;
    return super.uuid;
  }

  /* -------------------------------------------- */

  prepareData() {
    super.prepareData();
    if (!this.data.img) this.data.img = CONST.DEFAULT_TOKEN;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A convenience reference to the Actor entity which owns this item, if any
   * @type {Actor|null}
   */
  get actor() {
    return this.options.actor || null;
  }

  /* -------------------------------------------- */

  /**
   * A convenience reference to the image path (data.img) used to represent this Item
   * @type {string}
   */
  get img() {
    return this.data.img;
  }

  /* -------------------------------------------- */

  /**
   * A convenience reference to the item type (data.type) of this Item
   * @type {string}
   */
  get type() {
    return this.data.type;
  }

  /* -------------------------------------------- */

  /**
   * A boolean indicator for whether the current game user has ONLY limited visibility for this Entity.
   * @return {boolean}
   */
  get limited() {
    if (this.isOwned) return this.actor.limited;
    else return super.limited;
  }

  /* -------------------------------------------- */

  /**
   * A flag for whether the item is owned by an Actor entity
   * @return {boolean}
   */
  get isOwned() {
    return this.actor !== null;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Override the standard permission test for Item entities as we need to apply a special check for owned items
   * OwnedItems have permission that the player has for the parent Actor.
   * @return {Boolean}            Whether or not the user has the permission for this item
   */
  hasPerm(...args) {
    if (this.actor) return this.actor.hasPerm(...args);
    else return super.hasPerm(...args);
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /**
   * Extend the base Entity update logic to update owned items as well.
   * See Entity.update for more complete API documentation
   *
   * @param {Object} data   The data with which to update the entity
   * @param {Object} options  Additional options which customize the update workflow
   * @return {Promise}        A Promise which resolves to the updated Entity
   */
  async update(data, options) {

    // Case 1 - Update an OwnedItem within an Actor
    if (this.isOwned) {
      data._id = this.data._id;
      return this.actor.updateEmbeddedEntity("OwnedItem", data, options);
    }

    // Case 2 - Standard Entity update procedure
    else return super.update(data, options);
  }

  /* -------------------------------------------- */

  /**
   * A convenience constructor method to create an Item instance which is owned by an Actor
   * @param {Object} itemData
   * @param {Actor} actor
   */
  static createOwned(itemData, actor) {
    let Item = CONFIG.Item.entityClass;
    return new Item(itemData, {actor: actor});
  }
}

/**
 * The Journal collection
 * @type {Collection}
 */
class Journal extends Collection {

  /**
   * Return the Entity class which is featured as a member of this collection
   * @private
   */
  get object() {
    return JournalEntry;
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /**
   * Open Socket listeners which transact JournalEntry data
   * @private
   */
  static socketListeners(socket) {
    super.socketListeners(socket);
    socket.on("showEntry", this._showEntry.bind(this));
    socket.on("shareImage", this._shareImage);
  }

  /* -------------------------------------------- */

  /**
   * Handle a received request to show a JournalEntry to the current client
   * @param {String} entryId      The ID of the journal entry to display for other players
   * @param {String} mode         The JournalEntry mode to display
   * @param {Boolean} force       Display the entry to all players regardless of normal permissions
   * @private
   */
  static _showEntry(entryId, mode="text", force=true) {
    let entry = this.instance.get(entryId);
    if ( !force && !entry.visible ) return;

    // Don't show an entry that has no content
    if ( mode === "image" && !entry.data.img ) return;
    else if ( mode === "text" && !entry.data.content ) return;

    // Show the sheet with the appropriate mode
    entry.sheet.render(true, {sheetMode: mode});
  }

  /* -------------------------------------------- */

  /**
   * Handle a received request to show an Image file to the current client
   * @param {string} image    The web source path to the image to display
   * @param {string} title    The title to display for the image frame
   * @param {Object} entity   An optional Entity instance which the image belongs to
   * @private
   */
  static _shareImage({image, title, entity}={}) {
    new ImagePopout(image, {
      title: title,
      shareable: false,
      editable: false,
      entity: entity.type ? {type: entity.type, id: entity.id} : null
    }).render(true);
  }
}


/* -------------------------------------------- */


/**
 * The JournalEntry class
 */
class JournalEntry extends Entity {

  /**
   * Configure the attributes of the JournalEntry Entity
   *
   * @returns {Entity} baseEntity       The parent class which directly inherits from the Entity interface.
   * @returns {Collection} collection   The Collection class to which Entities of this type belong.
   * @returns {Array} embeddedEntities  The names of any Embedded Entities within the Entity data structure.
   */
  static get config() {
    return {
      baseEntity: JournalEntry,
      collection: game.journal,
      embeddedEntities: {}
    };
  }

  /* -------------------------------------------- */

  /**
   * A boolean indicator for whether or not the JournalEntry is visible to the current user in the directory sidebar
   * @return {boolean}
   */
  get visible() {
    return this.hasPerm(game.user, "OBSERVER", false);
  }

	/* -------------------------------------------- */

  /**
   * Return a reference to the Note instance for this JournalEntry in the current Scene, if any
   * @type {Note}
   */
	get sceneNote() {
	  return canvas.notes.placeables.find(n => n.data.entryId === this._id);
  }

  /* -------------------------------------------- */

  /** @override */
	_onUpdate(data, ...args) {
	  super._onUpdate(data, ...args);

    // If permissions changed for an entry with corresponding map Notes, redraw them
    if ( ["name", "permission"].some(k => k in data) ) {
      canvas.notes.placeables.filter(n => n.data.entryId === this._id).forEach(n => n.draw());
    }
  }

  /* -------------------------------------------- */
  /*  Methods
  /* -------------------------------------------- */

  /**
   * Show the JournalEntry to connected players.
   * By default the entry will only be shown to players who have permission to observe it.
   * If the parameter force is passed, the entry will be shown to all players regardless of normal permission.
   *
   * @param {String} mode     Which JournalEntry mode to display? Default is text.
   * @param {Boolean} force   Display the entry to all players regardless of normal permissions
   * @return {Promise}        A Promise that resolves back to the shown entry once the request is processed
   */
  async show(mode="text", force=false) {
    if ( !this.owner ) throw new Error("You may only request to show Journal Entries which you own.");
    return new Promise((resolve) => {
      game.socket.emit("showEntry", this._id, mode, force, entry => {
        Journal._showEntry(this._id, mode, true);
      });
    });
  }

  /* -------------------------------------------- */

  /**
   * If the JournalEntry has a pinned note on the canvas, this method will animate to that note
   * The note will also be highlighted as if hovered upon by the mouse
   */
  panToNote({scale=1.5, duration=250}={}) {
    const note = this.sceneNote;
    if ( !note ) return;
    if ( note.visible && !canvas.notes._active ) canvas.notes.activate();
    canvas.animatePan({x: note.x, y: note.y, scale, duration}).then(() => {
      if ( canvas.notes._hover ) canvas.notes._hover._onMouseOut(new Event("mouseout"));
      note._onMouseOver(new Event("mouseover"));
    });
  }
}

/**
 * The Collection of Macro entities
 * @extends {Collection}
 */
class Macros extends Collection {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get object() {
    return CONFIG.Macro.entityClass;
  }

	/* -------------------------------------------- */

  /** @override */
  static get instance() {
    return game.macros;
  }

	/* -------------------------------------------- */

  /** @override */
  get directory() {
    return ui.macros;
  }

	/* -------------------------------------------- */

  /**
   * Determine whether a given User is allowed to use JavaScript macros
   * @param {User} user   The User entity to test
   * @return {boolean}    Can the User use scripts?
   */
  static canUseScripts(user) {
    if ( user.isGM ) return true;
    return game.settings.get("core", "allowMacroScripts");
  }

	/* -------------------------------------------- */

  static registerSettings() {
    game.settings.register("core", "allowMacroScripts", {
      name: "MACRO.AllowScripts",
      hint: "MACRO.AllowScriptsHint",
      scope: "world",
      config: true,
      default: true,
      type: Boolean
    });
  }
}


/* -------------------------------------------- */


/**
 * The Macro entity which implements a triggered chat or script expression which can be quickly activated by the user.
 * All users have permission to create and use chat-based Macros, but users must be given special permission to use
 * script-based macros.
 *
 * @extends {Entity}
 *
 * @see {@link Macros}        The Collection of Macro entities
 * @see {@link MacroConfig}   The Macro Configuration sheet
 * @see {@link Hotbar}        The Hotbar interface application
 */
class Macro extends Entity {

  /** @override */
  static get config() {
    return {
      baseEntity: Macro,
      collection: game.macros,
      embeddedEntities: []
    };
  }

	/* -------------------------------------------- */

  /**
   * Execute the Macro command
   * @return {Promise}
   */
  execute() {

    // Chat macros
    if ( this.data.type === "chat" ) {
      ui.chat.processMessage(this.data.command).catch(err => {
        ui.notifications.error("There was an error in your chat message syntax.");
        console.error(err);
      });
    }

    // Script macros
    else if ( this.data.type === "script" ) {
      if ( !Macros.canUseScripts(game.user) ) {
        return ui.notifications.error(`You are not allowed to use JavaScript macros.`);
      }
      const speaker = ChatMessage.getSpeaker();
      const actor = game.actors.get(speaker.actor);
      const token = canvas.tokens.get(speaker.token);
      const character = game.user.character;
      try {
        eval(this.data.command);
      } catch (err) {
        ui.notifications.error(`There was an error in your macro syntax. See the console (F12) for details`);
        console.error(err);
      }
    }
  }
}

/**
 * A :class:`Collection` of class:`ChatMessage` entities
 * The Messages collection is accessible within the game as `game.messages`.
 *
 * @type {Collection}
 */
class Messages extends Collection {

  /**
   * Elements of the Messages collection are instances of the ChatMessage class
   * @return {ChatMessage}
   */
  get object() {
    return ChatMessage;
  }

  /* -------------------------------------------- */

  /**
   * Don't render any applications for this collection, as rendering is handled at a per-message level
   * @param force
   */
  render(force=false) {
    return;
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers
  /* -------------------------------------------- */

  /**
   * Create a new ChatMessage using provided data. See Collection._createEntity for more details.
   * @private
   */
  _createEntity(response) {
    const entity = super._createEntity(response);
    this._sayBubble(response);
    return entity;
  }

  /* -------------------------------------------- */

  /**
   * If requested, dispatch a Chat Bubble UI for the newly created message
   * @param {Object} response     The created ChatMessage response
   * @private
   */
  _sayBubble(response) {
    if ( response.options.chatBubble && canvas.ready ) {
      const message = response.created,
            speaker = message.speaker;
      if ( speaker.scene === canvas.scene._id ) {
        const token = canvas.tokens.get(speaker.token);
        if ( token ) canvas.hud.bubbles.say(token, message.content, {
          emote: message.type === CONST.CHAT_MESSAGE_TYPES.EMOTE
        });
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle export of the chat log to a text file
   * @private
   */
  export() {
    const log = this.entities.map(m => m.export()).join("\n---------------------------\n");
    let date = new Date().toDateString().replace(/\s/g, "-");
    const filename = `fvtt-log-${date}.txt`;
    saveDataToFile(log, "text/plain", filename);
  }

  /* -------------------------------------------- */

  /**
   * Allow for bulk deletion of all chat messages, confirm first with a yes/no dialog.
   * @see {@link Dialog.confirm}
   */
  async flush() {
    return Dialog.confirm({
      title: game.i18n.localize("CHAT.FlushTitle"),
      content: game.i18n.localize("CHAT.FlushWarning"),
      yes: () => this.object.deleteMany([], {deleteAll: true})
    }, {
      top: window.innerHeight - 150,
      left: window.innerWidth - 720,
    });
  }
}


/* -------------------------------------------- */


/**
 * The Chat Message class is a type of :class:`Entity` which represents individual messages in the chat log.
 *
 * @type {Entity}
 */
class ChatMessage extends Entity {
  constructor(...args) {
    super(...args);

    /**
     * Get a reference to the user who sent the chat message
     */
    this.user = game.users.get(this.data.user);

    /**
     * If the Message contains a dice roll, store it here
     */
    this._roll = null;
  }

  /* -------------------------------------------- */

  /**
   * Configure the attributes of the ChatMessage Entity
   *
   * @returns {Entity} baseEntity       The parent class which directly inherits from the Entity interface.
   * @returns {Collection} collection   The Collection class to which Entities of this type belong.
   * @returns {Array} embeddedEntities  The names of any Embedded Entities within the Entity data structure.
   */
  static get config() {
    return {
      baseEntity: ChatMessage,
      collection: game.messages,
      embeddedEntities: {}
    };
  }

  /* -------------------------------------------- */
  /*  Properties and Attributes
  /* -------------------------------------------- */

  /**
   * Return the recommended String alias for this message.
   * The alias could be a Token name in the case of in-character messages or dice rolls.
   * Alternatively it could be a User name in the case of OOC chat or whispers.
   * @type {string}
   */
  get alias() {
    const speaker = this.data.speaker;
    if ( speaker.alias ) return speaker.alias;
    else if ( speaker.actor ) return game.actors.get(speaker.actor).name;
    else return this.user ? this.user.name : "";
  }

  /* -------------------------------------------- */

  /**
   * Return whether the ChatMessage is visible to the current user
   * Messages may not be visible if they are private whispers
   * @type {boolean}
   */
  get visible() {
    if ( this.data.whisper.length ) {
      if ( this.data.type === CONST.CHAT_MESSAGE_TYPES.ROLL ) return true;
      if ( (this.data.user === game.user._id) || this.data.whisper.indexOf(game.user._id ) !== -1 ) return true;
      let allowSecret = game.settings.get("core", "secretMessages");
      return ( !allowSecret && game.user.isGM );
    }
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Is the current User the author of this message?
   * @type {boolean}
   */
  get isAuthor() {
    return game.user._id === this.user._id;
  }

  /* -------------------------------------------- */

  /**
   * Test whether the chat message contains a dice roll
   * @type {boolean}
   */
  get isRoll() {
    return this.data.type === CONST.CHAT_MESSAGE_TYPES.ROLL;
  }

  /* -------------------------------------------- */

  /**
   * Return whether the message contains a visible dice roll.
   * @type {boolean|null}   Whether the roll is visible, or null if the message is not a roll
   */
  get isRollVisible() {
    if ( !this.isRoll ) return null;
    const whisper = this.data.whisper || [];
    return (!this.data.blind && this.isAuthor) || whisper.includes(game.user._id);
  }

  /* -------------------------------------------- */

  /**
   * Return the Roll instance contained in this chat message, if one is present
   * @type {Roll}
   */
  get roll() {
    if ( this._roll === null ) {
      try {
        this._roll = Roll.fromJSON(this.data.roll);
      } catch(err) {
        this._roll = false;
      }
    }
    return this._roll;
  }

  /* -------------------------------------------- */
  /*  HTML Rendering
  /* -------------------------------------------- */

  /**
   * Render the HTML for the ChatMessage which should be added to the log
   * @return {Promise.<HTMLElement>}
   */
  async render() {

    // Determine some metadata
    const isWhisper = this.data.whisper.length;

    // Construct message data
    const messageData = {
      user: game.user,
      author: this.user,
      alias: this.alias,
      message: duplicate(this.data),
      cssClass: [
        this.data.type === CONST.CHAT_MESSAGE_TYPES.IC ? "ic" : null,
        this.data.type === CONST.CHAT_MESSAGE_TYPES.EMOTE ? "emote" : null,
        isWhisper ? "whisper" : null,
        this.data.blind ? "blind": null
      ].filter(c => c !== null).join(" "),
      isWhisper: this.data.whisper.length > 1,
      whisperTo: this.data.whisper.map(u => {
        let user = game.users.get(u);
        return user ? user.name : null;
      }).filter(n => n !== null).join(", ")
    };

    // Enrich some data for dice rolls
    if ( this.isRoll ) {
      const isVisible = this.isRollVisible;
      messageData.message.content = await this.roll.render({isPrivate: !isVisible});
      if ( isWhisper ) {
        const subject = this.data.user === game.user._id ? "You" : this.user.name;
        messageData.message.flavor = `${subject} privately rolled some dice`;
      }
      if ( !isVisible ) {
        messageData.isWhisper = false;
        messageData.alias = this.user.name;
      }
    }

    // Define a border color
    if ( this.data.type === CONST.CHAT_MESSAGE_TYPES.OOC ) {
      messageData.borderColor = this.user.color;
    }

    // Render the chat message
    let html = await renderTemplate(CONFIG.ChatMessage.template, messageData);
    html = $(html);

    // Call a hook for the rendered ChatMessage data
    Hooks.call("renderChatMessage", this, html, messageData);
    return html;
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers
  /* -------------------------------------------- */

  /** @override */
  static async create(data, options) {
    return super.create(this._preprocessCreateData(data), options);
  }

  /* -------------------------------------------- */

  /** @override */
  static async createMany(data, options={}) {
    return super.createMany(data.map(this._preprocessCreateData), options);
  }

  /* -------------------------------------------- */

  /**
   * Preprocess the data object used to create a new Chat Message to automatically convert some Objects to the
   * data format expected by the database handler.
   * @param {Object} data
   * @return {Object}
   * @private
   */
  static _preprocessCreateData(data) {

    // Message creator
    if ( data.user instanceof User ) data.user = data.user._id;
    if ( !data.user ) data.user = game.user._id;

    // Ensure to pass IDs rather than objects
    if ( data.speaker && data.speaker.actor instanceof Actor ) data.speaker.actor = data.speaker.actor._id;
    if ( data.speaker && data.speaker.scene instanceof Scene ) data.speaker.scene = data.speaker.scene._id;
    if ( data.speaker && data.speaker.token instanceof Token ) data.speaker.token = data.speaker.token.id;

    // Serialize Roll data
    if ( data.roll && (data.roll instanceof Roll) ) {
      data.roll = JSON.stringify(data.roll);
    }

    // Enrich message content
    data.content = TextEditor.enrichHTML(data.content);
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Specific actions that should occur be when the ChatMessage is first created
   * @private
   */
	_onCreate(...args) {
	  super._onCreate(...args);
	  let notify = this.data.user._id !== game.user._id;
	  ui.chat.postOne(this, notify);
	}

  /* -------------------------------------------- */

  /**
   * Specific actions that should occur be when an existing ChatMessage is updated
   * @private
   */
	_onUpdate(...args) {
	  super._onUpdate(...args);
    ui.chat.updateMessage(this);
  }

  /* -------------------------------------------- */

  /**
   * Specific actions that should occur be when an existing ChatMessage is deleted
   * @private
   */
	_onDelete(...args) {
	  super._onDelete(...args);
    ui.chat.deleteMessage(this._id);
  }

  /* -------------------------------------------- */
  /*  Saving and Loading
  /* -------------------------------------------- */

  /**
   * Export the content of the chat message into a standardized log format
   * @return {String}
   */
  export() {
    let content = [];

    // Handle Roll content
    if ( this.isRoll ) {
      let r = this.roll;
      if ( this.data.content && (this.data.content !== "undefined")) {
        content.push($(`<div>${this.data.content}</div>`).text().trim());
      }
      let flavor = this.data.flavor;
      if ( flavor && flavor !== r.formula ) content.push(flavor);
      content.push(`${r.formula} = ${r.result} = ${r.total}`);
    }

    // Handle HTML content
    else {
      const html = $("<article>").html(this.data["content"].replace(/<\/div>/g, "</div>|n"));
      const text = html.length ? html.text() : this.data["content"];
      const lines = text.replace(/\n/g, "").split("  ").filter(p => p !== "").join(" ");
      content = lines.split("|n").map(l => l.trim());
    }

    // Author and timestamp
    const time = new Date(this.data.timestamp).toLocaleDateString('en-US', {
      hour: "numeric",
      minute: "numeric",
      second: "numeric"
    });

    // Format logged result
    return `[${time}] ${this.alias}\n${content.filterJoin("\n")}`;
  }

  /* -------------------------------------------- */

  /**
   * Given a string whisper target, return an Array of the user IDs which should be targeted for the whisper
   *
   * @param {String} name   The target name of the whisper target
   * @return {Array}        An array of user IDs (or possibly none)
   */
  static getWhisperIDs(name) {

    // Whisper to groups
    if (["GM", "DM"].includes(name.toUpperCase())) {
      return game.users.entities.filter(u => u.isGM).map(u => u._id);
    }
    else if (name.toLowerCase() === "players") {
      return game.users.players.map(u => u._id);
    }

    // Match against lowercase name
    const lname = name.toLowerCase();

    // Whisper to a single person
    let user = game.users.entities.find(u => u.name.toLowerCase() === lname);
    if (user) return [user._id];
    let actor = game.users.entities.find(a => a.character && a.character.name.toLowerCase() === lname);
    if (actor) return [actor._id];

    // Otherwise return an empty array
    return [];
  }

  /* -------------------------------------------- */

  /**
   * Attempt to determine who is the speaking character (and token) for a certain Chat Message
   * First assume that the currently controlled Token is the speaker
   * @returns {Object}  The identified speaker data
   */
  static getSpeaker({scene, actor, token, alias}={}) {
    let speaker = null;

    // CASE 1 - A Token is explicitly provided
    if ( token instanceof Token ) speaker = this._getSpeakerFromToken({token, alias});

    // CASE 2 - An Actor is explicitly provided
    else if ( actor instanceof Actor ) {
      alias = alias || actor.name;
      const tokens = actor.getActiveTokens();
      if ( !tokens.length ) speaker = this._getSpeakerFromActor({scene, actor, alias});
      else {
        const controlled = tokens.filter(t => t._controlled);
        token = controlled.length ? controlled.shift() : tokens.shift();
        speaker = this._getSpeakerFromToken({token, alias});
      }
    }

    // CASE 3 - Not the viewed Scene
    else if ( ( scene instanceof Scene ) && !scene.isView ) {
      const char = game.user.character;
      if ( char ) speaker = this._getSpeakerFromActor({scene, actor: char, alias});
      else speaker = this._getSpeakerFromUser({scene, user: game.user, alias});
    }

    // Remaining cases - infer from the viewed Scene
    else {
      const char = game.user.character;

      // CASE 4 - Infer from controlled tokens
      let controlled = canvas.tokens.controlled;
      if ( controlled.length ) speaker = this._getSpeakerFromToken({token: controlled.shift(), alias});

      // CASE 5 - Infer from impersonated Actor
      else if ( char ) {
        const tokens = char.getActiveTokens();
        if ( tokens.length ) speaker = this._getSpeakerFromToken({token: tokens.shift(), alias});
        else speaker = this._getSpeakerFromActor({actor: char, alias});
      }

      // CASE 6 - From the alias and User
      else speaker = this._getSpeakerFromUser({scene, user: game.user, alias});
    }

    // Clean data and return
    return {
      scene: speaker.scene instanceof Scene ? speaker.scene.id : speaker.scene,
      actor: speaker.actor instanceof Actor ? speaker.actor.id : speaker.actor,
      token: speaker.token instanceof Token ? speaker.token.id : speaker.token,
      alias: speaker.alias
    }
  }

  /* -------------------------------------------- */

  /**
   * A helper to prepare the speaker object based on a target Token
   * @private
   */
  static _getSpeakerFromToken({token, alias}) {
    return {
      scene: token.scene,
      token: token,
      actor: token.actor,
      alias: alias || token.name
    }
  }

  /* -------------------------------------------- */

  /**
   * A helper to prepare the speaker object based on a target Actor
   * @private
   */
  static _getSpeakerFromActor({scene, actor, alias}) {
    return {
      scene: scene || canvas.scene,
      actor: actor,
      token: null,
      alias: alias || actor.name
    }
  }

  /* -------------------------------------------- */

  /**
   * A helper to prepare the speaker object based on a target User
   * @private
   */
  static _getSpeakerFromUser({scene, user, alias}) {
    return {
      scene: scene || canvas.scene,
      actor: null,
      token: null,
      alias: alias || user.name
    }
  }
}

/**
 * The Collection of Playlist entities.
 * @extends {Collection}
 */
class Playlists extends Collection {
  constructor(...args) {
    super(...args);
    Hooks.on("preUpdateScene", this._onUpdateScene.bind(this));
  }

  /* -------------------------------------------- */

  /** @override */
  get object() {
    return Playlist;
  }

  /* -------------------------------------------- */

  /**
   * Return the subset of Playlist entities which are currently playing
   * @type {Array}
   */
  get playing() {
    return this.entities.filter(s => s.data.playing);
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to a Scene to determine whether to trigger changes to Playlist entities.
   * @param {Scene} scene       The Scene entity being updated
   * @param {Object} data       Incremental update data
   * @param {Object} options    Update options
   * @private
   */
  _onUpdateScene(scene, data, options) {
    const currentScene = game.scenes.active;
    scene = scene instanceof Scene ? scene : game.scenes.get(data._id); // this is a kind of temporary workaround
    const activeChange = data.active === true;
    const playlistChange = data.hasOwnProperty("playlist");
    if ( activeChange || (scene.data.active && playlistChange) ) {
      const currentPlaylist = currentScene && currentScene.playlist ? currentScene.playlist : null;
      const newPlaylist = playlistChange ? this.get(data.playlist) : scene.playlist;
      if ( (currentPlaylist === newPlaylist) && currentPlaylist && currentPlaylist.playing ) return;
      if ( currentPlaylist ) currentPlaylist.stopAll();
      if ( newPlaylist ) newPlaylist.playAll();
    }
  }
}


/* -------------------------------------------- */


/**
 * The Playlist Entity.
 * Each Playlist is a collection of Sounds which are used to provide background music and sound effects.
 * @extends {Entity}
 */
class Playlist extends Entity {
  constructor(...args) {
    super(...args);

    /**
     * Each sound which is played within the Playlist has a created Howl instance.
     * The keys of this object are the sound IDs and the values are the Howl instances.
     * @type {Object}
     */
    this.audio = this.audio || {};

    /**
     * Playlists may have a playback order which defines the sequence of Playlist Sounds
     * @type {Array}
     */
    this.playbackOrder = [];
  }

  /* -------------------------------------------- */

  /** @override */
  static get config() {
    return {
      baseEntity: Playlist,
      collection: game.playlists,
      embeddedEntities: {"PlaylistSound": "sounds"}
    };
  }

  /* -------------------------------------------- */

  /** @override */
  prepareEmbeddedEntities() {
    this.audio = {};
    this.data.sounds.forEach(s => this._createAudio(s));
  }

  /* -------------------------------------------- */

  /**
   * Set up the Howl object by calling the core AudioHelper utility
   * @param {Object} sound    The PlaylistSound for which to create an audio object
   * @return {Object}         The created audio object
   * @private
   */
  _createAudio(sound) {
    let howl = game.audio.create({src: sound.path});
    this.audio[sound._id] = {
      howl: howl,
      id: undefined,
      sound: sound._id
    };
    howl.on("end", () => this._onEnd(sound._id));

    // Handle sounds which are currently playing
    if (sound.playing) {
      if (Howler.state === "suspended") game.audio.pending.push(() => this.playSound(sound));
      else this.playSound(sound);
    }
  }

  /* -------------------------------------------- */

  /**
   * This callback triggers whenever a sound concludes playback
   * Mark the concluded sound as no longer playing and possibly trigger playback for a subsequent sound depending on
   * the playlist mode.
   *
   * @param {string} soundId  The sound ID of the track which is ending playback
   * @private
   */
  async _onEnd(soundId) {
    if (!game.user.isGM) return;

    // Retrieve the sound object whose reference may have changed
    const sound = this.getEmbeddedEntity("PlaylistSound", soundId);
    if (sound.repeat) return;

    // Conclude playback for the current sound
    const isPlaying = this.data.playing;
    await this.updateEmbeddedEntity("PlaylistSound", {_id: sound._id, playing: false});

    // Sequential or shuffled playback -- begin playing the next sound
    if (isPlaying && [CONST.PLAYLIST_MODES.SEQUENTIAL, CONST.PLAYLIST_MODES.SHUFFLE].includes(this.mode)) {
      let next = this._getNextSound(sound._id);
      if (next) await this.updateEmbeddedEntity("PlaylistSound", {_id: next._id, playing: true});
      else await this.update({playing: false});
    }

    // Simultaneous playback - check if all have finished
    else if (isPlaying && this.mode === CONST.PLAYLIST_MODES.SIMULTANEOUS) {
      let isComplete = !this.sounds.some(s => s.playing);
      if (isComplete) {
        await this.update({playing: false});
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Generate a new playback order for the playlist.
   * Use a seed for randomization to (hopefully) guarantee that all clients generate the same random order.
   * The seed is based on the first 9 characters of the UTC datetime multiplied by the index order of the playlist.
   * @private
   */
  _getPlaybackOrder() {
    const idx = this.collection.entities.findIndex(e => e._id === this.data._id);
    const seed = Number(new Date().getTime().toString().substr(0, 9)) * idx;
    const mt = new MersenneTwister(seed);

    // Draw a random order
    let shuffle = this.sounds.reduce((shuffle, s) => {
      shuffle[s._id] = mt.random();
      return shuffle;
    }, {});

    // Return the playback order
    return this.sounds.map(s => s._id).sort((a, b) => shuffle[a] - shuffle[b]);
  }

  /* -------------------------------------------- */

  /**
   * Get the next sound which should be played in the Playlist after the current sound completes
   * @param {string} soundId    The ID of the currently playing sound
   * @return {Object}           The sound data for the next sound to play
   * @private
   */
  _getNextSound(soundId) {

    // Get the playback order
    let order;
    if (this.mode === CONST.PLAYLIST_MODES.SHUFFLE) {
      if (!this.playbackOrder.length) this.playbackOrder = this._getPlaybackOrder();
      order = this.playbackOrder;
    } else order = this.sounds.map(s => s._id);

    // Cycle the playback index
    let idx = order.indexOf(soundId);
    if (idx === order.length - 1) idx = -1;

    // Return the next sound
    return this.getEmbeddedEntity("PlaylistSound", order[idx + 1]);
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * An Array of the sound data contained within this Playlist entity
   * @type {Array}
   */
  get sounds() {
    return this.data.sounds;
  }

  /* -------------------------------------------- */

  /**
   * The playback mode for the Playlist instance
   * @type {Number}
   */
  get mode() {
    return this.data.mode;
  }

  /* -------------------------------------------- */

  /**
   * An indicator for whether any Sound within the Playlist is currently playing
   * @type {boolean}
   */
  get playing() {
    return this.sounds.some(s => s.playing);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Play (or stop) a single sound from the Playlist
   * @param sound {Object}       The sound object to begin playback
   */
  playSound(sound) {

    // Get the audio data
    const audio = this.audio[sound._id];
    if (!sound.playing && !audio.id) return;

    // Start playing
    if (sound.playing) {
      if (audio.howl.state() !== "loaded") audio.howl.load();
      audio.id = audio.howl.play(audio.id);
      let vol = sound.volume * game.settings.get("core", "globalPlaylistVolume");
      audio.howl.volume(vol, audio.id);
      audio.howl.loop(sound.repeat, audio.id);
    }

    // End playback
    else audio.howl.stop(audio.id);
  }

  /* -------------------------------------------- */

  /**
   * Begin simultaneous playback for all sounds in the Playlist
   * @return {Promise}    A Promise which resolves once the Playlist update is complete
   */
  async playAll() {
    const updateData = {};

    // Handle different playback modes
    switch (this.mode) {

      // Soundboard Only
      case CONST.PLAYLIST_MODES.DISABLED:
        updateData.playing = false;
        break;

      // Sequential Playback
      case CONST.PLAYLIST_MODES.SEQUENTIAL:
        updateData.sounds = duplicate(this.data.sounds).map((s, i) => {
          s.playing = i === 0;
          return s;
        });
        updateData.playing = updateData.sounds.length > 0;
        break;

      // Simultaneous - play all tracks
      case CONST.PLAYLIST_MODES.SIMULTANEOUS:
        updateData.sounds = duplicate(this.data.sounds).map(s => {
          s.playing = true;
          return s;
        });
        updateData.playing = updateData.sounds.length > 0;
        break;


      // Shuffle - play random track
      case CONST.PLAYLIST_MODES.SHUFFLE:
        this.playbackOrder = this._getPlaybackOrder();
        updateData.sounds = duplicate(this.data.sounds).map(s => {
          s.playing = s._id === this.playbackOrder[0];
          return s;
        });
        updateData.playing = updateData.sounds.length > 0;
        break;
    }

    // Update the Playlist
    return this.update(updateData);
  }

  /* -------------------------------------------- */

  /**
   * End playback for any/all currently playing sounds within the Playlist
   * @return {Promise}    A Promise which resolves once the Playlist update is complete
   */
  async stopAll() {
    const sounds = duplicate(this.data.sounds).map(s => {
      s.playing = false;
      return s;
    });
    return this.update({playing: false, sounds: sounds});
  }

  /* -------------------------------------------- */

  /**
   * Cycle the playlist mode
   * @return {Promise.<Playlist>}   A promise which resolves to the updated Playlist instance
   */
  async cycleMode() {

    // Cycle the playback mode
    const modes = Object.values(CONST.PLAYLIST_MODES);
    let mode = this.mode + 1;
    mode = mode > Math.max(...modes) ? modes[0] : mode;

    // Stop current playback
    let sounds = this.data.sounds.map(s => {
      s.playing = false;
      return s;
    });

    // Update the playlist
    return this.update({sounds: sounds, mode: mode});
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /**
   * @extends {Entity.createEmbeddedEntity}
   * @deprecated
   */
  async createSound(data, options = {}) {
    console.warn("You are using Playlist.createSound() which is deprecated in favor of the generalized Entity.createEmbeddedEntity() method");
    return this.createEmbeddedEntity("PlaylistSound", data, options);
  }

  /* -------------------------------------------- */

  /**
   * @extends {Entity.updateEmbeddedEntity}
   * @deprecated
   */
  async updateSound(data, options = {}) {
    console.warn("You are using Playlist.updateSound() which is deprecated in favor of the generalized Entity.updateEmbeddedEntity() method");
    return this.updateEmbeddedEntity("PlaylistSound", data, options);
  }

  /* -------------------------------------------- */

  /**
   * @extends {Entity.deleteEmbeddedEntity}
   * @deprecated
   */
  async deleteSound(id, options = {}) {
    console.warn("You are using Playlist.deleteSound() which is deprecated in favor of the generalized Entity.deleteEmbeddedEntity() method");
    return this.deleteEmbeddedEntity("PlaylistSound", id, options);
  }

  /* -------------------------------------------- */

  /** @override */
  _onUpdate(response) {
    // Modify playback for individual sounds
    this.sounds.forEach(s => this.playSound(s));
    return super._onUpdate(response);
  }

  /* -------------------------------------------- */

  /** @override */
  _onCreateEmbeddedEntity(response) {
    // Create the audio object
    const sound = response.created;
    this._createAudio(sound);
    return super._onCreateEmbeddedEntity(response);
  }

  /* -------------------------------------------- */

  /** @override */
  _onUpdateEmbeddedEntity(response) {
    const changed = Object.keys(response.data);
    const sound = this.getEmbeddedEntity("PlaylistSound", response.data._id);

    // If the path was changed, we need to re-create the audio object
    if ( changed.includes("path") ) {
      const audio = this.audio[sound._id];
      audio.howl.stop(audio.id);
      this._createAudio(sound);
    }

    // Otherwise update the playing state
    else this.playSound(sound);
    return super._onUpdateEmbeddedEntity(response);
  }

  /* -------------------------------------------- */

  /** @override */
  _onDeleteEmbeddedEntity(response) {
    const sound = response.deleted;
    sound.playing = false;
    this.playSound(sound);
    delete this.audio[sound._id];
    return super._onDeleteEmbeddedEntity(response);
  }

  /* -------------------------------------------- */

  /** @override */
  _onModifyEmbeddedEntity(response) {
    this.collection.render();
  }
}

/**
 * The collection of Scene entities
 */
class Scenes extends Collection {

  /** @override */
  get object() {
    return Scene;
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the Scene which is currently active
   * @return {Scene}
   */
  get active() {
    return this.entities.find(s => s.active);
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the Scene which is currently viewed
   * @return {Scene}
   */
  get viewed() {
    return this.entities.find(s