foundry.js


const vtt = "Foundry VTT";
const VTT = "Foundry Virtual Tabletop";
const 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", "Macro", "RollTable", "Playlist"];

/**
 * Define the set of languages which have built-in support in the core software
 * @type {string[]}
 */
const CORE_SUPPORTED_LANGUAGES = ["en"];

/**
 * 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: DRAWING_FILL_TYPES.NONE,
  fillAlpha: 0.5,
  bezierFactor: 0.0,
  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",
];

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

/**
 * 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
};

/**
 * EULA version number
 * @type {String}
 */
const EULA_VERSION = "0.6.1";

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

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

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

/**
 * 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
};

/**
 * 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
};


/**
 * Encode the reasons why a package may be available or unavailable for use
 * @type {Object}
 */
const PACKAGE_AVAILABILITY_CODES = {
  "UNKNOWN": -1,
  "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 allowed software update channels
 * @type {Object}
 */
const SOFTWARE_UPDATE_CHANNELS = {
  "alpha": "SETUP.UpdateAlpha",
  "beta": "SETUP.UpdateBeta",
  "release": "SETUP.UpdateRelease"
};


/**
 * 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(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": {
    label: "PERMISSION.BroadcastAudio",
		hint: "PERMISSION.BroadcastAudioHint",
		disableGM: true,
    defaultRole: USER_ROLES.TRUSTED
  },
  "BROADCAST_VIDEO": {
    label: "PERMISSION.BroadcastVideo",
		hint: "PERMISSION.BroadcastVideoHint",
		disableGM: true,
    defaultRole: USER_ROLES.TRUSTED
  },
  "ACTOR_CREATE": {
    label: "PERMISSION.ActorCreate",
		hint: "PERMISSION.ActorCreateHint",
		disableGM: false,
    defaultRole: USER_ROLES.ASSISTANT
  },
  "DRAWING_CREATE": {
    label: "PERMISSION.DrawingCreate",
		hint: "PERMISSION.DrawingCreateHint",
		disableGM: false,
    defaultRole: USER_ROLES.TRUSTED
  },
  "ITEM_CREATE": {
    label: "PERMISSION.ItemCreate",
		hint: "PERMISSION.ItemCreateHint",
		disableGM: false,
    defaultRole: USER_ROLES.ASSISTANT
  },
  "FILES_BROWSE": {
    label: "PERMISSION.FilesBrowse",
		hint: "PERMISSION.FilesBrowseHint",
		disableGM: false,
    defaultRole: USER_ROLES.TRUSTED
  },
  "FILES_UPLOAD": {
    label: "PERMISSION.FilesUpload",
		hint: "PERMISSION.FilesUploadHint",
		disableGM: false,
    defaultRole: USER_ROLES.ASSISTANT
  },
  "JOURNAL_CREATE": {
      label: "PERMISSION.JournalCreate",
      hint: "PERMISSION.JournalCreateHint",
      disableGM: false,
      defaultRole: USER_ROLES.TRUSTED
  },
  "MACRO_SCRIPT": {
    label: "PERMISSION.MacroScript",
		hint: "PERMISSION.MacroScriptHint",
		disableGM: false,
    defaultRole: USER_ROLES.PLAYER
  },
  "MESSAGE_WHISPER": {
    label: "PERMISSION.MessageWhisper",
		hint: "PERMISSION.MessageWhisperHint",
		disableGM: false,
    defaultRole: USER_ROLES.PLAYER
  },
  "SETTINGS_MODIFY": {
    label: "PERMISSION.SettingsModify",
		hint: "PERMISSION.SettingsModifyHint",
		disableGM: false,
    defaultRole: USER_ROLES.ASSISTANT
  },
  "SHOW_CURSOR": {
    label: "PERMISSION.ShowCursor",
		hint: "PERMISSION.ShowCursorHint",
		disableGM: true,
    defaultRole: USER_ROLES.PLAYER
  },
  "SHOW_RULER": {
    label: "PERMISSION.ShowRuler",
    hint: "PERMISSION.ShowRulerHint",
    disableGM: true,
    defaultRole: USER_ROLES.PLAYER
  },
  "TEMPLATE_CREATE": {
    label: "PERMISSION.TemplateCreate",
		hint: "PERMISSION.TemplateCreateHint",
		disableGM: false,
    defaultRole: USER_ROLES.PLAYER
  },
  "TOKEN_CREATE": {
    label: "PERMISSION.TokenCreate",
		hint: "PERMISSION.TokenCreateHint",
		disableGM: false,
    defaultRole: USER_ROLES.ASSISTANT
  },
  "TOKEN_CONFIGURE": {
    label: "PERMISSION.TokenConfigure",
		hint: "PERMISSION.TokenConfigureHint",
		disableGM: false,
    defaultRole: USER_ROLES.TRUSTED
  },
  "WALL_DOORS": {
    label: "PERMISSION.WallDoors",
		hint: "PERMISSION.WallDoorsHint",
		disableGM: false,
    defaultRole: USER_ROLES.PLAYER
  }
};


/**
 * 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 allowed set of HTML template extensions
 * @type {string[]}
 */
const HTML_FILE_EXTENSIONS = ["html", "hbs"];

/**
 * 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", "m4v"];

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

// Module Export
const CONST = {
  vtt, VTT, WEBSITE_URL, BASE_ENTITY_TYPE, CHAT_MESSAGE_TYPES, COMPENDIUM_ENTITY_TYPES, CORE_SUPPORTED_LANGUAGES,
  DEFAULT_TOKEN, DEFAULT_NOTE_ICON, DICE_ROLL_MODES, DRAWING_DEFAULT_VALUES, DRAWING_TYPES, DRAWING_FILL_TYPES,
  ENTITY_PERMISSIONS, ENTITY_TYPES, ENTITY_LINK_TYPES, EULA_VERSION, FOLDER_ENTITY_TYPES, FOLDER_MAX_DEPTH,
  GRID_MIN_SIZE, GRID_TYPES, MACRO_SCOPES, PLAYLIST_MODES, PACKAGE_AVAILABILITY_CODES, PASSWORD_SAFE_STRING,
  SOFTWARE_UPDATE_CHANNELS, SORT_INTEGER_DENSITY, TABLE_RESULT_TYPES, TEXT_ANCHOR_POINTS,
  TOKEN_DISPLAY_MODES, TOKEN_DISPOSITIONS,
  USER_PERMISSIONS, USER_ROLES, USER_ROLE_NAMES,
  WALL_SENSE_TYPES, WALL_MOVEMENT_TYPES, WALL_DOOR_STATES, WALL_DIRECTIONS, WALL_DOOR_TYPES,
  HTML_FILE_EXTENSIONS, IMAGE_FILE_EXTENSIONS, VIDEO_FILE_EXTENSIONS, AUDIO_FILE_EXTENSIONS
};
try {
  module.exports = CONST;
} catch(err) {
  window.CONST = CONST;
}


/* -------------------------------------------- */
/*  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(degrees) {
  return (degrees % 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 if ( /^HTML/.test(cn) ) return "HTMLElement";
    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;
    const prefix = [window.location.origin, ROUTE_PREFIX].filterJoin("/")+"/";
    formData[img.dataset.edit] = img.src.replace(prefix, "");
  }

  // 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
   * AudioHelper.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);
    return 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                       */
  /* -------------------------------------------- */

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

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

  /**
   * 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 reusable storage concept which blends the functionality of an Array with the efficient key-based lookup of a Map.
 * This concept is reused throughout Foundry VTT where a collection of uniquely identified elements is required.
 * @extends {Map}
 */
class Collection extends Map {
  constructor(entries) {
    super(entries);
  }

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

  /**
   * When iterating over a Collection, we should iterate over its values instead of over its entries
   */
  [Symbol.iterator]() {
    return this.values();
  }

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

  /**
   * Return an Array of all the entry values in the Collection
   * @return {V[]}
   */
  get entries() {
    return Array.from(this.values());
  }

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

  /**
   * Find an entry in the Map using an functional condition.
   * @see {Array#find}
   *
   * @param {Function} condition  The functional condition to test
   * @return {V|null}             The value, if found, otherwise null
   *
   * @example
   * let c = new Collection([["a", "A"], ["b", "B"], ["c", "C"]]);
   * let a = c.find(entry => entry === "A");
   */
  find(condition) {
    let entry = null;
    for ( let e of this.values() ) {
      if ( condition(e) ) {
        return entry = e;
      }
    }
    return null;
  }

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

  /**
   * Filter the Collection, returning an Array of entries which match a functional condition.
   * @see {Array#filter}
   * @param {Function} condition  The functional condition to test
   * @return {V[]}                An Array of matched values
   *
   * @example
   * let c = new Collection([["a", "AA"], ["b", "AB"], ["c", "CC"]]);
   * let hasA = c.filters(entry => entry.slice(0) === "A");
   */
  filter(condition) {
    const entries = [];
    for ( let e of this.values() ) {
      if ( condition(e) ) {
        entries.push(e);
      }
    }
    return entries;
  }

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

  /**
   * Get an element from the Collection by its key.
   * @param {string} key      The key of the entry to retrieve
   * @param {boolean} strict  Throw an Error if the requested id does not exist, otherwise return null. Default false
   * @return {V|null}         The retrieved entry value, if the key exists, otherwise null
   *
   * @example
   * let c = new Collection([["a", "A"], ["b", "B"], ["c", "C"]]);
   * c.get("a"); // "A"
   * c.get("d"); // null
   * c.get("d", {strict: true}); // throws Error
   */
  get(key, {strict=false}={}) {
    const entry = super.get(key);
    if ( strict && !entry ) {
      throw new Error(`The key ${key} does not exist in the ${this.constructor.name} Collection`);
    }
    return entry || null;
  }

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

  /**
   * Transform each element of the Collection into a new form, returning an Array of transformed values
   * @param {Function} transformer  The transformation function to apply to each entry value
   * @return {V[]}                  An Array of transformed values
   */
  map(transformer) {
    const transformed = [];
    for ( let e of this.values() ) {
      transformed.push(transformer(e));
    }
    return transformed;
  }

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

  /**
   * Reduce the Collection by applying an evaluator function and accumulating entries
   * @see {Array#reduce}
   * @param {Function} evaluator    A function which mutates the accumulator each iteration
   * @param {any} initial           An initial value which accumulates with each iteration
   * @return {any}                  The accumulated result
   *
   * @example
   * let c = new Collection([["a", "A"], ["b", "B"], ["c", "C"]]);
   * let letters = c.reduce((s, l) => {
   *   return s + l;
   * }, ""); // "ABC"
   */
  reduce(evaluator, initial) {
    let accumulator = initial;
    for ( let e of this.values() ) {
      accumulator = evaluator(accumulator, e)
    }
    return accumulator;
  }
}

/**
 * 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
 * @deprecated since 0.6.4, to be removed in 0.8.x
 * @param fontName
 * @param callback
 */
function loadFont(fontName, callback) {
  console.warn(`The loadFont() utility method has been deprecated in favor of alternative methods for including font definitions. Please discontinue use of loadFont() which will be removed in 0.8.x`);

  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(`${vtt} | 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', this._onKeyDown.bind(this));
    window.addEventListener('keyup', this._onKeyUp.bind(this));
    window.addEventListener("visibilitychange", this._reset.bind(this));
    window.addEventListener("wheel", this._onWheel.bind(this), {passive: false});
    window.addEventListener("compositionend", this._onCompositionEnd.bind(this));
  }

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

  /**
   * 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) {
    if ( !event ) return false;
    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) && ((event.code in this.moveKeys) || (event.code in this.zoomKeys)) ) {
      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 this.constructor.MOVEMENT_KEYS;
  }

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

  /**
   * The key codes which represent a digit key
   * @return {Array.<string>}
   */
  get digitKeys() {
    return this.constructor.DIGIT_KEYS;
  }

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

  /**
   * Return the key codes used for zooming the canvas
   * @return {Object.<string>}
   */
  get zoomKeys() {
    return this.constructor.ZOOM_KEYS;
  }

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

  /**
   * 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) {
    if ( event.isComposing ) return; // Ignore IME composition
    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) {
    if ( event.isComposing ) return; // Ignore IME composition
    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 {KeyboardEvent} 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.toLowerCase() === "z" ) this._onKeyZ(event, up, modifiers);
    else if ( key.toLowerCase() === "c" ) this._onKeyC(event, up, modifiers);
    else if ( key.toLowerCase() === "v" ) this._onKeyV(event, up, modifiers);
    else if ( key in this.zoomKeys ) this._onKeyZoom(event, up, modifiers);

  }

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

  /**
   * Input events do not fire with isComposing = false at the end of a composition event in Chrome
   * See: https://github.com/w3c/uievents/issues/202
   * @param {CompositionEvent} event
   */
  _onCompositionEnd(event) {
    return this._onKeyDown(event);
  }

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

  /**
   * Master mouse-wheel event keyboard handler
   * @private
   */
  _onWheel(event) {
    if ( event.deltaY === 0 ) return;   // Only support vertical scroll events for now

    // 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 {KeyboardEvent} event     The originating keyboard event
   * @param {boolean} up              Is the key being released?
   * @param {Object} modifiers        The identified modifiers attached to this keypress
   * @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
    if ( canvas.tokens._active ) {
      let cycled = canvas.tokens.cycleTokens(!modifiers.isShift);
      if ( !cycled ) canvas.recenter();
    }
    this._tabState = 1;
  }

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

  /**
   * Handle ESC keypress events
   * @param {KeyboardEvent} event     The originating keyboard event
   * @param {boolean} up              Is the key being released?
   * @param {Object} modifiers        The identified modifiers attached to this keypress
   * @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 {KeyboardEvent} event     The originating keyboard event
   * @param {boolean} up              Is the key being released?
   * @param {Object} modifiers        The identified modifiers attached to this keypress
   * @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 {KeyboardEvent} event     The originating keyboard event
   * @param {boolean} up              Is the key being released?
   * @param {Object} modifiers        The identified modifiers attached to this keypress
   * @private
   */
  _onAlt(event, up, modifiers) {
    if ( !canvas.ready ) return;
    event.preventDefault();

    // Highlight placeable objects on any layers which are visible
    for ( let layer of canvas.layers ) {
      if ( !layer.objects || !layer.interactiveChildren ) continue;
      for ( let o of layer.placeables ) {
        if ( !o.visible ) continue;
        if ( !o.can(game.user, "hover") ) return;
        if ( !up ) o._onHoverIn(event, {hoverOutOthers: false});
        else o._onHoverOut(event);
      }
    }

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

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

  /**
   * Handle movement keypress events
   * @param {KeyboardEvent} event     The originating keyboard event
   * @param {boolean} up              Is the key being released?
   * @param {Object} modifiers        The identified modifiers attached to this keypress
   * @private
   */
  _onMovement(event, up, modifiers) {
    if ( !canvas.ready || up || modifiers.hasFocus ) return;
    event.preventDefault();

    // Reset move keys after a delay of 50ms or greater
    const now = Date.now();
    const delta = now - this._moveTime;
    if ( delta > 50 ) this._moveKeys.clear();

    // Track the movement set
    const directions = this.moveKeys[modifiers.key];
    for ( let d of directions ) {
      this._moveKeys.add(d);
    }

    // Handle canvas pan using CTRL
    if ( modifiers.isCtrl ) return this._handleCanvasPan();

    // Delay 50ms before shifting tokens in order to capture diagonal movements
    const layer = canvas.activeLayer;
    if ( layer instanceof TokenLayer || layer instanceof TilesLayer ) {
      if ( delta < 100 ) return; // Throttle keyboard movement once per 100ms
      setTimeout(() => this._handleMovement(event, layer), 50);
    }
    this._moveTime = now;
  }

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

  /**
   * Handle keyboard movement once a small delay has elapsed to allow for multiple simultaneous key-presses.
   * @private
   */
  _handleMovement(event, layer) {
    if ( !this._moveKeys.size ) return;

    // 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;
    this._moveKeys.clear();

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

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

  /**
   * Handle panning the canvas using CTRL + directional keys
   */
  _handleCanvasPan() {

    // Determine movement offsets
    const directions = this._moveKeys;
    let dx = 0;
    let dy = 0;
    if (directions.has("left")) dx -= 1;
    if (directions.has("up")) dy -= 1;
    if (directions.has("right")) dx += 1;
    if (directions.has("down")) dy += 1;

    // Pan by the grid size
    const s = canvas.dimensions.size;
    canvas.animatePan({
      x: canvas.stage.pivot.x + (dx * s),
      y: canvas.stage.pivot.y + (dy * s),
      duration: 100
    });

    // 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 {KeyboardEvent} event     The originating keyboard event
   * @param {boolean} up              Is the key being released?
   * @param {Object} modifiers        The identified modifiers attached to this keypress
   * @private
   */
  _onKeyZ(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 {KeyboardEvent} event     The originating keyboard event
   * @param {boolean} up              Is the key being released?
   * @param {Object} modifiers        The identified modifiers attached to this keypress
   * @private
   */
  _onKeyC(event, up, modifiers) {
    if ( up || modifiers.hasFocus ) return;

    // Case 1 - attempt a copy operation on the Placeables Layer
    if ( modifiers.isCtrl ) {
      if (window.getSelection().toString() !== "") return;
      if ( !canvas.ready || !game.user.isGM ) return;
      let layer = canvas.activeLayer;
      if ( layer instanceof PlaceablesLayer ) layer.copyObjects();
    }

    // Case 2 - Toggle character sheet
    else {
      const token = canvas.ready && (canvas.tokens.controlled.length === 1) ? canvas.tokens.controlled[0] : null;
      const actor = token ? token.actor : game.user.character;
      if ( actor ) {
        const sheet = actor.sheet;
        if ( sheet.rendered ) {
          if ( sheet._minimized ) sheet.maximize();
          else sheet.close()
        }
        else sheet.render(true);
      }
    }
  }

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

  /**
   * Handle "V" keypress events to paste data from clipboard
   * @param {KeyboardEvent} event     The originating keyboard event
   * @param {boolean} up              Is the key being released?
   * @param {Object} modifiers        The identified modifiers attached to this keypress
   * @private
   */
  _onKeyV(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 presses to keyboard zoom keys
   * @param {KeyboardEvent} event     The originating keyboard event
   * @param {boolean} up              Is the key being released?
   * @param {Object} modifiers        The identified modifiers attached to this keypress
   * @private
   */
  _onKeyZoom(event, up, modifiers ) {
    if ( !canvas.ready || this.hasFocus || up ) return;
    event.preventDefault();
    const direction = this.zoomKeys[modifiers.key];
    const delta = direction === "in" ? 1.05 : 0.95;
    return canvas.animatePan({scale: delta * canvas.stage.scale.x, duration: 100});
  }

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

  /**
   * Handle DELETE Keypress Events
   * @param {KeyboardEvent} event     The originating keyboard event
   * @param {boolean} up              Is the key being released?
   * @param {Object} modifiers        The identified modifiers attached to this keypress
   * @private
   */
  _onDelete(event, up, modifiers) {
    if ( this.hasFocus || up ) 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;

/**
 * Enumerate the "digit keys"
 * @type {Array}
 */
KeyboardManager.DIGIT_KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];

/**
 * Map keys used for movement
 * @type {Object}
 */
KeyboardManager.MOVEMENT_KEYS = {
  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"],
  Numpad2: ["down"],
  Numpad3: ["down", "right"],
  Numpad4: ["left"],
  Numpad6: ["right"],
  Numpad7: ["up", "left"],
  Numpad8: ["up"],
  Numpad9: ["up", "right"],
};

/**
 * Map keys used for canvas zooming
 * @type {Object}
 */
KeyboardManager.ZOOM_KEYS = {
  "PageUp": "in",
  "PageDown": "out",
  "NumpadAdd": "in",
  "NumpadSubtract": "out"
};

/**
 * An abstract interface for managing defined game settings or settings menus for different packages.
 * Each setting is a string key/value pair belonging to a certain package and a certain store scope.
 *
 * When Foundry Virtual Tabletop is initialized, a singleton instance of this class is constructed within the global
 * Game object as as game.settings.
 *
 * @see {@link Game#settings}
 * @see {@link Settings}
 * @see {@link SettingsConfig}
 */
class ClientSettings {
  constructor(worldSettings) {

    /**
     * A object of registered game settings for this scope
     * @type {Map}
     */
    this.settings = new Map();

    /**
     * Registered settings menus which trigger secondary applications
     * @type {Map}
     */
    this.menus = new Map();

    /**
     * The storage interfaces used for persisting settings
     * Each storage interface shares the same API as window.localStorage
     */
    this.storage = new Map([
      ["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();
    return this._sheet;
  }

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

  /**
   * Register a new game setting under this setting scope
   *
   * @param {string} module   The namespace under which the setting is registered
   * @param {string} key      The key name for the setting under the namespace module
   * @param {Object} data     Configuration for setting data
   *
   * @example
   * // Register a client setting
   * game.settings.register("myModule", "myClientSetting", {
   *   name: "Register a Module Setting with Choices",
   *   hint: "A description of the registered setting and its behavior.",
   *   scope: "client",     // This specifies a client-stored setting
   *   config: true,        // This specifies that the setting appears in the configuration view
   *   type: String,
   *   choices: {           // If choices are defined, the resulting setting will be a select menu
   *     "a": "Option A",
   *     "b": "Option B"
   *   },
   *   default: "a",        // The default value for the setting
   *   onChange: value => { // A callback function which triggers when the setting is changed
   *     console.log(value)
   *   }
   * });
   *
   * @example
   * // Register a world setting
   * game.settings.register("myModule", "myWorldSetting", {
   *   name: "Register a Module Setting with a Range slider",
   *   hint: "A description of the registered setting and its behavior.",
   *   scope: "world",      // This specifies a world-level setting
   *   config: true,        // This specifies that the setting appears in the configuration view
   *   type: Number,
   *   range: {             // If range is specified, the resulting setting will be a range slider
   *     min: 0,
   *     max: 100,
   *     step: 10
   *   }
   *   default: 50,         // The default value for the setting
   *   onChange: value => { // A callback function which triggers when the setting is changed
   *     console.log(value)
   *   }
   * });
   */
  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";
    this.settings.set(`${module}.${key}`, data);
  }

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

  /**
   * Register a new sub-settings menu
   *
   * @param {string} module   The namespace under which the menu is registered
   * @param {string} key      The key name for the setting under the namespace module
   * @param {Object} data     Configuration for setting data
   *
   * @example
   * // Define a settings submenu which handles advanced configuration needs
   * game.settings.registerMenu("myModule", "mySettingsMenu", {
   *   name: "My Settings Submenu",
   *   label: "Settings Menu Label",      // The text label used in the button
   *   hint: "A description of what will occur in the submenu dialog.",
   *   icon: "fas fa-bars",               // A Font Awesome icon used in the submenu button
   *   type: MySubmenuApplicationClass,   // A FormApplication subclass which should be created
   *   restricted: true                   // Restrict this submenu to gamemaster only?
   * });
   */
  registerMenu(module, key, data) {
    if ( !module || !key ) throw new Error("You must specify both module and key portions of the menu");
    data.key = `${module}.${key}`;
    data.module = module;
    if ( !data.type || !(data.type.prototype instanceof FormApplication) ) {
      throw new Error("You must provide a menu type that is FormApplication instance or subclass");
    }
    this.menus.set(data.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
   *
   * @example
   * // Retrieve the current setting value
   * game.settings.get("myModule", "myClientSetting");
   */
  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.has(key) ) throw new Error("This is not a registered game setting");

    // Get the setting and the correct storage interface
    const setting = this.settings.get(key);
    const storage = this.storage.get(setting.scope);

    // Get the setting value
    let value = storage.getItem(key);
    value = value !== null ? JSON.parse(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
   *
   * @example
   * // Update the current value of a setting
   * game.settings.set("myModule", "myClientSetting", "b");
   */
  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}`;
    if ( !this.settings.has(key) ) throw new Error("This is not a registered game setting");

    // Obtain the setting data and serialize the value
    const setting = this.settings.get(key);
    const json = JSON.stringify(value);

    // Broadcast the setting change to others
    if ( setting.scope === "world" ) {
      await SocketInterface.dispatch("modifyDocument", {
        type: "Setting",
        action: "update",
        data: {key, value: json}
      });
    }
    this._update(setting, key, json);

    // Return the updated value
    return value;
  }

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

  /**
   * Locally update a setting given a provided key and value
   * @param {Object} setting
   * @param {string} key
   * @param {*} value
   */
  _update(setting, key, value) {
    const storage = this.storage.get(setting.scope);
    storage.setItem(key, value);
    value = JSON.parse(value);
    if ( setting.onChange instanceof Function ) setting.onChange(value);
    return value;
  }

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

  /**
   * Handle changes to a Setting document to apply them to the world setting storage
   */
  static socketListeners(socket) {
    socket.on('modifyDocument', response => {
      const { request, result } = response;
      if (request.type !== "Setting") return;
      const {key, value} = result;
      const setting = game.settings.settings.get(key);
      switch ( request.action ) {
        case "create":
        case "update":
          game.settings._update(setting, key, value);
          break;
        case "delete":
          const storage = game.settings.storage.get(setting.scope);
          delete storage.data[key];
          break;
      }
    });
  }
}


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


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

  getItem(key) {
    const value = this.get(key);
    return value !== undefined ? value : null;
  }

  setItem(key, value) {
    this.set(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();
  }

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

  /**
   * Get an Array of available packages of a given type which may be installed
   * @param {string} type
   * @return {Promise<Object[]>}
   */
  static async getPackages({type="system"}={}) {
    const request = await this.post({action: "getPackages", type: type});
    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(Array.from(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");
    }
    const response = await fetch(this.setupURL, {
      method: "POST",
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify(body)
    });
    if ( response.status >= 400 ) {
      throw new Error(`The request was rejected by the server or failed.`);
    }
    return response;
  }
}

class SocketInterface {

  /**
   * Standardize the way that socket messages are dispatched and their results are handled
   * @param {string} eventName          The socket event name being handled
   * @param {SocketRequest} request     Data provided to the Socket event
   * @return {Promise<SocketResponse>}  A Promise which resolves to the SocketResponse
   */
  static dispatch(eventName, request) {
    return new Promise((resolve, reject) => {
      game.socket.emit(eventName, request, response => {
        if ( response.error ) {
          const err = this._handleError(response.error);
          reject(err);
        }
        else resolve(response);
      })
    })
  }

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

  /**
   * Handle an error returned from the database, displaying it on screen and in the console
   * @param {Error} err   The provided Error message
   * @private
   */
  static _handleError(err) {
    let error = err instanceof Error ? err : new Error(err.message);
    if ( err.stack ) error.stack = err.stack;
    ui.notifications.error(error.message);
    console.error(error);
    return error;
  }
}

/**
 * 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.get(`${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 mapping of base64 video thumbnail images
     * @type {Map<string,string>}
     */
    this.thumbs = new Map();

    /**
     * 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();
  }

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

  /**
   * Create and cache a static thumbnail to use for the video.
   * The thumbnail is cached using the video file path or URL.
   * @param {string} src        The source video URL
   * @param {object} options    Thumbnail creation options, including width and height
   * @return {Promise<string>}  The created and cached base64 thumbnail image
   */
  async createThumbnail(src, options) {
    const t = await BackgroundLayer.createThumbnail(src, options);
    this.thumbs.set(src, t.thumb);
    return t.thumb;
  }
}

let _appId = 0;
let _maxZ = 100;

const MIN_WINDOW_WIDTH = 200;
const 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: true,
      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
    };

    /**
     * DragDrop workflow handlers which are active for this Application
     * @type {DragDrop[]}
     */
    this._dragDrop = this._createDragDropHandlers();

    /**
     * Tab navigation handlers which are active for this Application
     * @type {TabsV2[]}
     */
    this._tabs = this._createTabHandlers();

    /**
     * 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;
  }

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

  /**
   * Create drag-and-drop workflow handlers for this Application
   * @return {DragDrop[]}     An array of DragDrop handlers
   * @private
   */
  _createDragDropHandlers() {
    return this.options.dragDrop.map(d => {
      d.permissions = {
        dragstart: this._canDragStart.bind(this),
        drop: this._canDragDrop.bind(this)
      };
      d.callbacks = {
        dragstart: this._onDragStart.bind(this),
        dragover: this._onDragOver.bind(this),
        drop: this._onDrop.bind(this)
      };
      return new DragDrop(d);
    });
  }

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

  /**
   * Create tabbed navigation handlers for this Application
   * @return {TabsV2[]}     An array of TabsV2 handlers
   * @private
   */
  _createTabHandlers() {
    return this.options.tabs.map(t => {
      t.callback = this._onChangeTab.bind(this);
      return new TabsV2(t);
    });
  }

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

  /**
   * 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: [],
      dragDrop: [],
      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).catch(err => {
	    err.message = `An error occurred while rendering ${this.constructor.name} ${this.appId}: ${err.message}`;
	    console.error(err);
	    this._state = Application.RENDER_STATES.ERROR;
    });
	  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, states.ERROR].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._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);
      this._element = 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}
   */
  activateListeners(html) {

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

    // Bind drag-and-drop workflow
    this._dragDrop.forEach(d => d.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();
  }

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

  /**
   * Define whether a user is able to begin a dragstart workflow for a given drag selector
   * @param {string} selector       The candidate HTML selector for dragging
   * @return {boolean}              Can the current user drag this selector?
   * @private
   */
  _canDragStart(selector) {
    return game.user.isGM;
  }

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

  /**
   * Define whether a user is able to conclude a drag-and-drop workflow for a given drop selector
   * @param {string} selector       The candidate HTML selector for the drop target
   * @return {boolean}              Can the current user drop on this selector?
   * @private
   */
  _canDragDrop(selector) {
    return game.user.isGM;
  }

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

  /**
   * Callback actions which occur at the beginning of a drag start workflow.
   * @param {DragEvent} event       The originating DragEvent
   * @private
   */
  _onDragStart(event) {}

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

  /**
   * Callback actions which occur when a dragged element is over a drop target.
   * @param {DragEvent} event       The originating DragEvent
   * @private
   */
  _onDragOver(event) {}

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

  /**
   * Callback actions which occur when a dragged element is dropped on a target.
   * @param {DragEvent} event       The originating DragEvent
   * @private
   */
  _onDrop(event) {}

  /* -------------------------------------------- */
  /*  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 ( ![states.RENDERED, states.ERROR].includes(this._state) ) return;
    this._state = states.CLOSING;

    // Get the element
    let el = this.element;
    if ( !el ) return this._state = states.CLOSED;
    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 {{left: number, top: number, width: number, height: number, scale:number}}
   * The updated position object for the application containing the new values
   */
  setPosition({left, top, width, height, scale}={}) {
    if ( !this.popOut ) return; // Only configure position for popout apps
    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";

      // If the new (width + left) exceeds the window width, we need to update left
      if ( (p.width + p.left) > window.innerWidth ) left = p.left;
    }

    // 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+1), // the +1 helps to avoid incorrect overflow
        el.style.maxHeight || window.innerHeight
      );
      el.style.height = p.height+"px";

      // If the new (height + top) exceeds the window height, we need to update top
      if ( (p.height + p.top) > window.innerHeight ) top = p.top;
    }

    // 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,
  ERROR: 3
};
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
 *
 * @extends {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) {

    // Identify the focused element
    let focus = this.element.find(":focus");
    focus = focus.length ? focus[0] : null;

    // Render the application and restore focus
    await super._render(...args);
    if ( focus && focus.name ) {
      const input = this.form[focus.name];
      if ( input && (input.focus instanceof Function) ) input.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);

    // Process changes to input fields
    html.on("change", "input,select,textarea", 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));
  }

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

  /**
   * 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 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
   * @param {boolean} [preventRender]   Prevent the application from re-rendering as a result of form submission
   * @returns {Promise}                 A promise which resolves to the validated update data
   * @private
   */
  async _onSubmit(event, {updateData=null, preventClose=false, preventRender=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);
    }

    // Flag if the application is staged to close to prevent callback renders
    if ( this.options.closeOnSubmit ) this._state = this.constructor.RENDER_STATES.CLOSING;
    else if ( preventRender ) this._state = this.constructor.RENDER_STATES.RENDERING;

    // Trigger the object update
    try {
      await this._updateObject(event, formData);
    } catch(err) {
      console.error(err);
      preventClose = true;
    }

    // Restore flags and (optionally) close
    this._submitting = false;
    this._state = this.constructor.RENDER_STATES.RENDERED;
    if ( this.options.closeOnSubmit && !preventClose ) this.close({submit: false});
    return formData;
  }

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

  /**
   * Handle changes to an input element, submitting the form if options.submitOnChange is true.
   * Do not preventDefault in this handler as other interactions on the form may also be occurring.
   * @param {Event} event  The initial change event
   * @private
   */
  _onChangeInput(event) {
    const el = event.target;

    // Handle changes to specific input types
    if ( (el.type === "color") && el.dataset.edit ) {
      this._onChangeColorPicker(event);
    }

    if ( el.type === "range" ) {
      this._onChangeRange(event);
    }

    // Maybe submit the form
    if ( this.options.submitOnChange ) {
      return this._onSubmit(event);
    }
  }

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

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

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

  /**
   * 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) {
	  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
    return 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} options     Options passed to the _onSubmit event handler
   * @returns {FormApplication}       Return a self-reference for convenient method chaining
   */
  async submit(options) {
    if ( this._submitting ) return; 
    const submitEvent = new Event("submit");
    await this._onSubmit(submitEvent, options);
    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
  }

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

  /** @override */
  async _updateObject(event, formData) {
    formData["_id"] = this.object._id;
    return this.entity.update(formData);
  }
}

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

/**
 * Support mousewheel control for range type input elements
 * @param {WheelEvent} event    A Mouse Wheel scroll event
 */
function _handleMouseWheelInputChange(event) {
  const oe = event.originalEvent;
  const rng = event.target;

  // Adjust the range slider by the step size
  const step = (parseFloat(rng.step) || 1.0) * Math.sign(-1 * oe.deltaY);
  rng.value = Math.clamped(parseFloat(rng.value) + step, parseFloat(rng.min), parseFloat(rng.max));

  // Dispatch a change event that can bubble upwards to the parent form
  const ev = new Event("change", {bubbles: true});
  ev.target = ev.currentTarget = rng;
  rng.dispatchEvent(ev);
}


/**
 * 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);

    // 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 promises = [];

    // Include core supported languages
    if ( CONST.CORE_SUPPORTED_LANGUAGES.includes(lang) ) {
      promises.push(this._loadTranslationFile(`lang/${lang}.json`));
    }

    // Add game system translations
    if ( game.system ) {
      let sl = game.system.languages.find(l => l.lang === lang);
      if ( sl ) promises.push(this._loadTranslationFile(sl.path));
    }

    // Add module translations
    if ( game.modules ) {
      for ( let module of game.modules.values() ) {
        let mls = module.languages.filter(l => {
          if ( l.lang !== lang ) return false;
          let checkSystem = !l.system || (game.system && (l.system === game.system.id));
          let checkModule = !l.module || game.modules.has(l.module);
          return checkSystem && checkModule;
        });
        for ( let ml of mls ) {
          promises.push(this._loadTranslationFile(ml.path));
        }
      }
    }

    // Merge translations in load order and return the prepared dictionary
    await Promise.all(promises);
    for ( let p of promises ) {
      let json = await p;
      mergeObject(translations, json, {inplace: true});
    }
    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");
  }

	/* -------------------------------------------- */
  /*  Localization API                            */
	/* -------------------------------------------- */

  /**
   * 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;
  }

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

  /**
   * Localize a string including variable formatting for input arguments.
   * Provide a string ID which defines the localized template.
   * Variables can be included in the template enclosed in braces and will be substituted using those named keys.
   *
   * @param {string} stringId     The string ID to translate
   * @param {Object} data         Provided input data
   * @return {string}             The translated and formatted string
   *
   * @example
   * const stringId = "MY_TEST_STRING"; // "Your name is {name}"
   * game.i18n.format("MY_TEST_STRING", {name: "Andrew"}); // Produces "Your name is Andrew"
   */
  format(stringId, data={}) {
    let str = this.localize(stringId);
    const fmt = /\{[^\}]+\}/g;
    str = str.replace(fmt, k => {
      return data[k.slice(1, -1)];
    });
    return str;
  }
}




// 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 => {
    	  if ( resp.error ) return console.error(resp.error);
	      const 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.");

  // Do not display the button for users who do not have browse permission
  if ( game.world && !game.user.can("FILES_BROWSE" ) ) return "";

  // 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);
});



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


/**
 * Localize a string translation using the localize or format method
 */
Handlebars.registerHelper('localize', function(value, options) {
  const data = options.hash;
  return isObjectEmpty(data) ? game.i18n.localize(value) : game.i18n.format(value, data);
});


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


/**
 * Register helpers for performing logical comparisons
 */
Handlebars.registerHelper({
  eq: (v1, v2) => v1 === v2,
  ne: (v1, v2) => v1 !== v2,
  lt: (v1, v2) => v1 < v2,
  gt: (v1, v2) => v1 > v2,
  lte: (v1, v2) => v1 <= v2,
  gte: (v1, v2) => v1 >= v2,
  and() {
      return Array.prototype.every.call(arguments, Boolean);
  },
  or() {
      return Array.prototype.slice.call(arguments, 0, -1).some(Boolean);
  }
});
/* Global Variables */
let socket = null;
let canvas = null;
let keyboard = null;
let game = {};
const ui = {
  windows: {}
};

/**
 * 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,
        transports: ["websocket"],    // Require websocket transport instead of XHR polling
        upgrade: false,               // Prevent "upgrading" to websocket since it is enforced
        reconnection: true,           // Automatically reconnect
        reconnectionDelay: 1000,
        reconnectionAttempts: 3,
        reconnectionDelayMax: 5000,
        query: { session: sessionId } // Pass session info
      });
      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 (/\/license/.test(url)) await this._initializeLicenseView();
    if (/\/game/.test(url)) await this._initializeGameView();
    else if (/\/setup/.test(url)) await this._initializeSetupView();
    else if (/\/join/.test(url)) await this._initializeJoinView();
    else if (/\/stream/.test(url)) await this._initializeStreamView();
    else if (/\/players/.test(url)) await this._initializePlayersView();

    // Display usability warnings or errors
    this._displayUsabilityErrors();
  }

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

  /**
   * Display certain usability error messages which are likely to result in the player having a bad experience.
   * @private
   */
  _displayUsabilityErrors() {

    // Validate required resolution
    const MIN_WIDTH = 1024;
    const MIN_HEIGHT = 700;
    if ( ui.notifications && (window.innerHeight < MIN_HEIGHT || window.innerWidth < MIN_WIDTH) ) {
      ui.notifications.error(game.i18n.format("ERROR.LowResolution", {
        width: window.innerWidth,
        reqWidth: MIN_WIDTH,
        height: window.innerHeight,
        reqHeight: MIN_HEIGHT
      }), {permanent: true});
    }

    // Unsupported Chromium version
    const MIN_CHROMIUM_VERSION = 80;
    const chromium = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
    if ( chromium && (parseInt(chromium[2]) < MIN_CHROMIUM_VERSION) ) {
      ui.notifications.error(game.i18n.format("ERROR.ChromiumVersion", {
        version: chromium[2],
        minimum: MIN_CHROMIUM_VERSION
      }), {permanent: true});
    }
  }

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

  /**
   * 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 EntityCollection instances for every Entity types
   */
  initializeEntities() {
    this.users = new Users(this.data.users);
    this.messages = new Messages(this.data.messages);
    this.scenes = new Scenes(this.data.scenes);
    this.actors = new Actors(this.data.actors);
    this.items = new Items(this.data.items);
    this.journal = new Journal(this.data.journal);
    this.macros = new Macros(this.data.macros);
    this.playlists = new Playlists(this.data.playlists);
    this.combats = new CombatEncounters(this.data.combat);
    this.tables = new RollTables(this.data.tables);
    this.folders = new Folders(this.data.folders);
  }

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

  /**
   * Initialize the Compendium packs which are present within this Game
   * Create a Collection which maps each Compendium pack using it's collection ID
   * @see {Compendium}
   * @see {Collection}
   */
  async initializePacks(config) {
    config = config || await game.settings.get("core", Compendium.CONFIG_SETTING);
    const prior = this.packs;
    const packs = new Collection();
    for ( let metadata of this.data.packs ) {
      const collection = `${metadata.package}.${metadata.name}`;

      // Get or create the pack
      let pack = null;
      if ( prior && prior.has(collection) ) pack = prior.get(collection);
      else pack = new Compendium(metadata);

      // Update the pack configuration and re-render
      const conf = config[collection];
      if ( conf ) {
        pack.private = !!conf.private;
        pack.locked = !!conf.locked;
      }
      pack.render(false);

      // Add to the new collection
      packs.set(collection, pack);
    }
    return this.packs = packs;
  }

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

  /**
   * Initialize the WebRTC implementation
   */
  initializeRTC() {
    const settings = new WebRTCSettings();
    this.webrtc = new WebRTC(settings);
    return 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")) {

      // Ensure that necessary fonts have loaded
      await this._checkFontsReady(3000);

      // Render the canvas
      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);
      }
    }
  }

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

  /**
   * Ensure that necessary fonts have loaded and are ready for use
   * Enforce a maximum timeout in milliseconds.
   * Proceed with rendering after that point even if fonts are not yet available.
   * @param {number} ms   The timeout to delay
   * @return {Promise<void>}
   * @private
   */
  async _checkFontsReady(ms) {
    const timeout = new Promise(resolve => setTimeout(resolve, ms));
    return Promise.race([document.fonts.ready, timeout]).then(() => {
      console.log(`${vtt} | Document fonts loaded and ready`);
    });
  }

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

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

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

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

    // Permissions Control Menu
    game.settings.registerMenu("core", "permissions", {
      name: "PERMISSION.Configure",
      label: "PERMISSION.ConfigureLabel",
      hint: "PERMISSION.ConfigureHint",
      icon: "fas fa-user-lock",
      type: PermissionConfig,
      restricted: true
    });

    // 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();
        ui.sidebar.render();
      }
    });

    // WebRTC Control Menu
    game.settings.registerMenu("core", "webrtc", {
      name: "WEBRTC.Title",
      label: "WEBRTC.MenuLabel",
      hint: "WEBRTC.MenuHint",
      icon: "fas fa-headset",
      type: AVConfig,
      restricted: false
    });

    // RTC World Settings
    game.settings.register("core", "rtcWorldSettings", {
      name: "WebRTC (Audio/Video Conferencing) World Settings",
      scope: "world",
      default: WebRTCSettings.getDefaultWorldSettings(),
      type: Object,
      onChange: () => game.webrtc.settings.onSettingsChanged()
    });

    // RTC Client Settings
    game.settings.register("core", "rtcClientSettings", {
      name: "WebRTC (Audio/Video Conferencing) Client specific Configuration",
      scope: "client",
      default: WebRTCSettings.getDefaultClientSettings(),
      type: Object,
      onChange: () => game.webrtc.settings.onSettingsChanged()
    });

    // 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.Dice.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", Compendium.CONFIG_SETTING, {
      name: "Compendium Configuration",
      scope: "world",
      config: false,
      default: {},
      type: Object,
      onChange: async config => {
        await this.initializePacks(config);
        ui.compendium.render();
      }
    });

    // 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)
    });

    // 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
    });

    // Maximum Framerate
    game.settings.register("core", "maxFPS", {
      name: "SETTINGS.MaxFPSN",
      hint: "SETTINGS.MaxFPSL",
      scope: "client",
      config: true,
      type: Number,
      range: {min: 10, max: 60, step: 10},
      default: 60,
      onChange: () => {
        if ( canvas ) canvas.draw();
      }
    });

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

    // 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();

    // Audio playback settings
    AudioHelper.registerSettings();

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

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

  /**
   * Is the current session user authenticated as an application administrator?
   * @type {boolean}
   */
  get isAdmin() {
    return this.data.isAdmin;
  }

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

  /**
   * 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() {

    // Helper Listeners
    Game.socketListeners(this.socket);
    AudioHelper.socketListeners(this.socket);
    ClientSettings.socketListeners(this.socket);

    // Database Listeners
    Entity.activateSocketListeners(this.socket);
    Users.socketListeners(this.socket);
    Scenes.socketListeners(this.socket);
    Journal.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);

    // Handle mousewheel form interaction
    $("body").on("mousewheel", 'input[type="range"]', _handleMouseWheelInputChange);

    // 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;

    // Force hyperlinks to a separate window/tab
    document.addEventListener("click", this._onClickHyperlink);
  }

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

  /**
   * 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();
  }

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

  /**
   * On left mouse clicks, check if the element is contained in a valid hyperlink and open it in a new tab.
   * @param {MouseEvent} event
   * @private
   */
  _onClickHyperlink(event) {
    const a = event.target.closest("a[href]");
    if ( !a || (a.href === "javascript:void(0)") ) return;
    event.preventDefault();
    window.open(a.href, "_blank");
  }

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

  /**
   * 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+"/license";
    if (!this.userId) {
      console.error("Invalid user session provided - returning to login screen.");
      this.logOut();
    }

    // Setup the game
    await 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();
  };

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

  /**
   * Initialization steps for the game setup view
   * @private
   */
  async _initializeLicenseView() {
    ui.notifications = new Notifications().render(true);
    const setup = document.getElementById("setup");
    if ( setup.dataset.step === "eula" ) new EULA().render(true);
  };

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

  /**
   * Initialization steps for the game setup view
   * @private
   */
  async _initializeSetupView() {
    if ( !SIGNED_EULA ) window.location.href = ROUTE_PREFIX+"/license";
    ui.notifications = new Notifications().render(true);
    if ( document.body.classList.contains("auth") ) return;
    ui.setup = new SetupConfigurationForm(game.data).render(true);
  };

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

  /**
   * Initialization steps for the game setup view
   * @private
   */
  async _initializeJoinView() {
    if ( !SIGNED_EULA ) window.location.href = ROUTE_PREFIX+"/license";
    ui.notifications = new Notifications().render(true);
    const form = document.getElementById("join-form");
    form.submit.disabled = false;
    form.submit.innerHTML = `<i class="fas fa-check"></i> Join Game Session`;
    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() {
    canvas = {ready: false};
    if ( !SIGNED_EULA ) window.location.href = ROUTE_PREFIX+"/license";
    this.initializeEntities();
    ui.chat = new ChatLog({stream: true}).render(true);
    Entity.activateSocketListeners(this.socket);
  }

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

  /**
   * Initialize the Player Management View
   * @private
   */
  async _initializePlayersView() {
    if ( !SIGNED_EULA ) window.location.href = ROUTE_PREFIX+"/license";
    this.users = new Users(this.data.users);
    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, 0);
    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) {
    if ( nd === 0 ) return this;
    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 = Number.isNumeric(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);
    if ( !Number.isNumeric(total) ) {
      throw new Error(game.i18n.format("DICE.ErrorNonNumeric", {formula: this.formula}));
    }

    // 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.Dice.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.Dice.template,
      blind: false
    }, chatOptions);
    const isPrivate = chatOptions.isPrivate;

    // Execute the roll, if needed
    if ( !this._rolled ) this.roll();

    // Define chat data
    const chatData = {
      formula: isPrivate ? "???" : this.formula,
      flavor: isPrivate ? null : chatOptions.flavor,
      user: chatOptions. user,
      tooltip: isPrivate ? "" : await this.getTooltip(),
      total: isPrivate ? "?" : 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.constructor.name.toLowerCase(),
              "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.Dice.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} messageData          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.Dice.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(messageData={}, {rollMode=null, create=true}={}) {

    // Perform the roll, if it has not yet been rolled
    if ( !this._rolled ) this.roll();

    // Prepare chat data
    messageData = mergeObject({
      user: game.user._id,
      type: CONST.CHAT_MESSAGE_TYPES.ROLL,
      content: this.total,
      sound: CONFIG.sounds.dice,
    }, messageData);
    messageData.roll = this;

    // Prepare message options
    const messageOptions = {rollMode};

    // Either create the message or just return the chat data
    return create ? CONFIG.ChatMessage.entityClass.create(messageData, messageOptions) : messageData;
  }

  /* -------------------------------------------- */
  /*  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}*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||1}*${d})`;
    });
    return new this(formula).roll();
  }

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

  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.Dice.types.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 EntityCollection, which defines the abstract interface.
 * @abstract
 * @extends {Collection}
 *
 * @param {Array} data      An Array of Entity data from which to create instances
 */
class EntityCollection extends Collection {
  constructor(data) {
    super();

    /**
     * The source data is, itself, a mapping of IDs to data objects
     * @type {Array}
     */
    this._source = data;

    /**
     * An Array of application references which will be automatically updated when the collection content changes
     * @type {Array}
     */
    this.apps = [];

    // Initialize data
    this._initialize(data);
  }

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

  /**
   * Initialize the Map object and all its contained entities
   * @param {Entity[]} data
   * @private
   */
  _initialize(data) {
    this.clear();
    for ( let d of data ) {
      this.set(d._id, new this.object(d));
    }
  }

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

  /**
   * An array of all the Entities in the EntityCollection.
   * @alias {Collection#entries}
   * @return {Entity[]}
   */
  get entities() {
    return this.entries;
  }

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

  /**
   * Render any Applications associated with this EntityCollection
   * @return {this}     A reference to the rendered EntityCollection
   */
  render(...args) {
    for (let a of this.apps) a.render(...args);
    return this;
  }

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

  /**
   * The EntityCollection name
   * @type {string}
   */
  get name() {
    return this.constructor.name;
  }

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

  /**
   * Return a reference to the singleton instance of this EntityCollection, or null if it has not yet been created.
   * @type {EntityCollection|null}
   */
  static get instance() {
    return game[this.name.toLowerCase()] || null;
  }

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

  /**
   * Return a reference to the SidebarDirectory application for this EntityCollection, 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 EntityCollection.
   * 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 EntityCollection.
   * @type {string}
   */
  get entity() {
    return this.object.entity;
  }

  /* -------------------------------------------- */
  /*  EntityCollection Management Methods         */
  /* -------------------------------------------- */

  /**
   * Get an Entity from the EntityCollection by name
   * @param {string} name     The name of the Entity to retrieve
   * @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 one was found, otherwise null;
   */
  getName(name, {strict = false} = {}) {
    const entity = this.find(e => e.name === name);
    if ( !entity && strict ) {
      throw new Error(`The ${this.object.name} ${name} does not exist in the ${this.constructor.name} collection`);
    }
    return entity || null;
  }

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

  /**
   * Add a new Entity to the EntityCollection, 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`);
    }
    this._source.push(entity.data);
    this.set(entity.id, entity);
  }

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

  /**
   * Remove an Entity from the EntityCollection by its ID.
   * @param id {string}   The entity ID which should be removed
   */
  remove(id) {
    this._source.findSplice(e => e._id === id);
    this.delete(id);
  }

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

  /**
   * 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.get(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(this.fromCompendium(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);
  }

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

  /**
   * Apply data transformations when importing an Entity from a Compendium pack
   * @param {Object} data           The original Compendium entry data
   * @return {Object}               The processed data ready for Entity creation
   */
  fromCompendium(data) {
    const nullKeys = ["_id", "folder", "sort"];
    for ( let k of nullKeys ) {
      data[k] = null;
    }
    data.permissions = {[game.user._id]: ENTITY_PERMISSIONS.OWNER};
    return data;
  }
}

/**
 * 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.keys();
 *
 * // Suppose we are working with a particular pack named "dnd5e.spells"
 * const pack = game.packs.get("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 locked for editing
     * @type {boolean}
     */
    this.locked = metadata.package !== "world";

    /**
     * Track whether the compendium pack is private
     * @type {Boolean}
     */
    this.private = false;

    /**
     * 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;

    /**
     * A filtering timeout function reference used to rate limit string filtering operations
     * @type {number|null}
     */
    this._filterTimeout = null;
  }

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

  /** @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"],
      dragDrop: [{ dragSelector: ".directory-item", dropSelector: ".directory-list" }]
    });
  }

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

  /** @override */
  get title() {
    return [this.metadata.label, this.locked ? "[Locked]" : null].filterJoin(" ");
  }

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

  /**
   * 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;
  }

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

  /**
   * A reference to the Entity class object contained within this Compendium pack
   * @return {*}
   */
  get cls() {
    return CONFIG[this.entity].entityClass;
  }

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

  /** @override */
  async getData(options) {
    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 close() {
    await 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");
  }

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

  /**
   * Create a new Compendium pack using provided
   * @param {Object} metadata   The compendium metadata used to create the new pack
   * @param {Options} options   Additional options which modify the Compendium creation request
   * @return {Promise.<Compendium>}
   */
  static async create(metadata, options={}) {
    if ( !game.user.isGM ) return ui.notifications.error("You do not have permission to modify this compendium pack");
    const response = await SocketInterface.dispatch("manageCompendium", {
      action: "create",
      data: metadata,
      options: options
    });

    // Add the new pack to the World
    game.data.packs.push(response.result);
    game.initializePacks().then(() => ui.compendium.render());
    return new Compendium(response.result);
  }

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

  /**
   * Assign configuration metadata settings to the compendium pack
   * @param {Object} settings   The object of compendium settings to define
   * @return {Promise}          A Promise which resolves once the setting is updated
   */
  configure(settings={}) {
    this._assertUserCanModify({requireUnlocked: false});
    const config = game.settings.get("core", this.constructor.CONFIG_SETTING);
    const pack = config[this.collection] || {private: false, locked: this.metadata.package !== "world"};
    config[this.collection] = mergeObject(pack, settings);
    return game.settings.set("core", this.constructor.CONFIG_SETTING, config);
  }

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

  /**
   * Delete a world Compendium pack
   * This is only allowed for world-level packs by a GM user
   * @return {Promise.<Compendium>}
   */
  async delete() {
    this._assertUserCanModify();
    await SocketInterface.dispatch("manageCompendium", {
      action: "delete",
      data: this.metadata.name
    });

    // Remove the pack from the game World
    game.data.packs.findSplice(p => (p.package === "world") && (p.name === this.metadata.name) );
    game.initializePacks().then(() => ui.compendium.render());
    return this;
  }

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

  /**
   * Duplicate a compendium pack to the current World
   * @param label
   * @return {Promise<Compendium>}
   */
  async duplicate({label}={}) {
    this._assertUserCanModify({requireUnlocked: false});
    label = label || this.metadata.label;
    const metadata = mergeObject(this.metadata, {
      name: label.slugify({strict: true}),
      label: label
    }, {inplace: false});
    return this.constructor.create(metadata, {source: this.collection});
  }

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

  /**
   * 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
   */
  async getIndex() {
    const response = await SocketInterface.dispatch("modifyCompendium", {
      type: this.collection,
      action: "get",
      data: {},
      options: {returnType: "index"}
    });
    return this.index = response.result;
  }

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

  /**
   * 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() {
    const response = await SocketInterface.dispatch("modifyCompendium", {
      type: this.collection,
      action: "get",
      data: {},
      options: {returnType: "content"}
    });
    return response.result.map(entry => this._toEntity(entry));
  }

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

  /**
   * 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
   */
  async getEntry(entryId) {
    const response = await SocketInterface.dispatch("modifyCompendium", {
      type: this.collection,
      action: "get",
      data: {_id: entryId},
      options: {returnType: "entry"}
    });
    return response.result[0];
  }

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

  /**
   * 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={}) {
    return new this.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
   */
  async 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);
    }

    // Get the data to import
    const data = await entity.toCompendium();
    return this.createEntity(data);
  }

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

  /**
   * Create a new Entity within this Compendium Pack using provided data
   * @param {Object} data       Data with which to create the entry
   * @param {Options} options   Additional options which modify the creation
   * @return {Promise}          A Promise which resolves to the created Entity once the operation is complete
   */
  async createEntity(data, options={}) {
    this._assertUserCanModify();
    data = data instanceof Array ? data : [data];

    // Dispatch the Socket request
    const response = await SocketInterface.dispatch("modifyCompendium", {
      action: "create",
      type: this.collection,
      data: data,
      options: options
    });
    this.render(false);

    // Return the created entities
    const results = response.result.map(r => this._toEntity(r));
    return data.length > 1 ? results : results[0];
  }

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

  /**
   * 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
   */
  async updateEntity(data, options={}) {
    this._assertUserCanModify();
    if ( !data._id ) throw new Error("You must specify the _id attribute for the data you wish to update");

    // Reference an existing Entity which is already rendered
    const entity = options["entity"] || null;
    delete options.entity;

    // Prepare data for update
    data = data instanceof Array ? data : [data];
    const updates = data.reduce((arr, d) => {
      if ( !d._id ) throw new Error(`You must provide an _id for every Compendium entry in the data Array.`);
      d = expandObject(d);
      arr.push(d);
      return arr;
    }, []);
    if ( !updates.length ) return [];

    // Dispatch the Socket request
    const response = await SocketInterface.dispatch("modifyCompendium", {
      action: "update",
      type: this.collection,
      data: updates,
      options: options
    });

    // Render updates
    this.render(false);
    if ( entity ) {
      const update = response.result.find(r => r._id === entity.id);
      mergeObject(entity.data, update);
      entity._onUpdate(update, options, response.userId);
    }

    // Return the update entities
    return response.result;
  }

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

  /**
   * Delete a single Compendium entry by its provided _id
   * @param {String} id         The entry ID to delete
   * @param {Object} options    Additional options which modify the deletion request
   * @return {Promise}          A Promise which resolves to the deleted entry ID once the operation is complete
   */
  async deleteEntity(id, options={}) {
    this._assertUserCanModify();
    const ids = id instanceof Array ? id : [id];
    const response = await SocketInterface.dispatch("modifyCompendium", {
      action: "delete",
      type: this.collection,
      data: ids,
      options: options
    });
    this.render(false);
    return response.result;
  }

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

  /**
   * Request that a Compendium pack be migrated to the latest System data template
   * @return {Promise.<Compendium>}
   */
  async migrate(options) {
    this._assertUserCanModify();
    ui.notifications.info(`Beginning migration for Compendium pack ${this.collection}, please be patient.`);
    const response = await SocketInterface.dispatch("manageCompendium", {
      type: this.collection,
      action: "migrate",
      data: this.collection,
      options: options
    });
    ui.notifications.info(`Successfully migrated Compendium pack ${this.collection}.`);
    return response;
  }

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

  /**
   * Filter the results in the Compendium pack to only show ones which match a provided search string
   * @param {string} searchString    The search string to match
   */
  search(searchString) {
    const query = new RegExp(RegExp.escape(searchString), "i");
    this.element.find('li.directory-item').each((i, li) => {
      let name = li.getElementsByClassName('entry-name')[0].textContent;
      li.style.display = query.test(name) ? "flex" : "none";
    });
    this.searchString = searchString;
  }

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

  /**
   * Validate that the current user is able to modify content of this Compendium pack
   * @return {boolean}
   * @private
   */
  _assertUserCanModify({requireGM=true, requireUnlocked=true}={}) {
    let err = null;
    if ( requireGM && !game.user.isGM ) err = new Error("You do not have permission to modify this compendium pack");
    if ( this.locked && requireUnlocked ) {
      err = new Error(`You cannot modify content in this compendium pack because it is locked.`);
    }
    if ( err ) {
      ui.notifications.error(err.message);
      throw err;
    }
    return true;
  }

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

  /**
   * Register event listeners for Compendium directories
   * @private
   */
  activateListeners(html) {
    super.activateListeners(html);
    const directory = html.find('.directory-list');
    const entries = directory.find('.directory-item');

    // Search filtering
    html.find('input[name="search"]').keyup(this._onFilterResults.bind(this));
    if ( this.searchString ) this.search(this.searchString);

    // Open sheets
    html.find('.entry-name').click(ev => {
      let li = ev.currentTarget.parentElement;
      this._onEntry(li.dataset.entryId);
    });

    // Context menu for each entry
    this._contextMenu(html);

    // Intersection Observer for Compendium avatars
    const observer = new IntersectionObserver(SidebarTab.prototype._onLazyLoadImage.bind(this), {root: directory[0]});
    entries.each((i, li) => observer.observe(li));
  }

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

  /**
   * Handle compendium filtering through search field
   * Toggle the visibility of indexed compendium entries by name (for now) match
   * @private
   */
  _onFilterResults(event) {
    event.preventDefault();
    let input = event.currentTarget;
    if ( this._filterTimeout ) {
      clearTimeout(this._filterTimeout);
      this._filterTimeout = null;
    }
    this._filterTimeout = setTimeout(() => this.search(input.value), 100);
  }

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

  /**
   * 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);
  }

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

  /** @override */
  _canDragStart(selector) {
    if ( this.cls.entity === "Item" ) return true;
    return this.cls.can(game.user, "create");
  }

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

  /** @override */
  _canDragDrop(selector) {
    return game.user.isGM;
  }

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

  /** @override */
  _onDragStart(event) {

    // Get the Compendium pack
    const li = event.currentTarget;
    const packName = li.parentElement.parentElement.getAttribute("data-pack");
    const pack = game.packs.get(packName);
    if ( !pack ) return;

    // Set the transfer data
    event.dataTransfer.setData("text/plain", JSON.stringify({
      type: pack.entity,
      pack: pack.collection,
      id: li.dataset.entryId
    }));
  }

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

  /**
   * Handle data being dropped into a Compendium pack
   * @private
   */
  async _onDrop(event) {

    // 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");
    let ent = null;

    // Case 1 - Data explicitly provided
    if ( data.data ) ent = new this.cls(data.data);

    // Case 2 - Import from other Compendium
    else if ( data.pack ) {
      if ( data.pack === this.collection ) return false;
      const source = game.packs.get(data.pack);
      ent = await source.getEntity(data.id);
    }

    // Case 3 - Import from World data
    else ent = this.cls.collection.get(data.id);

    // Create the new Compendium entry
    return this.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 = this.cls.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);
          })
        }
      }
    ]);
  }
}

Compendium.CONFIG_SETTING = "compendiumConfiguration";
/**
 * 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 EntityCollection} The EntityCollection 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 {EntityCollection} collection   The EntityCollection 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];
  }

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

  /**
   * Render all of the Application instances which are connected to this Entity by calling their respective
   * {@link Application#render} methods.
   * @param {boolean} force     Force rendering
   * @param {Options} context   Optional context
   */
  render(force, context={}) {
    const permChange = context.data && ("permission" in context.data);
    for ( let app of Object.values(this.apps) ) {
      if ( permChange ) app.options.editable = this.owner;
      app.render(force, context);
    }
  }

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

  /**
   * Return a reference to the EntityCollection 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 {EntityCollection}
   * @static
   */
	static get collection() {
	  if ( !this.config.collection ) {
	    throw new Error(`An Entity subclass must configure the EntityCollection 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);
	  const editable = this.owner && (!this.compendium  || !this.compendium.locked );
	  if ( !sheet ) sheet = new cls(this, {editable});
	  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.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);
  }

	/* -------------------------------------------- */
	/*  Permission Controls                         */
	/* -------------------------------------------- */

  /**
   * 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;
  }

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

  /**
   * Test whether a given User has permission to perform some action on this Entity
   * @param {User} user           The User requesting creation
   * @param {string} action       The attempted action
   * @param {Entity} target       The targeted Entity
   * @return {boolean}            Does the User have permission?
   */
  static can(user, action, target) {
    const permissions = this.config.permissions || {};
    switch ( action ) {
      case "create":
        return (permissions.create && user.can(permissions.create)) || user.isGM;
      case "update":
        return target.hasPerm(user, "OWNER");
      case "delete":
        return (permissions.delete && user.can(permissions.delete)) || user.isGM;
      default:
        return false;
    }
  }

  /**
   * Test whether a given User has permission to perform some action on this Entity
   * @alias Entity.can
   */
  can(user, action) {
    return this.constructor.can(user, action, this);
  }

	/* -------------------------------------------- */
	/*  Entity Management Methods                   */
	/* -------------------------------------------- */

  /**
   * Activate the Socket event listeners used to receive responses from events which modify database documents
   * @param {Socket} socket   The active game socket
   */
  static activateSocketListeners(socket) {

    // Document Management
    socket.on("modifyDocument", response => {
      const { request } = response;
      if ( !CONST.ENTITY_TYPES.includes(request.type ) ) return;
      const cls = CONFIG[request.type].entityClass;
      switch ( request.action ) {
        case "create":
          return cls._handleCreate(response);
        case "update":
          return cls._handleUpdate(response);
        case "delete":
          return cls._handleDelete(response);
        default:
          return;
      }
    });

    // Embedded Document Management
    socket.on("modifyEmbeddedDocument", response => {
      const { request } = response;
      const cls = CONFIG[request.parentType].entityClass;
      switch ( request.action ) {
        case "create":
          return cls._handleCreateEmbeddedEntity(response);
        case "update":
          return cls._handleUpdateEmbeddedEntity(response);
        case "delete":
          return cls._handleDeleteEmbeddedEntity(response);
        default:
          return;
      }
    });
  }

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

  /**
   * Create one or multiple new entities using provided input data.
   * Data may be provided as a single object to create one Entity, or as an Array of Objects.
   * Entities may be temporary (unsaved to the database) by passing the temporary option as true.
   * @static
   *
   * @param {Data|Data[]} data            A Data object or array of Data
   * @param {Options} 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<Entity|Entity[]>}   The created Entity or array of Entities
   *
   * @example
   * const data = {name: "New Entity", type: "character", img: "path/to/profile.jpg"};
   * const created = await Entity.create(data); // Returns one Entity, saved to the database
   * const temp = await Entity.create(data, {temporary: true}); // Not saved to the database
   *
   * @example
   * const data = [{name: "Tim", type: "npc"], [{name: "Tom", type: "npc"}];
   * const created = await Entity.create(data); // Returns an Array of Entities, saved to the database
   * const created = await Entity.create(data, {temporary: true}); // Not saved to the database
   */
  static async create(data, options={}) {
    const entityName = this.entity;
    const cls = CONFIG[entityName].entityClass;
    const user = game.user;
    options = mergeObject({temporary: false, renderSheet: false}, options);

    // Iterate over data to create
    data = data instanceof Array ? data : [data];
    for ( let d of data ) {
      const allowed = Hooks.call(`preCreate${entityName}`, d, options, user._id);
      if ( allowed === false ) {
        console.debug(`${vtt} | ${entityName} creation prevented by preCreate hook`);
        return null;
      }
    }

    // Trigger the Socket workflow
    const response = await SocketInterface.dispatch("modifyDocument", {
      type: entityName,
      action: "create",
      data: data,
      options: options
    });

    // Call the response handler and return the created Entities
    const entities = cls._handleCreate(response);
    return data.length === 1 ? entities[0] : entities;
  }

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

  /**
   * Handle a SocketResponse from the server when one or multiple Entities are created
   * @param {SocketRequest} request     The initial request
   * @param {Data[]} result             An Array of created Entity data
   * @param {string} userId             The id of the requesting User
   * @return {Entity[]}                 An Array of constructed Entity instances
   * @private
   */
  static _handleCreate({request, result=[], userId}={}) {
    const { type, options } = request;
    const { temporary } = options;

    // Prepare created Entities
    const entities = result.map(data => {

      // Create the Entity instance
      let entity = new this(data);
      if ( temporary ) return entity;

      // Add it to the EntityCollection
      this.collection.insert(entity);

      // Trigger follow-up actions and return
      entity._onCreate(data, options, userId);
      Hooks.callAll(`create${type}`, entity, options, userId);
      return entity;
    });

    // Log creation
    let msg = ( entities.length === 1 ) ? `Created ${type}` : `Created ${entities.length} ${type}s`;
    if ( entities.length === 1 ) msg += ` with id ${entities[0].id}`;
    else if ( entities.length <= 5 ) msg += ` with ids: [${entities.map(d => d.id)}]`;
    console.log(`${vtt} | ${msg}`);

    // Re-render the parent EntityCollection
    if ( options.render !== false ) {
      this.collection.render(false, {entityType: this.entity, action: "create", entities: entities, data: result});
    }

    // Return the created Entities
    return entities;
  }

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

  /**
   * Entity-specific actions that should occur when the Entity is first created
   * @private
   */
	_onCreate(data, options, userId) {
    if ( options.renderSheet && (userId === game.user._id) ) {
      if ( this.sheet ) this.sheet.render(true, context);
    }
  }

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

  /**
   * Update one or multiple existing entities using provided input data.
   * Data may be provided as a single object to update one Entity, or as an Array of Objects.
   * @static
   *
   * @param {Data|Data[]} data            A Data object or array of Data. Each element must contain the _id of an existing Entity.
   * @param {Options} options             Additional options which customize the update workflow
   * @param {boolean} [options.diff]      Difference the provided data against the current to eliminate unnecessary changes.
   *
   * @return {Promise<Entity|Entity[]>}   The updated Entity or array of Entities
   *
   * @example
   * const data = {_id: "12ekjf43kj2312ds", name: "New Name"}};
   * const updated = await Entity.update(data); // Updated entity saved to the database
   *
   * @example
   * const data = [{_id: "12ekjf43kj2312ds", name: "New Name 1"}, {_id: "kj549dk48k34jk34", name: "New Name 2"}]};
   * const updated = await Entity.update(data); // Returns an Array of Entities, updated in the database
   */
  static async update(data, options={}) {
    const entityName = this.entity;
    const collection = this.collection;
    const user = game.user;
    options = mergeObject({diff: true}, options);

    // Iterate over requested update data
    data = data instanceof Array ? data : [data];
    const updates = data.reduce((arr, d) => {

      // Get the Entity being updated
      if ( !d._id ) throw new Error(`You must provide an _id for every ${entityName} in the data Array.`);
      const entity = collection.get(d._id, {strict: true});

      // Diff the update against the current data
      if ( options.diff ) {
        d = diffObject(entity.data, expandObject(d));
        if ( isObjectEmpty(d) ) return arr;
        d["_id"] = entity.id;
      }

      // Call pre-update hooks to ensure the update is allowed to proceed
      const allowed = Hooks.call(`preUpdate${entityName}`, entity, d, options, user._id);
      if ( allowed === false ) {
        console.debug(`${vtt} | ${entityName} update prevented by preUpdate hook`);
        return arr;
      }

      // Stage the update
      arr.push(d);
      return arr;
    }, []);
    if ( !updates.length ) return [];

    // Trigger the Socket workflow
    const response = await SocketInterface.dispatch("modifyDocument", {
      type: entityName,
      action: "update",
      data: updates,
      options: options
    });

    // Call the response handler and return the created Entities
    const entities = this._handleUpdate(response);
    return data.length === 1 ? entities[0] : entities;
  }

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

  /**
   * Handle a SocketResponse from the server when one or multiple Entities are updated
   * @param {SocketRequest} request     The initial request
   * @param {Data[]} result             An Array of updated Entity data
   * @param {string} userId             The id of the requesting User
   * @return {Entity[]}                 An Array of constructed Entity instances
   * @private
   */
  static _handleUpdate({request, result=[], userId}={}) {
    const { type, options } = request;
    const collection = this.collection;

    // Prepare updated Entities
    const entities = result.map(data => {

      // Get and update the Entity data
      const entity = collection.get(data._id, {strict: true});
      mergeObject(entity.data, data);
      if (data.permission && entity.data.permission) entity.data.permission = data.permission;

      // Trigger follow-up actions and return
      entity._onUpdate(data, options, userId);
      Hooks.callAll(`update${type}`, entity, data, options, userId);
      return entity;
    });

    // Re-render the parent EntityCollection
    if ( options.render !== false ) {
      this.collection.render(false, {entityType: this.entity, action: "update", entities: entities, data: result});
    }

    // Return the updated Entities
    return entities;
  }

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

  /**
   * Entity-specific actions that should occur when the Entity is updated
   * @private
   */
	_onUpdate(data, options, userId) {
    this.prepareData();
    this.render(false, {
      action: "update",
      data: data
    });
  }

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

    /**
   * Update the current Entity using provided input data.
   * Data must be provided as a single object which updates the Entity data.
   * @see {Entity.update}
   *
   * @param {Data} data                   A Data object which updates the Entity
   * @param {Options} options             Additional options which customize the update workflow
   * @return {Promise<Entity>}            The updated Entity
   */
  async update(data, options={}) {
    data._id = this._id;

    // Delegate Compendium updates to the relevant pack
    if ( this.compendium ) {
      options.entity = this;
      return this.compendium.updateEntity(data, options);
    }

    // Perform World entity updates
    return this.constructor.update(data, options);
  }

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

  /**
   * Delete one or multiple existing entities using provided ids.
   * The target ids may be a single string or an Array of strings.
   * @static
   *
   * @param {string|string[]} data            A single id or Array of ids
   * @param {Options} options                 Additional options which customize the deletion workflow

   * @return {Promise<Entity|Entity[]>}       The deleted Entity or array of Entities
   *
   * @example
   * const id = "12ekjf43kj2312ds";
   * const deleted = await Entity.delete(id); // A single deleted entity from the database
   *
   * @example
   * const ids = ["12ekjf43kj2312ds", "kj549dk48k34jk34"];
   * const deleted = await Entity.delete(ids); // Returns an Array of deleted Entities
   */
  static async delete(data, options={}) {
    const entityName = this.entity;
    const user = game.user;
    options = mergeObject({temporary: false, renderSheet: false}, options);

    // Iterate over data to create
    data = data instanceof Array ? data : [data];
    for ( let d of data ) {
      const e = this.collection.get(d, {strict: true});
      const allowed = Hooks.call(`preDelete${entityName}`, e, options, user._id);
      if ( allowed === false ) {
        console.debug(`${vtt} | ${entityName} deletion prevented by preCreate hook`);
        return null;
      }
    }

    // Trigger the Socket workflow
    const response = await SocketInterface.dispatch("modifyDocument", {
      type: entityName,
      action: "delete",
      data: data,
      options: options
    });

    // Call the response handler and return the deleted Entities
    const entities = this._handleDelete(response);
    return data.length === 1 ? entities[0] : entities;
  }

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

  /**
   * Handle a SocketResponse from the server when one or multiple Entities are deleted
   * @param {SocketRequest} request     The initial request
   * @param {string[]} result           An Array of deleted Entity ids
   * @param {string} userId             The id of the requesting User
   * @return {Entity[]}                 An Array of deleted Entity instances
   * @private
   */
  static _handleDelete({request, result=[], userId}={}) {
    const {type, options} = request;
    const collection = this.collection;

    // Handle deleting all
    result = options.deleteAll ? Array.from(collection.keys()) : result;

    // Prepare deleted Entities
    const entities = result.map(id => {

      // Get and update the Entity data
      const entity = collection.get(id, {strict: true});
      collection.remove(id);

      // Trigger follow-up actions and return
      entity._onDelete(options, userId);
      Hooks.callAll(`delete${request.type}`, entity, options, userId);
      return entity;
    });

    // Log deletion
    let msg = ( entities.length === 1 ) ? `Deleted ${type}` : `Deleted ${entities.length} ${type}s`;
    if ( entities.length === 1 ) msg += ` with id ${entities[0].id}`;
    else if ( entities.length <= 5 ) msg += ` with ids: [${entities.map(d => d.id)}]`;
    console.log(`${vtt} | ${msg}`);

    // Re-render the parent EntityCollection
    if ( options.render !== false ) {
      this.collection.render(false, {entityType: this.entity, action: "delete", entities: entities, data: result});
    }

    // Return the deleted Entities
    return entities;
  }

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

  /**
   * Entity-specific actions that should occur when the Entity is deleted
   * @private
   */
  _onDelete(options, userId) {
    Object.values(this.apps).forEach(a => a.close({submit: false}));
  }

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

    /**
   * Delete the current Entity.
   * @see {Entity.delete}

   * @param {Options} options             Options which customize the deletion workflow
   * @return {Promise<Entity>}            The deleted Entity
   */
  async delete(options={}) {
    if ( this.compendium ) return this.compendium.deleteEntity(this._id, {entity: this});
    return this.constructor.delete(this._id, options);
  }

	/* -------------------------------------------- */
  /*  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;
  }

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

  /**
   * Create one or multiple EmbeddedEntities within this parent Entity.
   * Data may be provided as a single Object to create one EmbeddedEntity or as an Array of Objects to create many.
   * Entities may be temporary (unsaved to the database) by passing the temporary option as true.
   *
   * @param {string} embeddedName   The name of the Embedded Entity class to create
   * @param {Data|Data[]} data      A Data object or an Array of Data objects to create
   * @param {Options} options       Additional creation options which modify the request
   * @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 each created Embedded Entities once created.
   *
   * @return {Promise<Data|Data[]>} A Promise which resolves to the created embedded Data once the creation request is successful
   *
   * @example
   * const actor = game.actors.get("dfv934kj23lk6h9k");
   * const data = {name: "Magic Sword", type: "weapon", img: "path/to/icon.png"};
   * const created = await actor.createEmbeddedEntity("OwnedItem", data); // Returns one EmbeddedEntity, saved to the Actor
   * const temp = await actor.createEmbeddedEntity("OwnedItem", data, {temporary: true}); // Not saved to the Actor
   *
   * @example
   * const actor = game.actors.get("dfv934kj23lk6h9k");
   * const data = [{name: "Mace of Crushing", type: "weapon"}, {name: "Shield of Defense", type: "armor"}];
   * const created = await actor.createEmbeddedEntity("OwnedItem", data); // Returns an Array of EmbeddedEntities, saved to the Actor
   * const temp = await actor.createEmbeddedEntity("OwnedItem", data, {temporary: true}); // Not saved to the Actor
   */
  async createEmbeddedEntity(embeddedName, data, options={}) {
    this.getEmbeddedCollection(embeddedName); // Do this to validate the collection exists
    const user = game.user;
    options = mergeObject({temporary: false, renderSheet: false}, options);

    // Iterate over data to create
    data = data instanceof Array ? data : [data];
    for ( let d of data ) {
      const allowed = Hooks.call(`preCreate${embeddedName}`, this, d, options, user._id);
      if ( allowed === false ) {
        console.debug(`${vtt} | ${embeddedName} creation prevented by preCreate hook`);
        return null;
      }
    }

    // Trigger the Socket workflow
    const response = await SocketInterface.dispatch("modifyEmbeddedDocument", {
      action: "create",
      type: embeddedName,
      parentType: this.entity,
      parentId: this.id,
      data: data,
      options: options
    });

    // Call the response handler and return the created Entities
    const embedded = this.constructor._handleCreateEmbeddedEntity(response);
    return data.length === 1 ? embedded[0] : embedded;
  }

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

  /**
   * Handle a SocketResponse from the server when one or multiple Embedded Entities are created
   * @param {SocketRequest} request     The initial request
   * @param {Data[]} result             An Array of created Entity data
   * @param {string} userId             The id of the requesting User
   * @return {Data[]}                   An Array of constructed EmbeddedDocument data
   * @private
   */
  static _handleCreateEmbeddedEntity({request, result=[], userId}={}) {
    const { type, parentType, parentId, options } = request;
    const { temporary } = options;
    const parent = this.collection.get(parentId);
    const collection = parent.getEmbeddedCollection(type);

    // Return temporary data directly
    if ( temporary ) return result;

    // Add the created data into the collection
    collection.push(...result);

    // Trigger follow-up actions for each created EmbeddedEntity
    for ( let r of result ) {
      parent._onCreateEmbeddedEntity(type, r, options, userId);
      Hooks.callAll(`create${type}`, parent, r, options, userId);
    }
    parent._onModifyEmbeddedEntity(type, result, options, userId, {action: "create"});

    // Log creation
    let msg = ( result.length === 1 ) ? `Created ${type}` : `Created ${result.length} ${type}s`;
    if ( result.length === 1 ) msg += ` ${result[0]._id}`;
    else if ( result.length <= 5 ) msg += ` [${result.map(d => d._id)}]`;
    msg += ` in parent ${parentType} ${parent.id}`;
    console.log(`${vtt} | ${msg}`);

    // Return the created results
    return result;
  }

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

  /**
   * Handle Embedded Entity creation within this Entity with specific callback steps.
   * This function is triggered once per EmbeddedEntity which is updated.
   * It therefore may run multiple times per creation workflow.
   * Any steps defined here should run on a per-EmbeddedEntity basis.
   * Steps that should run once for the whole batch should go in _onModifyEmbeddedEntity()
   * @private
   */
  _onCreateEmbeddedEntity(embeddedName, child, options, userId) {}

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

  /**
   * Update one or multiple existing entities using provided input data.
   * Data may be provided as a single object to update one Entity, or as an Array of Objects.
   * @static
   *
   * @param {string} embeddedName   The name of the Embedded Entity class to create
   * @param {Data|Data[]} data            A Data object or array of Data. Each element must contain the _id of an existing Entity.
   * @param {Options} options             Additional options which customize the update workflow
   * @param {boolean} [options.diff]      Difference the provided data against the current to eliminate unnecessary changes.
   *
   * @return {Promise<Entity|Entity[]>}   The updated Entity or array of Entities
   *
   * @example
   * const actor = game.actors.get("dfv934kj23lk6h9k");
   * const item = actor.data.items.find(i => i.name === "Magic Sword");
   * const update = {_id: item._id, name: "Magic Sword +1"};
   * const updated = await actor.updateEmbeddedEntity("OwnedItem", update); // Updates one EmbeddedEntity
   *
   * @example
   * const actor = game.actors.get("dfv934kj23lk6h9k");
   * const weapons = actor.data.items.filter(i => i.type === "weapon");
   * const updates = weapons.map(i => {
   *   return {_id: i._id, name: i.name + "+1"};
   * }
   * const updated = await actor.createEmbeddedEntity("OwnedItem", updates); // Updates multiple EmbeddedEntity objects
   */
  async updateEmbeddedEntity(embeddedName, data, options={}) {
    const collection = this.getEmbeddedCollection(embeddedName);
    const user = game.user;
    options = mergeObject({diff: true}, options);

    // Structure the update data
    const pending = new Map();
    data = data instanceof Array ? data : [data];
    for ( let d of data ) {
      if ( !d._id ) throw new Error("You must provide an id for every Embedded Entity in an update operation");
      pending.set(d._id, d);
    }

    // Difference each update against existing data
    const updates = collection.reduce((arr, d) => {
      if ( !pending.has(d._id) ) return arr;
      let update = pending.get(d._id);

      // Diff the update against current data
      if ( options.diff ) {
        update = diffObject(d, expandObject(update));
        if ( isObjectEmpty(update) ) return arr;
        update["_id"] = d._id;
      }

      // Call pre-update hooks to ensure the update is allowed to proceed
      const allowed = Hooks.call(`preUpdate${embeddedName}`, this, d, update, options, user._id);
      if ( allowed === false ) {
        console.debug(`${vtt} | ${embeddedName} update prevented by preUpdate hook`);
        return arr;
      }

      // Stage the update
      arr.push(update);
      return arr;
    }, []);
    if ( !updates.length ) return [];

    // Trigger the Socket workflow
    const response = await SocketInterface.dispatch("modifyEmbeddedDocument", {
      action: "update",
      type: embeddedName,
      parentType: this.entity,
      parentId: this.id,
      data: updates,
      options: options
    });

    // Call the response handler and return the created Entities
    const embedded = this.constructor._handleUpdateEmbeddedEntity(response);
    return data.length === 1 ? embedded[0] : embedded;
  }

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

  /**
   * Handle a SocketResponse from the server when one or multiple Embedded Entities are updated
   * @param {SocketRequest} request     The initial request
   * @param {Data[]} result             An Array of updated Entity data
   * @param {string} userId             The id of the requesting User
   * @return {Data[]}                   An Array of updated EmbeddedDocument data
   * @private
   */
  static _handleUpdateEmbeddedEntity({request, result=[], userId}={}) {
    const { type, parentId, options } = request;
    const parent = this.collection.get(parentId);
    const collection = parent.getEmbeddedCollection(type);

    // Structure the pending updates
    const pending = new Map(result.map(d => [d._id, d]));

    // Update children in the collection
    for ( let doc of collection ) {

      // Update the data
      if ( !pending.has(doc._id) ) continue;
      const update = pending.get(doc._id);
      mergeObject(doc, update);

      // Trigger follow-up actions
      parent._onUpdateEmbeddedEntity(type, doc, update, options, userId);
      Hooks.callAll(`update${type}`, parent, doc, update, options, userId);
    }

    // Trigger overall modification of the parent
    parent._onModifyEmbeddedEntity(type, result, options, userId, {action: "update"});

    // Return the created results
    return result;
  }

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

  /**
   * Handle Embedded Entity updates within this Entity with specific callback steps.
   * This function is triggered once per EmbeddedEntity which is updated.
   * It therefore may run multiple times per creation workflow.
   * Any steps defined here should run on a per-EmbeddedEntity basis.
   * Steps that should run once for the whole batch should go in _onModifyEmbeddedEntity()
   * @private
   */
  _onUpdateEmbeddedEntity(embeddedName, child, updateData, options, userId) {}

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

  /**
   * Delete one or multiple existing EmbeddedEntity objects using provided input data.
   * Data may be provided as a single id to delete one object or as an Array of string ids.
   * @static
   *
   * @param {string} embeddedName   The name of the Embedded Entity class to create
   * @param {string|string[]} data        A Data object or array of Data. Each element must contain the _id of an existing Entity.
   * @param {Options} options             Additional options which customize the update workflow

   * @return {Promise<Data|Data[]>}       The deleted Embedded Entities
   *
   * @example
   * const actor = game.actors.get("dfv934kj23lk6h9k");
   * const item = actor.data.items.find(i => i.name === "Magic Sword");
   * const deleted = await actor.deleteEmbeddedEntity("OwnedItem", item._id); // Deletes one EmbeddedEntity
   *
   * @example
   * const actor = game.actors.get("dfv934kj23lk6h9k");
   * const weapons = actor.data.items.filter(i => i.type === "weapon");
   * const deletions = weapons.map(i => i._id);
   * const updated = await actor.deleteEmbeddedEntity("OwnedItem", deletions); // Deletes multiple EmbeddedEntity objects

   */
  async deleteEmbeddedEntity(embeddedName, data, options={}) {
    const collection = this.getEmbeddedCollection(embeddedName);
    const user = game.user;

    // Structure the input data
    data = data instanceof Array ? data : [data];
    const ids = new Set(data);

    // Iterate over elements of the collection
    const deletions = collection.reduce((arr, d) => {
      if ( !ids.has(d._id) ) return arr;

      // Call pre-update hooks to ensure the update is allowed to proceed
      const allowed = Hooks.call(`preDelete${embeddedName}`, this, d, options, user._id);
      if ( allowed === false ) {
        console.debug(`${vtt} | ${embeddedName} update prevented by preUpdate hook`);
        return arr;
      }

      // Add the id to the pending array
      arr.push(d._id);
      return arr;
    }, []);
    if ( !deletions.length ) return [];

    // Trigger the Socket workflow
    const response = await SocketInterface.dispatch("modifyEmbeddedDocument", {
      action: "delete",
      type: embeddedName,
      parentType: this.entity,
      parentId: this.id,
      data: deletions,
      options: options
    });

    // Call the response handler and return the created Entities
    const embedded = this.constructor._handleDeleteEmbeddedEntity(response);
    return deletions.length === 1 ? embedded[0] : embedded;
  }

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

  /**
   * Handle a SocketResponse from the server when one or multiple Embedded Entities are deleted
   * @param {SocketRequest} request     The initial request
   * @param {string[]} result           An Array of deleted EmbeddedEntity ids
   * @param {string} userId             The id of the requesting User
   * @return {Data[]}                   An Array of deleted EmbeddedDocument data
   * @private
   */
  static _handleDeleteEmbeddedEntity({request, result=[], userId}={}) {
    const { type, parentType, parentId, options } = request;
    const parent = this.collection.get(parentId);
    const collection = parent.getEmbeddedCollection(type);

    // Structure the pending updates
    const deletions = new Set(result);

    // Update children in the collection
    const [deleted, surviving] = collection.partition(doc => {
      if ( !deletions.has(doc._id) ) return true;
      parent._onDeleteEmbeddedEntity(type, doc, options, userId);
      Hooks.callAll(`delete${type}`, parent, doc, options, userId);
      return false;
    });

    // Assign the updated collection
    const collectionName = this.config.embeddedEntities[type];
    parent.data[collectionName] = surviving;

    // Trigger overall modification of the parent
    parent._onModifyEmbeddedEntity(type, result, options, userId, {action: "update"});

    // Log deletion
    let msg = ( deleted.length === 1 ) ? `Deleted ${type}` : `Deleted ${result.length} ${type}s`;
    if ( deleted.length === 1 ) msg += ` ${result[0]}`;
    else if ( !options.deleteAll && (deleted.length <= 5) ) msg += ` [${result}]`;
    msg += ` from parent ${parentType} ${parent.id}`;
    console.log(`${vtt} | ${msg}`);
    return deleted;
  }

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

  /**
   * Handle Embedded Entity deletion within this Entity with specific callback steps.
   * This function is triggered once per EmbeddedEntity which is updated.
   * It therefore may run multiple times per creation workflow.
   * Any steps defined here should run on a per-EmbeddedEntity basis.
   * Steps that should run once for the whole batch should go in _onModifyEmbeddedEntity()
   * @private
   */
  _onDeleteEmbeddedEntity(embeddedName, child, options, userId) {}

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

  /**
   * A generic helper since we take the same actions for every type of Embedded Entity update
   * Unlike the specific _onCreate, _onUpdate, and _onDelete methods this only runs once per updated batch
   * @private
   */
  _onModifyEmbeddedEntity(embeddedName, changes, options, userId, context={}) {
    this.prepareData();
    this.render(false, context);
  }

  /* -------------------------------------------- */
  /*  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});
    return this.constructor.create(createData, options);
  }

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

  /**
   * Serializing an Entity should simply serialize it's inner data, not the entire instance
   * @return {Object}
   */
  toJSON() {
    return this.data;
  }

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

  /**
   * 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);
  }

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

  /**
   * Transform the Entity data to be stored in a Compendium pack.
   * Remove any features of the data which are world-specific.
   * This function is asynchronous in case any complex operations are required prior to exporting.
   *
   * @return {Object}   A data object of cleaned data ready for compendium import
   */
  async toCompendium() {
    const data = duplicate(this.data);
    const deleteKeys = ["_id", "permission", "folder", "sort", "active"];
    for ( let k of deleteKeys ) {
      delete data[k];
    }
    return 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() {

    // Verify that WebGL is available
    if ( !PIXI.utils.isWebGLSupported() ) {
      const err = new Error(game.i18n.localize("ERROR.NoWebGL"));
      Hooks.once("renderNotifications", app => app.error(err.message, {permanent: true}));
      throw err;
    }

    // Create the canvas element and attach it to the DOM
    const canvas = document.createElement("canvas");
    canvas.id = "board";
    $("#board").replaceWith(canvas);

    // Activate drop handling
    this._dragDrop = new DragDrop({ callbacks: { drop: this._onDrop.bind(this) } }).bind(canvas);

    // Create PIXI Application
    this.app = new PIXI.Application({
      view: canvas,
      width: window.innerWidth,
      height: window.innerHeight,
      antialias: true,
      transparent: false,
      resolution: window.devicePixelRatio,
      backgroundColor: null
    });

    // Define the Stage
    this.stage = this.app.stage;
    Object.defineProperty(this.stage.constructor, 'name', {value: `CanvasStage`, writable: false});

    // Create Canvas layers and the HUD
    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;

    /**
     * Track the last automatic pan time to throttle
     * @type {number}
     * @private
     */
    this._panTime = 0;


    /**
     * An object of data which is temporarily cached to be reloaded after the canvas is drawn
     * @type {Object}
     * @private
     */
    this._reload = { layer: "TokenLayer" };

    /**
     * The singleton interaction manager instance which handles mouse workflows on the Canvas
     * @type {MouseInteractionManager}
     */
    this.mouseInteractionManager = null;

    /**
     * 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.constructor.getDimensions(scene.data);
    canvas.app.view.style.display = "block";
    document.documentElement.style.setProperty("--gridSize", this.dimensions.size+"px");

    // Configure the app ticker
    const maxFPS = Math.clamped(game.settings.get("core", "maxFPS"), 0, 60);
    this.app.ticker.maxFPS = maxFPS === 60 ? 0 : maxFPS;

    // Call initialiation hooks
    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 TextureLoader.loadSceneTextures(this.scene);

    // Draw layers
    for ( let l of this.layers ) {
      try {
        await l.draw();
      } catch(err) {
        ui.notifications.error(`Canvas drawing failed for the ${l.name}, see the console for more details.`);
        console.error(err);
      }
    }

    // Initialize starting conditions
    await this._initialize();

    // Add interactivity
    this._addListeners();

    // Check if the window was re-sized before the draw operation concluded
    await 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
   */
  static 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
   */
  async _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
    await 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}={}) {

    // Constrain the resulting canvas view
    const constrained = this._constrainView({x, y, scale});

    // Set the pivot point
    this.stage.pivot.set(constrained.x, constrained.y);

    // Set the zoom level
    this.stage.scale.set(constrained.scale, constrained.scale);

    // Decrease the level of blur as we zoom out
    this.sight.blurDistance = 20 / (CONFIG.Canvas.maxZoom - Math.round(constrained.scale) + 1);

    // Update the scene tracked position
    canvas.scene._viewPosition = constrained;

    // Call canvasPan Hook
    Hooks.callAll("canvasPan", this, constrained);

    // Align the HUD
    this.hud.align();
  }

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

  /**
   * 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);
    }

    // Constrain the resulting dimensions and construct animation attributes
    const constrained = this._constrainView({x, y, scale});
    const attributes = [
      { parent: this.stage.pivot, attribute: 'x', to: constrained.x },
      { parent: this.stage.pivot, attribute: 'y', to: constrained.y },
      { parent: this.stage.scale, attribute: 'x', to: constrained.scale },
      { parent: this.stage.scale, attribute: 'y', to: constrained.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});
      }
    });

    // Decrease the level of blur as we zoom out
    this.sight.blurDistance = 20 / (CONFIG.Canvas.maxZoom - Math.round(constrained.scale) + 1);

    // Update the scene tracked position
    canvas.scene._viewPosition = constrained;
  }

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

  /**
   * Get the constrained zoom scale parameter which is allowed by the maxZoom parameter
   * @param {number} x              The requested x-coordinate
   * @param {number} y              The requested y-coordinate
   * @param {number} scale          The requested scale
   * @return {{x, y, scale}}        The allowed scale
   * @private
   */
  _constrainView({x, y, scale}) {
    const d = canvas.dimensions;

    // Constrain the maximum zoom level
    if ( Number.isNumeric(scale) && (scale !== this.stage.scale.x) ) {
      const max = CONFIG.Canvas.maxZoom;
      const ratio = Math.max(d.width / window.innerWidth, d.height / window.innerHeight, max);
      scale = Math.round(Math.clamped(scale, 1 / ratio, max) * 100) / 100;
    } else {
      scale = this.stage.scale.x;
    }

    // Constrain the pivot point using the new scale
    if ( Number.isNumeric(x) && x !== this.stage.pivot.x ) {
      const padw = 0.4 * (window.innerWidth / scale);
      x = Math.clamped(x, -padw, d.width + padw);
    }
    else x = this.stage.pivot.x;
    if ( Number.isNumeric(y) && x !== this.stage.pivot.y ) {
      const padh = 0.4 * (window.innerHeight / scale);
      y = Math.clamped(y, -padh, d.height + padh);
    }
    else y = this.stage.pivot.y;

    // Return the constrained view dimensions
    return {x, y, scale};
  }

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

  /**
   * 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() {

    // Remove all existing listeners
    this.stage.removeAllListeners();

    // Define callback functions for mouse interaction events
    const callbacks = {
      clickLeft: this._onClickLeft.bind(this),
      clickLeft2: this._onClickLeft2.bind(this),
      clickRight: this._onClickRight.bind(this),
      clickRight2: null,
      dragLeftStart: this._onDragLeftStart.bind(this),
      dragLeftMove: this._onDragLeftMove.bind(this),
      dragLeftDrop: this._onDragLeftDrop.bind(this),
      dragLeftCancel: this._onDragLeftCancel.bind(this),
      dragRightStart: null,
      dragRightMove: this._onDragRightMove.bind(this),
      dragRightDrop: this._onDragRightDrop.bind(this),
      dragRightCancel: null
    };

    // Create and activate the interaction manager
    const permissions = {};
    const mgr = new MouseInteractionManager(this.stage, this.stage, permissions, callbacks);
    this.mouseInteractionManager = mgr.activate();

    // Add a listener for cursor movement
    this.stage.on("mousemove", event => canvas.controls._onMoveCursor(event));
  }

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

  /**
   * Handle left mouse-click events occurring on the Canvas stage or its active Layer.
   * @see {MouseInteractionManager#_handleClickLeft}
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onClickLeft(event) {

    // Extract event data
    const layer = this.activeLayer;
    const tool = game.activeTool;

    // Place Ruler waypoints
    const isRuler = tool === "ruler";
    const isCtrlRuler = game.keyboard.isCtrl(event) && (layer.name === "TokenLayer");
    if ( isRuler || isCtrlRuler ) return this.controls.ruler._onClickLeft(event);

    // Begin select events
    const isSelect = ["select", "target"].includes(tool);
    if ( isSelect ) return;

    // Dispatch the event to the active layer
    if ( layer instanceof PlaceablesLayer ) layer._onClickLeft(event);
  }

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

  /**
   * Handle double left-click events occurring on the Canvas stage.
   * @see {MouseInteractionManager#_handleClickLeft2}
   * @param {PIXI.interaction.InteractionEvent} event
   */
  _onClickLeft2(event) {
    const layer = this.activeLayer;
    if ( layer instanceof PlaceablesLayer ) layer._onClickLeft2(event);
  }

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

  /**
   * Handle the beginning of a left-mouse drag workflow on the Canvas stage or its active Layer.
   * @see {MouseInteractionManager#_handleDragStart}
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onDragLeftStart(event) {

    // Extract event data
    const oe = event.data.originalEvent;
    const layer = this.activeLayer;
    const isRuler = game.activeTool === "ruler";
    const isCtrlRuler = (oe.ctrlKey || oe.metaKey) && (layer.name === "TokenLayer");

    // Begin ruler measurement
    if ( isRuler || isCtrlRuler ) return this.controls.ruler._onDragStart(event);

    // Activate select rectangle
    const isSelect = ["select", "target"].includes(game.activeTool);
    if ( isSelect ) {
      canvas.controls.select.active = true;
      return;
    }

    // Dispatch the event to the active layer
    if ( layer instanceof PlaceablesLayer ) layer._onDragLeftStart(event);
  }

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

  /**
   * Handle mouse movement events occurring on the Canvas stage or it's active layer
   * @see {MouseInteractionManager#_handleDragMove}
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onDragLeftMove(event) {
    const layer = this.activeLayer;

    // Pan the canvas if the drag event approaches the edge
    this._onDragCanvasPan(event.data.originalEvent);

    // Continue a Ruler measurement
    const ruler = this.controls.ruler;
    if ( ruler._state > 0 ) return ruler._onMouseMove(event);

    // Continue a select event
    const isSelect = ["select", "target"].includes(game.activeTool);
    if ( isSelect && canvas.controls.select.active ) return this._onDragSelect(event);


    // Dispatch the event to the active layer
    if ( layer instanceof PlaceablesLayer ) layer._onDragLeftMove(event);
  }

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

  /**
   * Handle the conclusion of a left-mouse drag workflow when the mouse button is released.
   * @see {MouseInteractionManager#_handleDragDrop}
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onDragLeftDrop(event) {

    // Extract event data
    const {coords, originalEvent} = event.data;
    const tool = game.activeTool;
    const layer = canvas.activeLayer;
    const isCtrl = game.keyboard.isCtrl(event);

    // Conclude a measurement event if we aren't holding the CTRL key
    const ruler = canvas.controls.ruler;
    if ( ruler.active ) {
      if ( isCtrl ) originalEvent.preventDefault();
      return ruler._onMouseUp(event);
    }

    // Conclude a select event
    const isSelect = ["select", "target"].includes(tool);
    if ( isSelect && canvas.controls.select.active ) {
      canvas.controls.select.clear();
      canvas.controls.select.active = false;
      if ( tool === "select" ) return layer.selectObjects(coords);
      if ( tool === "target" ) return layer.targetObjects(coords, {releaseOthers: !originalEvent.shiftKey});
    }

    // Dispatch the event to the active layer
    if ( layer instanceof PlaceablesLayer ) layer._onDragLeftDrop(event);
  }

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

  /**
   * Handle the cancellation of a left-mouse drag workflow
   * @see {MouseInteractionManager#_handleDragCancel}
   * @param {PointerEvent} event
   * @private
   */
  _onDragLeftCancel(event) {
    const layer = canvas.activeLayer;
    const tool = game.activeTool;

    // Don't cancel ruler measurement
    const ruler = canvas.controls.ruler;
    if ( ruler.active ) return event.preventDefault();

    // Clear selection
    const isSelect = ["select", "target"].includes(tool);
    if ( isSelect ) return canvas.controls.select.clear();

    // Dispatch the event to the active layer
    if ( layer instanceof PlaceablesLayer ) layer._onDragLeftCancel(event);
  }

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

  /**
   * Handle right mouse-click events occurring on the Canvas stage or it's active layer
   * @see {MouseInteractionManager#_handleClickRight}
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onClickRight(event) {
    const ruler = canvas.controls.ruler;
    if ( ruler.active ) return ruler._onClickRight(event);

    // Dispatch to the active layer
    const layer = this.activeLayer;
    if ( layer instanceof PlaceablesLayer ) layer._onClickRight(event);
  }

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

  /**
   * Handle right-mouse drag events occuring on the Canvas stage or an active Layer
   * @see {MouseInteractionManager#_handleDragMove}
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onDragRightMove(event) {

    // Extract event data
    const DRAG_SPEED_MODIFIER = 0.8;
    const {cursorTime, origin, destination} = event.data;
    const dx = destination.x - origin.x;
    const dy = destination.y - origin.y;

    // Update the client's cursor position every 100ms
    const now = Date.now();
    if ( (now - (cursorTime || 0)) > 100 ) {
      if ( this.controls ) this.controls._onMoveCursor(event, destination);
      event.data.cursorTime = now;
    }

    // Pan the canvas
    this.pan({
      x: canvas.stage.pivot.x - (dx * DRAG_SPEED_MODIFIER),
      y: canvas.stage.pivot.y - (dy * DRAG_SPEED_MODIFIER)
    });

    // Reset Token tab cycling
    this.tokens._tabIndex = null;
  }


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

  /**
   * Handle the conclusion of a right-mouse drag workflow the Canvas stage.
   * @see {MouseInteractionManager#_handleDragDrop}
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onDragRightDrop(event) {}

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

  /**
   * Determine selection coordinate rectangle during a mouse-drag workflow
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onDragSelect(event) {

    // Extract event data
    const {origin, destination} = event.data;

    // Determine rectangle coordinates
    let coords = {
      x: Math.min(origin.x, destination.x),
      y: Math.min(origin.y, destination.y),
      width: Math.abs(destination.x - origin.x),
      height: Math.abs(destination.y - origin.y)
    };

    // Draw the select rectangle
    canvas.controls.drawSelect(coords);
    event.data.coords = coords;
  }

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

  /**
   * Pan the canvas view when the cursor position gets close to the edge of the frame
   * @param {MouseEvent} event    The originating mouse movement event
   * @private
   */
  _onDragCanvasPan(event) {

    // Throttle panning by 200ms
    const now = Date.now();
    if ( now - (this._panTime || 0) <= 200 ) return;
    this._panTime = now;

    // Shift by 3 grid spaces at a time
    const {x, y} = event;
    const pad = 50;
    const shift = (this.dimensions.size * 3) / this.stage.scale.x;

    // Shift horizontally
    let dx = 0;
    if ( x < pad ) dx = -shift;
    else if ( x > window.innerWidth - pad ) dx = shift;

    // Shift vertically
    let dy = 0;
    if ( y < pad ) dy = -shift;
    else if ( y > window.innerHeight - pad ) dy = shift;

    // Enact panning
    if ( dx || dy ) return this.animatePan({x: this.stage.pivot.x + dx, y: this.stage.pivot.y + dy, duration: 200});
  }

  /* -------------------------------------------- */
  /*  Other Event Handlers                        */
  /* -------------------------------------------- */

  /**
   * 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});
  }

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

  _onMouseWheel(event) {
    let dz = ( event.deltaY < 0 ) ? 1.05 : 0.95;
    this.pan({scale: dz * canvas.stage.scale.x});
  }

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

  /**
   * 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;
    }

    // Acquire the cursor position transformed to Canvas coordinates
    const [x, y] = [event.clientX, event.clientY];
    const t = this.stage.worldTransform;
    data.x = (x - t.tx) / canvas.stage.scale.x;
    data.y = (y - t.ty) / canvas.stage.scale.y;

    // 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);
    }

    // Dropped Tile artwork
    else if ( data.type === "Tile" ) {
      return canvas.tiles._onDropTileData(event, data);
    }
  }

  /* -------------------------------------------- */
  /*  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.layers.forEach(layer => {
      if ( layer !== this ) 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;
  }
}
/**
 * An Abstract Base Class which defines a Placeable Object which represents an Entity placed on the Canvas
 * @extends {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 {{fov: PIXI.Polygon|null, los: PIXI.Polygon|null}}
     */
    this.vision = {fov: null, los: null};

    /**
     * A control icon for interacting with the object
     * @type {ControlIcon}
     */
    this.controlIcon = null;

    /**
     * A mouse interaction manager instance which handles mouse workflows related to this object.
     * @type {MouseInteractionManager}
     */
    this.mouseInteractionManager = null;

    /**
     * An indicator for whether the object is currently controlled
     * @type {boolean}
     * @private
     */
    this._controlled = false;

    /**
     * An indicator for whether the object is currently a hover target
     * @type {boolean}
     * @private
     */
    this._hover = false;

    /**
     * A singleton reference to the FormApplication class which configures this object
     * @type {FormApplication|null}
     * @private
     */
    this._sheet = null;
  }

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

  /**
   * The central coordinate pair of the placeable object based on it's own width and height
   * @type {PIXI.Point}
   */
  get center() {
    if ( "width" in this.data && "height" in this.data ) {
      return new PIXI.Point(this.data.x + (this.data.width / 2), this.data.y + (this.data.height / 2));
    }
    return new PIXI.Point(this.data.x, this.data.y);
  }

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

  /**
   * The _id of the underlying EmbeddedEntity
   * @type {string}
   */
  get id() {
    return this.data._id;
  }

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

  /**
   * The field-of-vision polygon for the object, if it has been computed
   * @type {PIXI.Polygon|null}
   */
  get fov() {
    return this.vision.fov;
  }

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

  /**
   * Identify the official EmbeddedEntity name for this PlaceableObject class
   * @type {string}
   */
  static get embeddedName() {
    throw new Error("Each PlaceableObject subclass should define it's canonical embeddedName");
  }

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

  /**
   * Provide a reference to the canvas layer which contains placeable objects of this type
   * @type {PlaceablesLayer}
   */
  static get layer() {
    return canvas.getLayerByEmbeddedName(this.embeddedName);
  }

  /** @alias {PlaceableObject.layer} */
  get layer() {
    return this.constructor.layer;
  }

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

  /**
   * The line-of-sight polygon for the object, if it has been computed
   * @type {PIXI.Polygon|null}
   */
  get los() {
    return this.vision.los;
  }

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

  /**
   * A Form Application which is used to configure the properties of this Placeable Object or the EmbeddedEntity
   * it represents.
   * @type {FormApplication}
   */
  get sheet() {
    if ( !this._sheet ) {
      const cls = this.layer.options.sheetClass;
      this._sheet = new cls(this);
    }
    return this._sheet;
  }

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

  /**
   * A Universally Unique Identifier (uuid) for this EmbeddedEntity
   * @type {string}
   */
  get uuid() {
    return `${this.scene.uuid}.${this.constructor.name}.${this.id}`;
  }

  /* -------------------------------------------- */
  /*  Permission Controls                         */
  /* -------------------------------------------- */

  /**
   * Test whether a user can perform a certain interaction with regards to a Placeable Object
   * @param {User} user       The User performing the action
   * @param {string} action   The named action being attempted
   * @return {boolean}        Does the User have rights to perform the action?
   */
  can(user, action) {
    const fn = this[`_can${action.titleCase()}`];
    return fn ? fn.call(this, user) : false;
  }

  /**
   * Can the User access the HUD for this Placeable Object?
   * @private
   */
  _canHUD(user, event) {
    return false;
  }

  /**
   * Does the User have permission to configure the Placeable Object?
   * @private
   */
  _canConfigure(user, event) {
    return user.isGM;
  }

  /**
   * Does the User have permission to control the Placeable Object?
   * @private
   */
  _canControl(user, event) {
    return user.isGM;
  }

  /**
   * Does the User have permission to view details of the Placeable Object?
   * @private
   */
  _canView(user, event) {
    return user.isGM;
  }

  /**
   * Does the User have permission to create the underlying Embedded Entity?
   * @private
   */
  _canCreate(user, event) {
    return user.isGM;
  }

  /**
   * Does the User have permission to drag this Placeable Object?
   * @private
   */
  _canDrag(user, event) {
    return this._canControl(user);
  }

  /**
   * Does the User have permission to hover on this Placeable Object?
   * @private
   */
  _canHover(user, event) {
    return this._canControl(user);
  }

  /**
   * Does the User have permission to update the underlying Embedded Entity?
   * @private
   */
  _canUpdate(user, event) {
    return this._canControl(user);
  }

  /**
   * Does the User have permission to delete the underlying Embedded Entity?
   * @private
   */
  _canDelete(user, event) {
    return this._canControl(user);
  }

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

  /**
   * Clear the display of the existing object
   * @return {PlaceableObject}    The cleared object
   */
  clear() {
    this.removeChildren().forEach(c => c.destroy({children: true}));
    return 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;
    clone._original = this;
    return clone;
  }

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

  /**
   * Draw the placeable object into its parent container
   * @return {PlaceableObject}    The drawn object
   */
  async draw() {
    throw new Error("A PlaceableObject subclass must define initial drawing procedure.");
  }

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

  /**
   * Draw the primary Sprite for the PlaceableObject
   * @return {PIXI.Sprite|null}
   * @private
   */
  _drawPrimarySprite(texture) {
    if ( !texture || !texture.valid ) return null;
    const s = new PIXI.Sprite(texture);
    const source = getProperty(texture, "baseTexture.resource.source");
    if ( source && (source.tagName === "VIDEO") ) {
      source.loop = true;
      source.muted = true;
      game.video.play(source);
    }
    return s;
  }

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

  /**
   * Refresh the current display state of the Placeable Object
   * @return {PlaceableObject}    The refreshed object
   */
  refresh() {
    throw new Error("A PlaceableObject subclass must define an refresh drawing procedure.");
  }

  /* -------------------------------------------- */
  /*  Database Management                         */
  /* -------------------------------------------- */

  /** @extends {Entity.createEmbeddedEntity} */
  static async create(data, options) {
    const created = await canvas.scene.createEmbeddedEntity(this.embeddedName, data, options);
    if ( !created ) return;
    if ( created instanceof Array ) {
      return created.map(c => this.layer.get(c._id));
    } else {
      return this.layer.get(created._id);
    }
  }

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

  /** @extends {Entity.updateEmbeddedEntity} */
  async update(data, options) {
    data["_id"] = this.id;
    await this.scene.updateEmbeddedEntity(this.constructor.embeddedName, data, options);
    return this;
  }

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

  /** @extends {Entity.deleteEmbeddedEntity} */
  async delete(options) {
    await this.scene.deleteEmbeddedEntity(this.constructor.embeddedName, this.id, options);
    return this;
  }

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

  /**
   * 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});
  }

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

  /**
   * Register pending canvas operations which should occur after a new PlaceableObject of this type is created
   * @private
   */
  _onCreate() {}

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

  /**
   * 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") ) {
      this.zIndex = parseInt(data.z) || 0;
    }
    this.refresh();
  }

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

  /**
   * 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;
  }

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

  /**
   * 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.layer.options.controllableObjects ) return false;
    if (this._controlled) return true;
    if ( options.releaseOthers !== false ) this.layer.releaseAll();
    if (!this.can(game.user, "control")) 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) {
    const layer = this.layer;
    if ( layer.hud && (layer.hud.object === this) ) layer.hud.clear();
    this.refresh();
  }

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

  /**
   * 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<PlaceableObject>} A Promise which resolves once the rotation has completed
   */
  async rotate(angle, snap) {
    if ( this.data.rotation === undefined ) return this;
    const rotation = this._updateRotation({angle, snap});

    // Conceal any active hud
    const hud = this.layer.hud;
    if ( hud ) hud.clear();

    // Update the object
    return this.update({rotation});
  }

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

  /**
   * Determine a new angle of rotation for a PlaceableObject either from an explicit angle or from a delta offset.
   * @param {number} [angle]    An explicit angle, either this or delta must be provided
   * @param {number} [delta]    A relative angle delta, either this or the angle must be provided
   * @param {number} [snap]     A precision (in degrees) to which the resulting angle should snap. Default is 0.
   * @return {number}           The new rotation angle for the object
   */
  _updateRotation({angle=null, delta=0, snap=0}={}) {
    let degrees = Number.isNumeric(angle) ? angle : this.data.rotation + delta;

    // Determine an offset for certain grid orientations
    let isHexRow = [CONST.GRID_TYPES.HEXODDR, CONST.GRID_TYPES.HEXEVENR].includes(canvas.grid.type);
    const offset = isHexRow ? 30 : 0;
    degrees = (degrees + offset) % 360;

    // Snap to the desired precision
    if ( snap > 0 ) {
      degrees = Math.round(degrees / snap) * snap;
    }
    return degrees - offset;
  }

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

  /**
   * Obtain a shifted position for the Placeable 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 shifted target coordinates
   * @private
   */
  _getShiftedPosition(dx, dy) {
    let [x, y] = canvas.grid.grid.shiftPosition(this.data.x, this.data.y, dx, dy);
    return {x, y};
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /**
   * Activate interactivity for the Placeable Object
   */
  activateListeners() {
    const mgr = this._createInteractionManager();
    this.mouseInteractionManager = mgr.activate();
  }

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

  /**
   * Create a standard MouseInteractionManager for the PlaceableObject
   * @private
   */
  _createInteractionManager() {

    // Handle permissions to perform various actions
    const permissions = {
      hoverIn: this._canHover,
      hoverOut: this._canHover,
      clickLeft: this._canControl,
      clickLeft2: this._canView,
      clickRight: this._canHUD,
      clickRight2: this._canConfigure,
      dragStart: this._canDrag
    };

    // Define callback functions for each workflow step
    const callbacks = {
      hoverIn: this._onHoverIn,
      hoverOut: this._onHoverOut,
      clickLeft: this._onClickLeft,
      clickLeft2: this._onClickLeft2,
      clickRight: this._onClickRight,
      clickRight2: this._onClickRight2,
      dragLeftStart: this._onDragLeftStart,
      dragLeftMove: this._onDragLeftMove,
      dragLeftDrop: this._onDragLeftDrop,
      dragLeftCancel: this._onDragLeftCancel,
      dragRightStart: null,
      dragRightMove: null,
      dragRightDrop: null,
      dragRightCancel: null
    };

    // Define options
    const options = {
      target: this.controlIcon ? "controlIcon" : null
    };

    // Create the interaction manager
    return new MouseInteractionManager(this, canvas.stage, permissions, callbacks, options);
  }

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

  /**
   * Actions that should be taken for this Placeable Object when a mouseover event occurs
   * @param {PIXI.interaction.InteractionEvent} event
   * @param {boolean} hoverOutOthers
   * @private
   */
  _onHoverIn(event, {hoverOutOthers=true}={}) {
    if ( this._hover === true ) return false;
    if ( this.data.locked ) return false;
    const layer = this.layer;

    // Update the hover state of all objects in the layer
    if ( hoverOutOthers ) {
      layer.placeables.forEach(o => {
        if ( o !== this ) o._onHoverOut(event);
      });
    }
    this._hover = true;
    layer._hover = this;

    // Refresh the object display
    this.refresh();
    Hooks.callAll("hover"+this.constructor.name, this, this._hover);
  }

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

  /**
   * Actions that should be taken for this Placeable Object when a mouseout event occurs
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onHoverOut(event) {
    if ( this._hover !== true ) return false;
    if ( this.data.locked ) return false;
    const layer = this.layer;
    this._hover = false;
    layer._hover = null;
    this.refresh();
    Hooks.callAll("hover"+this.constructor.name, this, this._hover);
  }

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

  /**
   * Callback actions which occur on a single left-click event to assume control of the object
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onClickLeft(event) {
    const hud = this.layer.hud;
    if ( hud ) hud.clear();

    // Add or remove the Placeable Object from the currently controlled set
    const oe = event.data.originalEvent;
    if ( this._controlled ) {
      if ( oe.shiftKey ) return this.release();
    } else {
      return this.control({releaseOthers: !oe.shiftKey});
    }
  }

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

  /**
   * Callback actions which occur on a double left-click event to activate
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onClickLeft2(event) {
    const sheet = this.sheet;
    if ( sheet ) sheet.render(true);
  }

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

  /**
   * Callback actions which occur on a single right-click event to configure properties of the object
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onClickRight(event) {
    const hud = this.layer.hud;
    if ( hud ) {
      this.control({releaseOthers: true});
      if ( hud.object === this) hud.clear();
      else hud.bind(this);
    }
  }

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

  /**
   * Callback actions which occur on a double right-click event to configure properties of the object
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onClickRight2(event) {
    const sheet = this.sheet;
    if ( sheet ) sheet.render(true);
  }

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

  /**
   * Callback actions which occur when a mouse-drag action is first begun.
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onDragLeftStart(event) {

    // Identify and clone every drag target
    const targets = this.layer.options.controllableObjects ? this.layer.controlled : [this];
    const clones = [];
    for ( let o of targets ) {
      if ( o.data.locked ) continue;
      o.data.locked = true;

      // Clone the object
      const c = o.clone();
      clones.push(c);

      // Draw the clone
      c.draw().then(c => {
        o.alpha = 0.4;
        c.alpha = 0.8;
        c.visible = true;
        this.layer.preview.addChild(c);
      });
    }
    event.data.clones = clones;
  }

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

  /**
   * Callback actions which occur on a mouse-move operation.
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onDragLeftMove(event) {
    const {clones, destination, origin, originalEvent} = event.data;

    // Pan the canvas if the drag event approaches the edge
    canvas._onDragCanvasPan(originalEvent);

    // Determine dragged distance
    const dx = destination.x - origin.x;
    const dy = destination.y - origin.y;

    // Update the position of each clone
    for ( let c of clones || [] ) {
      c.data.x = c._original.data.x + dx;
      c.data.y = c._original.data.y + dy;
      c.refresh();
    }
  }

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

  /**
   * Callback actions which occur on a mouse-move operation.
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onDragLeftDrop(event) {
    const clones = event.data.clones || [];

    // Ensure the destination is within bounds
    const dest = event.data.destination;
    if ( !canvas.grid.hitArea.contains(dest.x, dest.y) ) return false;

    // Compute the final dropped positions
    const updates = clones.map(c => {
      let dest = {x: c.data.x, y: c.data.y};
      if ( !event.data.originalEvent.shiftKey ) {
        dest = canvas.grid.getSnappedPosition(c.data.x, c.data.y, this.layer.options.gridPrecision);
      }
      return {_id: c._original.id, x: dest.x, y: dest.y, rotation: c.data.rotation};
    });
    return canvas.scene.updateEmbeddedEntity(this.constructor.name, updates);
  }

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

  /**
   * Callback actions which occur on a mouse-move operation.
   * @param {PIXI.interaction.InteractionEvent} event
   * @private
   */
  _onDragLeftCancel(event) {
    for ( let c of this.layer.preview.children ) {
      const o = c._original;
      if ( o ) {
        o.data.locked = false;
        o.alpha = 1.0;
      }
    }
    this.layer.preview.removeChildren();
  }
}

/**
 * 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,
      objectClass: null,
      sheetClass: null
    }
  }

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

  /**
   * 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() {
    return Scene.config.embeddedEntities[this.placeableClass.name];
  }

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

  /**
   * Define a Container implementation used to render placeable objects contained in this layer
   * @static
   * @type {PIXI.Container}
   */
  static get placeableClass() {
    return this.layerOptions.objectClass;
  }

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

  /**
   * 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 = {};
    }

    // Clear the HUD
    if ( this.hud ) this.hud.clear();

    // Destroy the layer children
    return super.tearDown();
  }

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

  /**
   * Draw the PlaceablesLayer and draw each PlaceableObject which exists within the layer.
   * Resolve a Promise once all objects have finished drawing.
   */
  async draw() {
    await super.draw();

    // Create objects container which can be sorted
    this.objects = this.addChild(new PIXI.Container());
    this.objects.sortableChildren = true;

    // Create preview container which is always above objects
    this.preview = this.addChild(new PIXI.Container());

    // Create and draw objects
    const promises = canvas.scene.data[this.constructor.dataArray].map(data => {
      return this.createObject(data);
    });

    // Wait for all objects to draw and return
    await Promise.all(promises);
    return this;
  }

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

  /**
   * Draw a single placeable object
   * @return {Promise<PlaceableObject>}
   */
  createObject(data) {
    const obj = new this.constructor.placeableClass(data, canvas.scene);
    obj.zIndex = data.z || 0;
    this.objects.addChild(obj);
    return obj.draw();
  }

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

  /**
   * Override the activation behavior of the PlaceablesLayer.
   * While active, ambient sound previews are displayed.
   */
  activate() {
    const wasActive = this._active;
    super.activate();
    this.objects.visible = true;
    if ( !wasActive ) {
      this.placeables.forEach(l => l.refresh());
    }
  }

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

  /**
   * Override the deactivation behavior of the PlaceablesLayer.
   * When inactive, ambient sound previews are hidden from view.
   */
  deactivate() {
    const wasActive = this._active;
    super.deactivate();
    this.objects.visible = false;
    if ( wasActive ) {
      this.releaseAll();
      this.placeables.forEach(l => l.refresh());
      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 objects
    const rotatable = this.controlled.filter(o => !o.data.locked);
    if ( !rotatable.length ) return;

    // Conceal any active HUD
    const hud = this.hud;
    if ( hud ) hud.clear();

    // Update the objects with a single operation
    const updateData = rotatable.map(o => {
      return {_id: o.id, rotation: o._updateRotation({angle, delta, snap})}
    });
    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 ( !dx && !dy ) return;
    if ( game.paused && !game.user.isGM ) {
      return ui.notifications.warn(game.i18n.localize("GAME.PausedWarning"));
    }

    // 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);
  }

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

  /**
   * 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 with deletion
    if ( event.type === "create" ) {
      return this.deleteMany(event.data.map(d => d._id), {isUndo: true});
    }

    // Undo updates with update
    else if ( event.type === "update" ) {
      return this.updateMany(event.data, {isUndo: true});
    }

    // Undo deletion with creation
    else if ( event.type === "delete" ) {
      return cls.create(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.createEmbeddedEntity(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.updateEmbeddedEntity(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.deleteEmbeddedEntity(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
   */
  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
    await canvas.scene.createEmbeddedEntity(cls.name, toCreate);
    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={}}={}) {
    if ( !this.options.controllableObjects ) return false;
    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                */
  /* -------------------------------------------- */

  /**
   * Handle left mouse-click events which originate from the Canvas stage and are dispatched to this Layer.
   * @see {Canvas#_onClickLeft}
   */
  _onClickLeft(event) {
    if ( this.hud ) this.hud.clear();
    this.releaseAll();
  }

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

  /**
   * Handle double left-click events which originate from the Canvas stage and are dispatched to this Layer.
   * @see {Canvas#_onClickLeft2}
   */
  _onClickLeft2(event) {}

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

  /**
   * Start a left-click drag workflow originating from the Canvas stage.
   * @see {Canvas#_onDragLeftStart}
   */
  _onDragLeftStart(event) {
    if ( !this.options.canDragCreate ) {
      delete event.data.createState;
      return;
    }
    event.data.createState = 0;

    // Clear any existing preview
    if ( this.preview ) this.preview.removeChildren();
    event.data.preview = null;

    // Snap the origin to the grid
    const {origin, originalEvent} = event.data;
    if ( this.options.snapToGrid && !originalEvent.isShift ) {
      event.data.origin = canvas.grid.getSnappedPosition(origin.x, origin.y, this.gridPrecision);
    }

    // Register the ongoing creation
    event.data.createState = 1;
  }

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

  /**
   * Continue a left-click drag workflow originating from the Canvas stage.
   * @see {Canvas#_onDragLeftMove}
   */
  _onDragLeftMove(event) {
    const preview = event.data.preview;
    if ( !preview ) return;
    if ( preview.parent === null ) { // In theory this should never happen, but rarely does
      this.preview.addChild(preview);
    }
  }

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

  /**
   * Conclude a left-click drag workflow originating from the Canvas stage.
   * @see {Canvas#_onDragLeftDrop}
   */
  _onDragLeftDrop(event) {
    const object = event.data.preview;
    if ( object ) {
      this.constructor.placeableClass.create(object.data);
    }
  }

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

  /**
   * Cancel a left-click drag workflow originating from the Canvas stage.
   * @see {Canvas#_onDragLeftDrop}
   */
  _onDragLeftCancel(event) {
    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;
        }
      }
      this.preview.removeChildren();
    }
  }

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

  /**
   * Handle right mouse-click events which originate from the Canvas stage and are dispatched to this Layer.
   * @see {Canvas#_onClickRight}
   */
  _onClickRight(event) {
    if ( this.hud ) this.hud.clear();
  }

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

  /**
   * 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
   */
  _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);

    // Case 1 - rotate preview objects
    if ( this.preview.children.length ) {
      for ( let p of this.preview.children ) {
        p.data.rotation = p._updateRotation({delta, snap});
        p.refresh();
      }
    }

    // Case 2 - Update multiple objects
    else return this.rotateMany({delta, snap});
  }

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

  /**
   * 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;

    // Delete all controlled objects which are not currently locked
    if ( this.options.controllableObjects ) {
      ids = this.controlled.reduce((ids, o) => {
        if ( o.data.locked ) return ids;
        ids.push(o.id);
        return ids;
      }, []);
    }

    // Otherwise delete the hovered object
    else ids = this._hover ? [this._hover.id] : [];

    // Execute delete operations
    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);
  }

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

  /**
   * Set the position of the context menu, taking into consideration whether the menu should expand upward or downward
   * @private
   */
  _setPosition(html, target) {
    const targetRect = target[0].getBoundingClientRect();
    const parentRect = target[0].parentElement.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
    const bottomHalf = targetRect.bottom > (window.innerHeight / 2);
    this._expandUp = bottomHalf && ((parentRect.bottom - targetRect.bottom) < contextRect.height);

    // 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(this._onClickButton.bind(this));
    $(document).on('keydown.chooseDefault', this._onKeyDown.bind(this));
  }

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

  /**
   * Handle a left-mouse click on one of the dialog choice buttons
   * @param {MouseEvent} event    The left-mouse click event
   * @private
   */
  _onClickButton(event) {
    const id = event.currentTarget.dataset.button;
    const button = this.data.buttons[id];
    this.submit(button);
  }

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

  /**
   * Handle a keydown event while the dialog is active
   * @param {KeyboardEvent} event   The keydown event
   * @private
   */
  _onKeyDown(event) {

    // Close dialog
    if ( event.key === "Escape" ) {
      event.preventDefault();
      event.stopPropagation();
      return this.close();
    }

    // Confirm default choice
    if ( (event.key === "Enter") && this.data.default ) {
      event.preventDefault();
      event.stopPropagation();
      const defaultChoice = this.data.buttons[this.data.default];
      return this.submit(defaultChoice);
    }
  }

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

  /**
   * Submit the Dialog by selecting one of its buttons
   * @param {Object} button     The configuration of the chosen button
   * @private
   */
  submit(button) {
    try {
      // TODO: Backwards compatibility for 0.6.0 - in future I should pass the entire HTML in
      const innerHTML = this.element.find(".window-content").children();
      if (button.callback) button.callback(innerHTML);
      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
   *
   * @example
   * let d = Dialog.confirm({
   *  title: "A Yes or No Question",
   *  content: "<p>Choose wisely.</p>",
   *  yes: () => console.log("You chose ... wisely"),
   *  no: () => console.log("You chose ... poorly"),
   *  defaultYes: false
   * });
   */
  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);
    });
  }
}

/**
 * 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;
    this.resizable = resizable || false;

    /**
     * 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 = {};

    // Activate interactivity
    this.activateListeners();
  }

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

  /**
   * Activate event handling for a Draggable application
   * Attach handlers for floating, dragging, and resizing
   */
  activateListeners() {

    // Float to top
    this.handlers["click"] = ["mousedown", this._onClickFloatTop.bind(this), {capture: true, passive: true}];
    this.element.addEventListener(...this.handlers.click);

    // Drag 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];
    this.handle.addEventListener(...this.handlers.dragDown);
    this.handle.classList.add("draggable");

    // Resize handlers
    if ( !this.resizable ) return;
    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 left-mouse down events to float the window to the top of the rendering stack
   * @param {MouseEvent} event      The mousedown event on the application element
   * @private
   */
  _onClickFloatTop(event) {
    let z = Number(window.document.defaultView.getComputedStyle(this.element).zIndex);
    if ( z <= _maxZ ) {
      this.element.style.zIndex = Math.min(++_maxZ, 9999);
    }
  }

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

  /**
   * Handle the initial mouse click which activates dragging behavior for the application
   * @private
   */
  _onDragMouseDown(event) {
    event.preventDefault();

    // 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();

    // 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);
  }
}
/**
 * A controller class for managing drag and drop workflows within an Application instance.
 * The controller manages the following actions: dragstart, dragover, drop
 * @see {@link Application}
 *
 * @param {string} dragSelector     The CSS selector used to target draggable elements.
 * @param {string} dropSelector     The CSS selector used to target viable drop targets.
 * @param {Object<string,Function>} permissions    An object of permission test functions for each action
 * @param {Object<string,Function>} callbacks      An object of callback functions for each action
 *
 * @example
 * const dragDrop = new DragDrop({
 *   dragSelector: ".item",
 *   dropSelector: ".items",
 *   permissions: { dragstart: this._canDragStart.bind(this), drop: this._canDragDrop.bind(this) }
 *   callbacks: { dragstart: this._onDragStart.bind(this), drop: this._onDragDrop.bind(this) }
 * });
 * dragDrop.bind(html);
 */
class DragDrop {
  constructor({dragSelector=null, dropSelector=null, permissions={}, callbacks={}} = {}) {

    /**
     * The HTML selector which identifies draggable elements
     * @type {string}
     */
    this.dragSelector = dragSelector;

    /**
     * The HTML selector which identifies drop targets
     * @type {string}
     */
    this.dropSelector = dropSelector;

    /**
     * A set of permission checking functions for each action of the Drag and Drop workflow
     * @type {Object}
     */
    this.permissions = permissions;

    /**
     * A set of callback functions for each action of the Drag and Drop workflow
     * @type {Object}
     */
    this.callbacks = callbacks;
  }

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

  /**
   * Bind the DragDrop controller to an HTML application
   * @param {HTMLElement} html    The HTML element to which the handler is bound
   */
  bind(html) {

    // Identify and activate draggable targets
    if ( this.can("dragstart", this.dragSelector) ) {
      const draggables = html.querySelectorAll(this.dragSelector);
      for (let el of draggables) {
        el.setAttribute("draggable", true);
        el.ondragstart = this._handleDragStart.bind(this);
      }
    }

    // Identify and activate drop targets
    if ( this.can("dragdrop", this.dropSelector) ) {
      const droppables = this.dropSelector ? html.querySelectorAll(this.dropSelector) : [html];
      for ( let el of droppables ) {
        el.ondragover = this._handleDragOver.bind(this);
        el.ondrop = this._handleDrop.bind(this);
      }
    }
    return this;
  }

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

  /**
   * Execute a callback function associated with a certain action in the workflow
   * @param {DragEvent} event   The drag event being handled
   * @param {string} action     The action being attempted
   */
  callback(event, action) {
    const fn = this.callbacks[action];
    if ( fn instanceof Function ) return fn(event);
  }

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

  /**
   * Test whether the current user has permission to perform a step of the workflow
   * @param {string} action     The action being attempted
   * @param {string} selector   The selector being targeted
   * @return {boolean}          Can the action be performed?
   */
  can(action, selector) {
    const fn = this.permissions[action];
    if ( fn instanceof Function ) return fn(selector);
    return true;
  }

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

  /**
   * Handle the start of a drag workflow
   * @param {DragEvent} event   The drag event being handled
   * @private
   */
  _handleDragStart(event) {
    event.stopPropagation();
    return this.callback(event, "dragstart");
  }

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

  /**
   * Handle a dragged element over a droppable target
   * @param {DragEvent} event   The drag event being handled
   * @private
   */
  _handleDragOver(event) {
    event.preventDefault();
    this.callback(event, "dragover");
    return false;
  }

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

  /**
   * Handle a dragged element dropped on a droppable target
   * @param {DragEvent} event   The drag event being handled
   * @private
   */
  _handleDrop(event) {
    event.preventDefault();
    return this.callback(event, "drop");
  }

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

  static createDragImage(img, width, height) {
    let div = document.getElementById("drag-preview");

    // Create the drag preview div
    if ( !div ) {
      div = document.createElement("div");
      div.setAttribute("id", "drag-preview");
      const img = document.createElement("img");
      img.classList.add("noborder");
      div.appendChild(img);
      document.body.appendChild(div);
    }

    // Add the preview image
    const i = div.children[0];
    i.src = img.src;
    i.width = width;
    i.height = height;
    return div;
  }
}
/**
 * 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.map(c => ROUTE_PREFIX ? `/${ROUTE_PREFIX}${c}` : c).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);
  }

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

  /**
   * Safely decode an HTML string, removing invalid tags and converting entities back to unicode characters.
   * @param {string} html     The original encoded HTML string
   * @return {string}         The decoded unicode string
   */
  static decodeHTML(html) {
    const txt = this._decoder;
    txt.innerHTML = html;
    return txt.value;
  }

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

  /**
   * 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?
   * @param {Object} rollData       The data object providing context for inline rolls
   * @return {String}               The enriched HTML content
   */
  static enrichHTML(content, {secrets=false, entities=true, links=true, rolls=true, rollData=null}={}) {
    content = String(content);

    // 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(content.matchAll(rgx));
      if ( matches.length ) this._preloadCompendiumIndices(matches);

      // Replace content links
      content = content.replace(rgx, this._replaceContentLinks.bind(this));
    }

    // Replace hyperlinks
    if ( links ) {
      // Match hyperlinks which are not immediately preceded by a quote
      const hyperlinkRgx = /(^|\s)((?:https?:\/\/)(?:www\.)?(?:[^\s<]+))/gi;
      content = content.replace(hyperlinkRgx, this._replaceHyperlinks);
    }

    // Process inline dice rolls
    if ( rolls ) {
      const inlineRollRgx = /\[\[(\/[a-zA-Z]+\s)?([^\]]+)\]\]/gi;
      content = content.replace(inlineRollRgx, (...args) => this._replaceInlineRolls(...args, rollData));
    }

    // Create the HTML element
    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));
    }

    // Return the enriched HTML
    return html.innerHTML;
  };

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

  /**
   * Preview an HTML fragment by constructing a substring of a given length from its inner text.
   * @param {string} content    The raw HTML to preview
   * @param {number} length     The desired length
   * @return {string}           The previewed HTML
   */
  static previewHTML(content, length=250) {
    const div = document.createElement("div");
    div.innerHTML = content;
    const short = div.innerText.slice(0, length);
    return div.innerText.length > length ? short.replace(/[\s]+[^\s]+$/, " ...") : short;
  }

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

  /**
   * 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.get(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.get(`${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 = 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, start, url) {
    let href = /^https?:\/\//.test(url) ? url : `http://${url}`;
    return `${start}<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
   * @param {Object} rollData   The data object providing context for inline rolls
   * @return {string}           The replaced match
   */
  static _replaceInlineRolls(match, command, formula, ...args) {
    const isDeferred = !!command;
    const rollData = args.pop();
    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, rollData).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;

    // Target 1 - Compendium Link
    if ( a.dataset.pack ) {
      const pack = game.packs.get(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;
    }

    // Target 2 - World Entity Link
    else {
      const cls = CONFIG[a.dataset.entity].entityClass;
      entity = cls.collection.get(a.dataset.id);
      if ( entity.entity === "Scene" && entity.journal ) entity = entity.journal;
      if ( !entity.hasPerm(game.user, "LIMITED") ) {
        return ui.notifications.warn(`You do not have permission to view this ${entity.entity} sheet.`);
      }
    }
    if ( !entity ) return;

    // Action 1 - Execute an Action
    if ( entity.entity === "Macro" ) {
      if ( !entity.hasPerm(game.user, "LIMITED") ) {
        return ui.notifications.warn(`You do not have permission to use this ${entity.entity}.`);
      }
      return entity.execute();
    }

    // Action 2 - Render the Entity sheet
    return 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 cls = CONFIG.ChatMessage.entityClass;

      // Get the "speaker" for the inline roll
      const actor = cls.getSpeakerActor(cls.getSpeaker());
      const rollData = actor ? actor.getRollData() : {};

      // Execute the roll
      const roll = new Roll(a.dataset.formula, rollData).roll();
      return roll.toMessage({flavor: a.dataset.flavor}, {rollMode: a.dataset.mode});
    }
  }

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

  /**
   * 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.get(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.get(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);
    }
  }
}

// Singleton decoder area
TextEditor._decoder = document.createElement('textarea');

// Global Export
window.TextEditor = TextEditor;

/**
 * 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: "",
        label: "User Data",
        icon: "fas fa-database"
      },
      public: {
        target: "",
        label: "Core Data",
        icon: "fas fa-server"
      },
      s3: {
        buckets: [],
        bucket: "",
        target: "",
        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 = options.activeSource || "data";

    /**
     * The latest set of results browsed from the server
     * @type {Object}
     */
    this.results = {};

    /**
     * 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 display mode of the FilePicker UI
     * @type {string}
     */
    this.displayMode = options.displayMode || "list";

    /**
     * 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;

    /**
     * A filtering timeout function reference used to rate limit string filtering operations
     * @type {number|null}
     */
    this._filterTimeout = null;
  }

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

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    template: "templates/apps/filepicker.html",
      classes: ["filepicker"],
      width: 520,
      tabs: [{navSelector: ".tabs"}],
      dragDrop: [{dragSelector: ".file", dropSelector: ".filepicker-body"}],
      tileSize: false
    });
  }

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

  /**
   * 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;
    }, []);
  }

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

  /**
   * Parse a s3 key to learn the bucket and the key prefix used for the request
   * @param {string} key    A fully qualified key name or prefix path
   * @return {{bucket: string, keyPrefix: string}}
   * @private
   */
  static parseS3URL(key) {
    try {
      const url = new URL(key);
      return {
        bucket: url.host.split(".").shift(),
        keyPrefix: url.pathname.slice(1)
      };
    } catch(err) {
      return {
        bucket: null,
        keyPrefix: ""
      }
    }
  }

  /* -------------------------------------------- */
  /*  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;
    return game.isAdmin || (game.user && game.user.can("FILES_UPLOAD"));
  }

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

  /**
   * 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) {
    const result = this.result;
    const source = this.source;
    let target = decodeURIComponent(source.target);
    const isS3 = this.activeSource === "s3";

    // Sort directories alphabetically and store their paths
    let dirs = result.dirs.map(d => { return {
      name: decodeURIComponent(d.split("/").pop()),
      path: d,
      private: result.private || result.privateDirs.includes(d)
    }});
    dirs = dirs.sort((a, b) => a.name.localeCompare(b.name));

    // Sort files alphabetically and store their client URLs
    let files = result.files.map(f => {
      let img = f;
      if ( VideoHelper.hasVideoExtension(f) ) img = "icons/svg/video.svg";
      else if ( AudioHelper.hasAudioExtension(f) ) img = "icons/svg/sound.svg";
      return {
        name: decodeURIComponent(f.split("/").pop()),
        url: f,
        img: img
      }
    });
    files = files.sort((a, b) => a.name.localeCompare(b.name));

    // Return rendering data
    return {
      bucket: isS3 ? source.bucket : null,
      canGoBack: this.activeSource !== "",
      canUpload: this.canUpload,
      canSelect: !this.options.tileSize,
      cssClass: [this.displayMode, result.private ? "private": "public"].join(" "),
      dirs: dirs,
      displayMode: this.displayMode,
      extensions: this.extensions,
      files: files,
      isS3: isS3,
      noResults: dirs.length + files.length === 0,
      request: this.request,
      source: source,
      sources: this.sources,
      target: target,
      tileSize: this.options.tileSize ? (FilePicker.LAST_TILE_SIZE || canvas.dimensions.size) : null,
      user: game.user
    }
  }

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

  /**
   * 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={}) {

    // If the user does not have permission to browse, do not proceed
    if ( game.world && !game.user.can("FILES_BROWSE") ) return;

    // Configure browsing parameters
    target = typeof target === "string" ? target : this.target;
    const source = this.activeSource;

    // Configure browsing options
    const isS3 = source === "s3";
    options = mergeObject({
      extensions: this.extensions,
      wildcard: false,
      bucket: isS3 ? this.source.bucket : null
    }, options);

    // Initial population of S3 bucket list
    if ( isS3 && !this.source.buckets.length ) {
      const buckets = await this.constructor.browse("s3", "");
      this.source.buckets = buckets.dirs;
      options.bucket = buckets.dirs[0];
      return this.browse(target, options);
    }

    // Avoid browsing certain paths
    if ( target.startsWith("/") ) target = target.slice(1);
    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);
    });

    // Populate browser content
    this.result = result;
    this.source.target = result.target;
    if ( isS3 ) this.source.bucket = result.bucket;
    this.constructor.LAST_BROWSED_DIRECTORY = result.target;
    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={}) {
    const data = {action: "browseFiles", storage: source, target: target};
    return this._manageFiles(data, options);
  }

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

  /**
   * Configure metadata settings regarding a certain file system path
   * @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 which modify the request
   * @return {Promise<Object>}
   */
  static async configurePath(source, target, options={}) {
    const data = {action: "configurePath", storage: source, target: target};
    return this._manageFiles(data, options);
  }

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

  /**
   * Create a subdirectory within a given source. The requested subdirectory path must not already exist.
   * @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 which modify the request
   * @return {Promise<Object>}
   */
  static async createDirectory(source, target, options={}) {
    const data = {action: "createDirectory", storage: source, target: target};
    return this._manageFiles(data, options);
  }

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

  /**
   * General dispatcher method to submit file management commands to the server
   * @private
   */
  static async _manageFiles(data, options) {
    return new Promise((resolve, reject) => {
      game.socket.emit("manageFiles", data, options, result => {
        if (result.error) return reject(result.error);
        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<Object>}  The response object
   */
  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 request = await fetch(this.uploadURL, {method: "POST", body: fd});
    if ( request.status === 413 ) {
      return ui.notifications.error(game.i18n.localize("FILES.ErrorTooLarge"));
    } else if ( request.status !== 200 ) {
      return ui.notifications.error(game.i18n.localize("FILES.ErrorSomethingWrong"));
    }

    // Retrieve the server response
    const response = await request.json();
    if (response.error) {
      ui.notifications.error(response.error);
      return false;
    } else if (response.message) {
      if ( /^(modules|systems)/.test(response.path) ) {
        ui.notifications.warn(game.i18n.localize("FILES.WarnUploadModules"))
      }
      ui.notifications.info(response.message);
    }
    return response;
  }

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

  /**
   * Additional actions performed when the file-picker UI is rendered
   */
  render(force, options) {
    if ( game.world && !game.user.can("FILES_BROWSE") ) return;
    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) {
    this._tabs[0].active = this.activeSource;
	  super.activateListeners(html);
	  const header = html.find("header.filepicker-header");
	  const form = html[0];

    // Change the directory
    const target = header.find('input[name="target"]');
    target.on("keydown", this._onRequestTarget.bind(this));
    target[0].focus();

    // Filter results
    form.filter.addEventListener("keyup", this._onFilterResults.bind(this));
    form.filter.addEventListener("keydown", ev => {
      if ( ev.key === "Enter" ) event.preventDefault();
    });

    // Header Control Buttons
    html.find(".current-dir button").click(this._onClickDirectoryControl.bind(this));

    // Change the S3 bucket
    html.find('select[name="bucket"]').change(this._onChangeBucket.bind(this));

    // Activate display mode controls
    const modes = html.find(".display-modes");
    modes.on("click", ".display-mode", this._onChangeDisplayMode.bind(this));
    for ( let li of modes[0].children ) {
      li.classList.toggle("active", li.dataset.mode === this.displayMode);
    }

    // Upload new file
    if ( this.canUpload ) form.upload.onchange = ev => this._onUpload(ev);

    // Directory-level actions
    html.find(".directory").on("click", "li", this._onPick.bind(this));

	  // Flag the current pick
	  let li = form.querySelector(`.file[data-path="${this.request}"]`);
	  if ( li ) li.classList.add("picked");

	  // Form submission
    form.onsubmit = ev => this._onSubmit(ev);

    // Intersection Observer to lazy-load images
    const files = html.find(".files-list");
    const observer = new IntersectionObserver(this._onLazyLoadImages.bind(this), {root: files[0]});
    files.find("li.file").each((i, li) => observer.observe(li));
  }

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

  /**
   * Handle a click event to change the display mode of the File Picker
   * @param {MouseEvent} event    The triggering click event
   * @private
   */
  _onChangeDisplayMode(event) {
    event.preventDefault();
    const a = event.currentTarget;
    if ( !FilePicker.DISPLAY_MODES.includes(a.dataset.mode) ) {
      throw new Error("Invalid display mode requested");
    }
    if ( a.dataset.mode === this.displayMode ) return;
    this.displayMode = a.dataset.mode;
    this.render();
  }

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

  /** @override */
  _onChangeTab(event, tabs, active) {
    this.activeSource = active;
    this.browse(this.source.target);
  }

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

  /** @override */
  _canDragStart(selector) {
    return (game.user && game.user.isGM) && (canvas && canvas.tiles._active);
  }

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

  /** @override */
  _canDragDrop(selector) {
    return this.canUpload;
  }

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

  /** @override */
  _onDragStart(event) {
    const li = event.currentTarget;

    // Get the tile size ratio
    const tileSize = parseInt(li.closest("form").tileSize.value) || canvas.dimensions.size;
    FilePicker.LAST_TILE_SIZE = tileSize;
    const ratio = canvas.dimensions.size / tileSize;

    // Set drag data
    const dragData = {
      type: "Tile",
      img: li.dataset.path,
      tileSize: tileSize
    };
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));

    // Create the drag preview for the image
    const img = li.querySelector("img");
    const w = img.naturalWidth * ratio * canvas.stage.scale.x;
    const h = img.naturalHeight * ratio * canvas.stage.scale.y;
    const preview = DragDrop.createDragImage(img, w, h);
    event.dataTransfer.setDragImage(preview, w/2, h/2);
  }

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

  /** @override */
  async _onDrop(event) {
    if ( this.activeSource === "public" ) return;
    const form = event.currentTarget.closest("form");
    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;
      }
      const response = await this.constructor.upload(this.activeSource, target, upload, {
        bucket: form.bucket ? form.bucket.value : null
      });
      this.request = response.path;
    }

    // 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 requests from the IntersectionObserver to lazily load an image file
   * @private
   */
  _onLazyLoadImages(...args) {
    return SidebarTab.prototype._onLazyLoadImage.call(this, ...args);
  }

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

  /**
   * 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);
    for ( let l of li.parentElement.children ) {
      l.classList.toggle("picked", l === li);
    }
    li.closest("form").file.value = li.dataset.path;
  }

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

  /**
   * Handle backwards navigation of the fol6der structure
   * @private
   */
  _onClickDirectoryControl(event) {
    event.preventDefault();
    const button = event.currentTarget;
    const action = button.dataset.action;
    switch(action) {
      case "back":
        let target = this.target.split("/");
        target.pop();
        return this.browse(target.join("/"));
      case "mkdir":
        return this._createDirectoryDialog(this.source);
      case "toggle-privacy":
        let isPrivate = !this.result.private;
        const data = {private: isPrivate, bucket: this.result.bucket};
        return this.constructor.configurePath(this.activeSource, this.target, data).then(r => {
          this.result.private = r.private;
          this.render();
        })
    }
  }

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

  /**
   * Present the user with a dialog to create a subdirectory within their currently browsed file storate location.
   * @private
   */
  _createDirectoryDialog(source) {
    const form = `<form><div class="form-group">
    <label>Directory Name</label>
    <input type="text" name="dirname" placeholder="directory-name" required/>
    </div></form>`;
    return Dialog.confirm({
      title: "Create Subfolder",
      content: form,
      yes: html => {
        const dirname = html.find("input")[0].value;
        const path = [source.target, dirname].filterJoin("/");
        this.constructor.createDirectory(this.activeSource, path, {bucket: source.bucket}).then(() => this.browse());
      }
    })
  }

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

  /**
   * Handle changes to the bucket selector
   * @private
   */
  _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('.directory').each((i, ol) => {
        for ( let li of ol.children ) {
          li.style.display = !query.test(li.dataset.path) ? "none" : "";
        }
      })
    };

    // Filter if we are done entering keys
    let rgx = RegExp.escape(encodeURIComponent(input.value));
    let query = new RegExp(rgx, "i");
    if ( this._filterTimeout ) {
      clearTimeout(this._filterTimeout);
      this._filterTimeout = null;
    }
    this._filterTimeout = setTimeout(() => filter(query), 100);
  }

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

  /**
   * 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;
    const options = { bucket: form.bucket ? form.bucket.value : null };
    const response = await this.constructor.upload(this.activeSource, target, upload, options);

    // Flag the uploaded file as the new request
    this.request = response.path;
    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 = "";

FilePicker.LAST_TILE_SIZE = null;

/**
 * Enumerate the allowed FilePicker display modes
 * @type {Object<string,number>}
 */
FilePicker.DISPLAY_MODES = ["list", "thumbs", "tiles", "images"];


/**
 * 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 = [];

    // Initialize any pending messages
    this.initialize();
  }

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

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
      popOut: false,
      id: "notifications",
      template: "templates/hud/notifications.html"
    });
  }

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

  /**
   * Initialize the Notifications system by displaying any system-generated messages which were passed from the server.
   */
  initialize() {
    if ( !MESSAGES ) return;
    for ( let m of MESSAGES ) {
      this.notify(game.i18n.localize(m.message), m.type, m.options);
    }
  }

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

  /** @override */
  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
   * @param {boolean} permanent   Whether the notification should be permanently displayed unless otherwise dismissed
   */
  notify(message, type="info", {permanent=false}={}) {

    // Construct notification data
    let n = {
      message: message,
      type: ["info", "warning", "error"].includes(type) ? type : "info",
      timestamp: new Date().getTime(),
      permanent: permanent
    };
    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
   * @param {Object} options    Notification options passed to the notify function
   */
	info(message, options) {
	  this.notify(message, "info", options);
  }

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

  /**
   * Display a notification with the "warning" type
   * @param {string} message    The content of the notification message
   * @param {Object} options    Notification options passed to the notify function
   */
  warn(message, options) {
	  this.notify(message, "warning", options);
  }

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

  /**
   * Display a notification with the "error" type
   * @param {string} message    The content of the notification message
   * @param {Object} options    Notification options passed to the notify function
   */
  error(message, options) {
	  this.notify(message, "error", options);
  }

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

  /**
   * 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 cls = ["notification", next.type, next.permanent ? "permanent": null].filterJoin(" ");
    const li = $(`<li class="${cls}">${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
	  if ( !next.permanent ) 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.
 * @deprecated in 0.5.2 will be removed in 0.6.2
 */
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.2.`);

    /**
     * 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);
    if ( !this._nav ) return;

    // 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() {

    /**
     * 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();
  }

  /* -------------------------------------------- */
  /*  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);

    // If server URL changed, then we need to generate a new room ID to avoid leaking
    // the room ID when switching servers. 
    if (key === "server.url" || key === "server.type")
        setProperty(this._worldSettings, "server.room", randomID(32));
    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];
    }

    // 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",
      dragDrop: [{dragSelector: ".item-list .item", dropSelector: null}]
    });
  }

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

  /**
   * 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;
  }

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

  /** @override */
  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;
  }

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

  /** @override */
  async _render(force=false, options={}) {
    if ( force ) this.token = options.token || null;
    return super._render(force, options);
  }

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

  /** @inheritdoc */
  _getHeaderButtons() {
    let buttons = super._getHeaderButtons();

    // Token Configuration
    const canConfigure = game.user.isGM || (this.actor.owner && game.user.can("TOKEN_CONFIGURE"));
    if (this.options.editable && 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                             */
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);

    // Support Image updates
    if ( this.options.editable ) {
      html.find('img[data-edit]').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) {
    const attr = event.currentTarget.dataset.edit;
    const current = getProperty(this.actor.data, attr);
    new FilePicker({
      type: "image",
      current: current,
      callback: path => {
        event.currentTarget.src = path;
        this._onSubmit(event);
      },
      top: this.position.top + 40,
      left: this.position.left + 10
    }).browse(current);
  }

  /* -------------------------------------------- */
  /*  Drag and Drop                               */
  /* -------------------------------------------- */

  /** @override */
  _canDragStart(selector) {
    return this.options.editable && this.actor.owner;
  }

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

  /** @override */
  _canDragDrop(selector) {
    return this.options.editable && this.actor.owner;
  }

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

  /** @override */
  _onDragStart(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));
  }

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

  /**
   * @deprecated since 0.5.6
   */
  _onDragItemStart(event) {
    return this._onDragStart(event);
  }

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

  /** @override */
  async _onDrop(event) {

    // 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.updateEmbeddedEntity("OwnedItem", updateData);
  }

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

  _getSortSiblings(source) {
    return this.actor.items.filter(i => {
      return (i.data.type === source.data.type) && (i.data._id !== source.data._id);
    });
  }
}
/**
 * Audio/Video Conferencing Configuration Sheet
 * @type {FormApplication}
 */
class AVConfig extends FormApplication {
  constructor(object, options) {
    super(object || game.webrtc, options);
  }

  /** @override */
  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"}]
    });
  }

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

  /** @override */
  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
   */
  async _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
    });
  }

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

  /** @override */
  async getData(options) {
    return {
      settings: game.settings.get("core", Combat.CONFIG_SETTING),
      attributeChoices: this.getAttributeChoices()
    };
  };

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

  /** @override */
  async _updateObject(event, formData) {
    return game.settings.set("core", Combat.CONFIG_SETTING, {
      resource: formData.resource,
      skipDefeated: formData.skipDefeated
    });
  }

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

  /**
   * Get an Array of attribute choices which could be tracked for Actors in the Combat Tracker
   * @return {Promise<Array>}
   */
  getAttributeChoices() {
    const actorData = {};
    for ( let model of Object.values(game.system.model.Actor) ) {
      mergeObject(actorData, model);
    }
    const attributes = TokenConfig.getTrackedAttributes(actorData, []);
    attributes.bar.forEach(a => a.push("value"));
    return TokenConfig.getTrackedAttributeChoices(attributes);
  }
}

/**
 * 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");
  }

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

  /** @override */
  async getData(options) {
    return {
      folder: this.object.data,
      submitText: game.i18n.localize(this.object._id ? "FOLDER.Update" : "FOLDER.Create")
    }
  }

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

  /** @override */
  async _updateObject(event, formData) {
    if ( !formData.parent ) formData.parent = null;
    if ( !this.object._id ) return Folder.create(formData);
    return this.object.update(formData);
  }
}

/**
 * A tool for fine tuning the grid in a Scene
 * @extends {FormApplication}
 */
class GridConfig extends FormApplication {
  constructor(scene, sheet, ...args) {
    super(scene, ...args);

    /**
     * Track the Scene Configuration sheet reference
     * @type {SceneConfig}
     */
    this.sheet = sheet;

    /**
     * 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(options) {
    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);
    if ( !this.object.data.img ) {
      ui.notifications.warn(game.i18n.localize("WARNING.GridConfigNoBG"));
    }
    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;
    canvas.draw();
    await this.sheet.maximize();
    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) || game.keyboard.hasFocus ) 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) {
    if ( event.deltaY === 0 ) return;

    // 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           */
	/* -------------------------------------------- */

  /**
   * Scale the background size relative to the grid size
   * @param {number} delta          The directional change in background size
   * @private
   */
  _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});
  }

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

  /**
   * Scale the grid size relative to the background image.
   * When scaling the grid size in this way, constrain the allowed values between 50px and 300px.
   * @param {number} delta          The grid size in pixels
   * @private
   */
  _scaleGridSize(delta) {
    this.form.grid.value = Math.clamped(parseInt(this.form.grid.value) + delta, 50, 300);
    this._refresh({grid: true});
  }

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

  /**
   * Shift the background image relative to the grid layer
   * @param {number} deltaX         The number of pixels to shift in the x-direction
   * @param {number} deltaY         The number of pixels to shift in the y-direction
   * @private
   */
  _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});
  }

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

  /**
   * Temporarily refresh the display of the BackgroundLayer and GridLayer for the new pending dimensions
   * @param {boolean} background      Refresh the background display?
   * @param {boolean} grid            Refresh the grid display?
   * @private
   */
  _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.max(parseInt(form.grid.value), 50),
      gridDistance: this.object.data.gridDistance,
      shiftX: parseInt(form.shiftX.value),
      shiftY: parseInt(form.shiftY.value)
    });
    canvas.dimensions = d;

    // Update the background
    if ( background && bg ) {
      bg.position.set(d.paddingX - d.shiftX, d.paddingY - d.shiftY);
      bg.width = d.sceneWidth;
      bg.height = d.sceneHeight;
      grid = true;
    }

    // Update the grid layer
    if ( grid ) {
      canvas.grid.tearDown();
      canvas.grid.draw({type: parseInt(form.gridType.value), dimensions: d, gridColor: 0xFF0000, gridAlpha: 1.0});
      canvas.stage.hitArea = new PIXI.Rectangle(0, 0, d.width, d.height);
    }
  }

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

  /** @override */
  _onChangeInput(event) {
    event.preventDefault();
    this._refresh({background: true, grid: true});
  }

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

  /** @override */
  async _updateObject(event, formData) {
    formData.width = Math.round(this._dimensions.sceneWidth);
    formData.height = Math.round(this._dimensions.sceneHeight);
    return this.object.update(formData);
  }
}

/**
 * An Image Popout Application which features a single image in a lightbox style frame.
 * This popout can also be used as a form, allowing the user to edit an image which is being used.
 * Furthermore, this application allows for sharing the display of an image with other connected players.
 * @extends {FormApplication}
 *
 * @example
 * // Construct the Application instance
 * const ip = new ImagePopout("path/to/image.jpg", {
 *   title: "My Featured Image",
 *   shareable: true,
 *   entity: game.actors.getName("My Hero")
 * });
 *
 * // Display the image popout
 * ip.render(true);
 *
 * // Share the image with other connected players
 * ip.share();
 */
class ImagePopout extends FormApplication {
  constructor(src, options={}) {
    super(src, options);
    this._related = null;
  }

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

  /** @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,
      uuid: null
    });
	  return options;
  }

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

  /** @override */
  get title() {
    if ( this._related && this._related["hasPerm"] ) {
      return this._related.hasPerm(game.user, "LIMITED") ? super.title : "";
    }
    return super.title;
  }

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

  /** @override */
  async getData(options) {
    const data = super.getData();
    await this.getRelatedObject();
    data.image = this.object;
    return data;
  }

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

  /**
   * Provide a reference to the Entity referenced by this popout, if one exists
   * @return {null|*}
   */
  async getRelatedObject() {
    if ( this.options.uuid && !this._related ) {
      this._related = await fromUuid(this.options.uuid);
    }
    return this._related;
  }

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

  /** @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,
      uuid: this.options.uuid
    });
  }

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

  /**
   * Handle a received request to display an image.
   * @param {string} image
   * @param {string} title
   * @param {string} uuid
   * @return {ImagePopout}
   * @private
   */
  static _handleShareImage({image, title, uuid}={}) {
    return new ImagePopout(image, {
      title: title,
      uuid: uuid,
      shareable: false,
      editable: false
    }).render(true);
  }
}

/**
 * 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.isGM;
    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]').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) {
    const attr = event.currentTarget.dataset.edit;
    const current = getProperty(this.item.data, attr);
    const fp = new FilePicker({
      type: "image",
      current: current,
      callback: path => {
        event.currentTarget.src = path;
        if ( this.options.submitOnChange ) {
          this._onSubmit(event);
        }
      },
      top: this.position.top + 40,
      left: this.position.left + 10
    });
    fp.browse(current);
  }
}

/**
 * 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();
  }

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

  /** @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;
  }

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

  /**
   * Guess the default view mode for the sheet based on the player's permissions to the Entry
   * @return {string}
   * @private
   */
  _inferDefaultMode() {
    const hasImage = !!this.object.data.img;
    const otherwise = this.object.limited ? null : "text";
    return hasImage ? "image" : otherwise;
  }

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

  /** @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.folders.filter(f => (f.data.type === "JournalEntry") && f.displayed);
    return data
  }

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

  /** @override */
  async _updateObject(event, formData) {
    if ( this._sheetMode === "image" ) {
      formData.name = formData.title;
      delete formData["title"];
      formData.img = formData.image;
      delete formData["image"];
    }
    return 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);
  }
}

/**
 * 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,
      height: 480,
      resizable: true
    });
  }

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

  /** @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));
    html.find("button.execute").click(this._onExecute.bind(this));
  }

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

  /**
   * 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);
  }

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

  /**
   * Save and execute the macro using the button on the configuration sheet
   * @param {MouseEvent} event      The originating click event
   * @return {Promise<void>}
   * @private
   */
  async _onExecute(event) {
    event.preventDefault();
    await this._onSubmit(event, {preventClose: true}); // Submit pending changes
    this.object.execute(); // Execute the macro
  }


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

  /** @override */
  async _updateObject(event, formData) {
    if ( !this.object.data._id ) {
      return Macro.create(formData);
    } else {
      return super._updateObject(event, formData);
    }
  }
}

/**
 * A configuration Form Application for modifying the properties of a MeasuredTemplate object.
 * @extends {FormApplication}
 * @see {@link MeasuredTemplate}
 */
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 */
  async _updateObject(event, formData) {
    if ( this.object.id ) {
      formData["id"] = this.object.id;
      return this.object.update(formData);
    }
    return this.object.constructor.create(formData);
  }
}

/**
 * A generic application for configuring permissions for various Entity types
 * @extends {BaseEntitySheet}
 */
class PermissionControl extends BaseEntitySheet {

  /** @override */
	static get defaultOptions() {
	  const options = super.defaultOptions;
	  options.id = "permission";
	  options.template = "templates/apps/permission.html";
	  return options;
  }

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

  /** @override */
  get title() {
    return `${game.i18n.localize("PERMISSION.Title")}: ${this.entity.name}`;
  }

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

  /** @override */
  getData() {
    const e = this.entity;

    // Configure permission levels
    const defaultLevels = Object.entries(CONST.ENTITY_PERMISSIONS).map(level => {
      let [n, l] = level;
      return {level: l, name: n.titleCase()};
    });
    const playerLevels = [{level: "-1", name: "Default"}].concat(defaultLevels);

    // Player users
    const players = game.users.map(u => {
      return {
        user: u,
        level: e.data.permission[u._id],
        hidden: u.isGM
      };
    });

    // Construct and return the data object
    return {
      entity: e,
      defaultLevels: defaultLevels,
      levels: playerLevels,
      users: players
    };
  }

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

  /** @override */
 async _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
    return 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
   */
  async _updateObject(event, formData) {
    return 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 */
  async _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;
      return this.playlist.updateEmbeddedEntity("PlaylistSound", formData, {});
    }
    return 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;
      r.text = TextEditor.decodeHTML(r.text);
      return r;
    });
    results.sort((a, b) => a.range[0] - b.range[0]);

    // 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: Array.from(game.packs.keys())
    });
  }

  /* -------------------------------------------- */
  /* 	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.js");
    let collection = "";
    if ( value === rt.ENTITY ) collection = "Actor";
    else if ( value === rt.COMPENDIUM ) collection = game.packs.keys().next().value;
    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.submit({preventClose: true, preventRender: true});
    event.currentTarget.disabled = true;
    let tableRoll = this.entity.roll();
    const draws = this.entity._getResultsForRoll(tableRoll.roll.total);
    if ( draws.length ) {
      if (game.settings.get("core", "animateRollTable")) await this._animateRoll(draws);
      await this.entity.draw(tableRoll);
    }
    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.get(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[]} results     An Array of drawn table results to highlight
   * @return {Promise}
   * @private
   */
  async _animateRoll(results) {

    // Get the list of results and their indices
    const ol = this.element.find(".table-results")[0];
    const drawnIds = new Set(results.map(r => r._id));
    const drawnItems = Array.from(ol.children).filter(item => drawnIds.has(item.dataset.resultId));

    // 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 the roulette
    await this._animateRoulette(ol, drawnIds, nLoops, animTime, animOffset);

    // Flash the results
    const flashes = drawnItems.map(li => this._flashResult(li));
    return Promise.all(flashes);
  }

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

  /**
   * Animate a "roulette" through the table until arriving at the final loop and a drawn result
   * @return {Promise<void>}
   * @private
   */
  async _animateRoulette(ol, drawnIds, nLoops, animTime, animOffset) {
    let loop = 0;
    let idx = 1;
    let item = null;
    return new Promise(resolve => {
      let animId = setInterval(() => {
        if (idx === 1) loop++;
        if (item) item.classList.remove("roulette");

        // Scroll to the next item
        item = ol.children[idx];
        ol.scrollTop = (idx - animOffset) * item.offsetHeight;

        // If we are on the final loop
        if ( (loop === nLoops) && drawnIds.has(item.dataset.resultId) ) {
          clearInterval(animId);
          return resolve();
        }

        // Continue the roulette and cycle the index
        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<void>}
   * @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 {EntityCollection} 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();
    const gc = new GridConfig(this.object, this).render(true);
    return this.minimize();
  }

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

  /** @override */
  async _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
 * @extends {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: game.user.can("TEMPLATE_CREATE"),
      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"
        },
        {
          name: "browse",
          title: "CONTROLS.TileBrowser",
          icon: "fas fa-folder",
          button: true,
          onClick: () => {
            new FilePicker({
              type: "imagevideo",
              displayMode: "tiles",
              tileSize: true
            }).render(true);
          }
        }
      ],
      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: "text",
          title: "CONTROLS.DrawingText",
          icon: "fas fa-font"
        },
        {
          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: "select",
          title: "CONTROLS.WallSelect",
          icon: "fas fa-expand"
        },
        {
          name: "walls",
          title: "CONTROLS.WallDraw",
          icon: "fas fa-bars"
        },
        {
          name: "terrain",
          title: "CONTROLS.WallTerrain",
          icon: "fas fa-mountain"
        },
        {
          name: "invisible",
          title: "CONTROLS.WallInvisible",
          icon: "fas fa-eye-slash"
        },
        {
          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: "snap",
          title: "CONTROLS.WallSnap",
          icon: "fas fa-plus",
          toggle: true,
          active: canvas.walls._forceSnap,
          onClick: toggled => canvas.walls._forceSnap = toggled
        },
        {
          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: "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
        },
        {
          name: "clear",
          title: "CONTROLS.LightClear",
          icon: "fas fa-trash",
          onClick: () => canvas.lighting.deleteAll(),
          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,
      dragDrop: [{ dragSelector: ".macro", dropSelector: "#macro-list" }]
    });
  }

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

  /** @override */
	getData(options) {
	  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) {
    super.activateListeners(html);

    // Macro actions
    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);
  }

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

  /**
   * 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();
  }

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

  /** @override */
  _canDragStart(selector) {
    return true;
  }

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

  /** @override */
  _onDragStart(event) {
    const li = event.currentTarget.closest(".macro");
    if ( !li.dataset.macroId ) return false;
    const dragData = { type: "Macro", id: li.dataset.macroId, slot: li.dataset.slot };
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

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

  /** @override */
  _canDragDrop(selector) {
    return true;
  }

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

  /** @override */
  async _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
    const macro = await this._getDropMacro(data);
    if ( macro ) game.user.assignHotbarMacro(macro, li.dataset.slot, {fromSlot: data.slot});
  }

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

  /**
   * Get the Macro entity being dropped in the Hotbar. If the data comes from a non-World source, create the Macro
   * @param {Object} data             The data transfer attached to the DragEvent
   * @return {Promise<Macro|null>}    A Promise which returns the dropped Macro, or null
   * @private
   */
  async _getDropMacro(data) {
    if ( data.type !== "Macro" ) return null;

    // Case 1 - Data explicitly provided (but no ID)
    if ( data.data && !data.id ) {
      return await Macro.create(data.data);
    }

    // Case 2 - Imported from a Compendium pack
    else if ( data.pack ) {
      const createData = await game.packs.get(data.pack).getEntry(data.id);
      return Macro.create(createData);
    }

    // Case 3 - Imported from a World ID
    else {
      return game.macros.get(data.id);
    }
  }

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

  /**
   * 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;
  }

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

  /**
   * 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
    });
  }

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

  /**
   * Convenience access for the canvas layer which this HUD modifies
   * @type {PlaceablesLayer}
   */
  get layer() {
    return this.object.layer;
  }

	/* -------------------------------------------- */
  /*  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) {
	  const states = this.constructor.RENDER_STATES;
	  if ( this._state !== states.NONE ) return;
	  if ( this.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.element.hide().fadeIn(200);
  }

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

  /**
   * Clear the HUD by fading out it's active HTML and recording the new display state
   */
	clear() {
	  let states = this.constructor.RENDER_STATES;
	  if ( this._state <= states.NONE ) return;
	  this._state = states.CLOSING;

	  // Unbind
	  this.object = null;
	  this.element.hide();
	  this._element = null;
    this._state = 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();
  }

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

  getData(options) {
    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 the visible state of all controlled objects in the Layer
   * @param {PointerEvent} event    The originating click event
   * @private
   */
  async _onToggleVisibility(event) {
    event.preventDefault();

    // Toggle the visible state
    const isHidden = this.object.data.hidden;
    const updates = this.layer.controlled.map(o => {
      return {_id: o.id, hidden: !isHidden};
    });

    // Update all objects
    await this.layer.updateMany(updates);
    event.currentTarget.classList.toggle("active", !isHidden);
  }

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

  /**
   * Toggle locked state of all controlled objects in the Layer
   * @param {PointerEvent} event    The originating click event
   * @private
   */
  async _onToggleLocked(event) {
    event.preventDefault();

    // Toggle the visible state
    const isLocked = this.object.data.locked;
    const updates = this.layer.controlled.map(o => {
      return {_id: o.id, locked: !isLocked};
    });

    // Update all objects
    await this.layer.updateMany(updates);
    event.currentTarget.classList.toggle("active", !isLocked);
  }

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

  /**
   * Handle sorting the z-order of the object
   * @param event
   * @param up
   * @return {Promise<void>}
   * @private
   */
  async _onSort(up, event) {
    event.preventDefault();
    const siblings = this.layer.placeables;
    const controlled = this.layer.controlled.filter(o => !o.data.locked);

    // Determine target sort index
    let z = 0;
    if ( up ) {
      controlled.sort((a, b) => a.data.z - b.data.z);
      z = siblings.length ? Math.max(...siblings.map(o => o.data.z)) + 1 : 1;
    }
    else {
      controlled.sort((a, b) => b.data.z - a.data.z);
      z = siblings.length ? Math.min(...siblings.map(o => o.data.z)) - 1 : -1;
    }

    // Update all controlled objects
    const updates = controlled.map((o, i) => {
      let d = up ? i : i * -1;
      return {_id: o.id, z: z + d};
    });
    await this.layer.updateMany(updates);
  }
}

/**
 * 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,
      dragDrop: [{dragSelector: ".scene"}]
    });
  }

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

  /**
   * Return an Array of Scenes which are displayed in the Navigation bar
   * @return {Array}
   */
  get scenes() {
    const scenes = game.scenes.entities.filter(s => {
      return (s.data.navigation && s.visible) || s.active || s.isView;
    });
    scenes.sort((a, b) => a.data.navOrder - b.data.navOrder);
    return scenes;
  }


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

  /** @override */
  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);
  }

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

  /** @override */
  async _render(...args) {
    await super._render(...args);
    const loading = document.getElementById("loading");
    const nav = this.element[0];
    loading.style.top = `${nav.offsetTop + nav.offsetHeight}px`;
  }

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

  /** @override */
	getData(options) {

    // 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) {
    super.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);
  }

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

  /**
   * 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: li => game.user.isGM && !game.scenes.get(li.data("sceneId")).data.active,
        callback: li => {
          let scene = game.scenes.get(li.data("sceneId"));
          scene.activate();
        }
      },
      {
        name: "SCENES.Configure",
        icon: '<i class="fas fa-cogs"></i>',
        condition: game.user.isGM,
        callback: li => {
          let scene = game.scenes.get(li.data("sceneId"));
          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: li => {
          const scene = game.scenes.get(li.data("sceneId"));
          return game.user.isGM && ( !scene.data.active );
        },
        callback: li => {
          const scene = game.scenes.get(li.data("sceneId"));
          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();
  }

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

  /** @override */
  _onDragStart(event) {
    const sceneId = event.currentTarget.dataset.sceneId;
    event.dataTransfer.setData("text/plain", JSON.stringify({
      type: "SceneNavigation",
      id: sceneId,
    }));
  }

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

  /** @override */
  async _onDrop(event) {

    // 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();
  }

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

  static _onLoadProgress(context, pct) {
    const loader = document.getElementById("loading");
    pct = Math.clamped(pct, 0, 100);
    loader.querySelector("#context").textContent = context;
    loader.querySelector("#loading-bar").style.width = `${pct}%`;
    loader.querySelector("#progress").textContent = `${pct}%`;
    loader.style.display = "block";
    if ( (pct === 100 ) && !loader.hidden) $(loader).fadeOut(2000);
  }
}

/**
 * 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
 * @Extends {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;
  }

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

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "players",
      template: "templates/user/players.html",
      popOut: false
    });
  }

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

  /** @override */
  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);
  }

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

  /** @override */
  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
	/* -------------------------------------------- */

  /** @override */
  activateListeners(html) {

    // Toggle online/offline
    html.find("h3").click(this._onToggleOfflinePlayers.bind(this));

    // Context menu
    const contextOptions = this._getUserContextOptions();
    Hooks.call(`getUserContextOptions`, html, contextOptions);
    new ContextMenu(html, ".player", contextOptions);
  }

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

  /**
   * Return the default context options available for the Players application
   * @return {Array}
   * @private
   */
  _getUserContextOptions() {
    return [
      {
        name: game.i18n.localize("PLAYERS.ConfigTitle"),
        icon: '<i class="fas fa-male"></i>',
        condition: li => game.user.isGM || (li[0].dataset.userId === game.user._id),
        callback: li => {
          const user = game.users.get(li[0].dataset.userId);
          new PlayerConfig(user).render(true)
        }
      },
      {
        name: game.i18n.localize("PLAYERS.ViewAvatar"),
        icon: '<i class="fas fa-image"></i>',
        condition: li => {
          const user = game.users.get(li[0].dataset.userId);
          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,
            uuid: user.uuid
          }).render(true);
        }
      },
      {
        name: game.i18n.localize("PLAYERS.PullToScene"),
        icon: '<i class="fas fa-directions"></i>',
        condition: li => game.user.isGM && (li[0].dataset.userId !== game.user._id),
        callback: li => game.socket.emit("pullToScene", canvas.scene._id, li.data("user-id"))
      },
      {
        name: game.i18n.localize("PLAYERS.Kick"),
        icon: '<i class="fas fa-door-open"></i>',
        condition: li => {
          const user = game.users.get(li[0].dataset.userId);
          return game.user.isGM && user.active && !user.isSelf;
        },
        callback: li => {
          const user = game.users.get(li[0].dataset.userId);
          const role = user.role;
          user.update({role: CONST.USER_ROLES.NONE}).then(u => u.update({role}));
          ui.notifications.info(`${user.name} has been kicked from the world.`);
        }
      },
      {
        name: game.i18n.localize("PLAYERS.Ban"),
        icon: '<i class="fas fa-ban"></i>',
        condition: li => {
          const user = game.users.get(li[0].dataset.userId);
          return game.user.isGM && !user.isSelf && (user.role !== CONST.USER_ROLES.NONE);
        },
        callback: li => {
          const user = game.users.get(li[0].dataset.userId);
          user.update({role: CONST.USER_ROLES.NONE});
          ui.notifications.info(`${user.name} has been <strong>banned</strong> from the world.`);
        }
      },
      {
        name: game.i18n.localize("PLAYERS.UnBan"),
        icon: '<i class="fas fa-ban"></i>',
        condition: li => {
          const user = game.users.get(li[0].dataset.userId);
          return game.user.isGM && !user.isSelf && (user.role === CONST.USER_ROLES.NONE);
        },
        callback: li => {
          const user = game.users.get(li[0].dataset.userId);
          user.update({role: CONST.USER_ROLES.PLAYER});
          ui.notifications.info(`${user.name} has been restored to a Player role in the World.`);
        }
      }
    ];
  }

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

  /**
   * 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();
  }
}

/**
 * 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 */
  async _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;
      return this.object.update(formData);
    }
    return 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.
 * @extends {BasePlaceableHUD}
 */
class DrawingHUD extends BasePlaceableHUD {

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "drawing-hud",
      template: "templates/hud/drawing-hud.html"
    });
  }

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

  /** @override */
  getData() {
    const data = super.getData();
    return mergeObject(data, {
      lockedClass: data.locked ? "active" : "",
      visibilityClass: data.hidden ? "active" : "",
    });
  }

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

  /** @override */
	setPosition() {
	  let {x, y, width, height} = this.object.drawing.hitArea;
	  const c = 70;
	  const p = 10;
	  const position = {
	    width: width + (c * 2) + (p * 2),
      height: height + (p * 2),
      left: x + this.object.data.x - c - p,
      top: y + this.object.data.y - p
    };
    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 */
  async _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;
      return this.object.update(formData);
    }
    return this.object.constructor.create(formData);
  }
}

/**
 * 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 = game.i18n.localize("NOTE.ConfigTitle");
	  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
   */
  async _updateObject(event, formData) {
    if ( this.object.id ) {
      formData["id"] = this.object.id;
      return this.object.update(formData);
    }
    else {
      canvas.notes.preview.removeChildren();
      return this.object.constructor.create(formData);
    }
  }

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

  /**
   * 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();
  }
}

/**
 * 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",
      width: 480
    });
  }

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

  /** @override */
  getData(options) {
    return {
      object: duplicate(this.object.data),
      options: this.options,
      submitText: this.preview ? "Create" : "Update"
    }
  }

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

  /** @override */
  async _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,
      submitOnChange: true
    });
  }

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

  /** @override */
  getData(options) {
    return {
      object: duplicate(this.object.data),
      options: this.options,
      submitText: this.options.preview ? "Create" : "Update"
    }
  }

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

  /** @override */
  _onChangeInput(event) {
    const fd = this._getFormData(event.currentTarget.form);
    for ( let [k, v] of fd.entries() ) {
      this.object.data[k] = v;
    }
    this.object.refresh();
  }

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

  /** @override */
  async _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;
      return this.object.update(formData, {diff: false});
    }
    return this.object.constructor.create(formData);
  }

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

  /** @override */
  async close(options) {
    await super.close(options);
    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.
 * @extends {BasePlaceableHUD}
 */
class TileHUD extends BasePlaceableHUD {

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "tile-hud",
      template: "templates/hud/drawing-hud.html"
    });
  }

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

  /** @override */
  getData() {
    const data = super.getData();
    return mergeObject(data, {
      lockedClass: data.locked ? "active" : "",
      visibilityClass: data.hidden ? "active" : "",
    });
  }

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

  /** @override */
  setPosition() {
	  let {x, y, width, height} = this.object.hitArea;
	  const c = 70;
	  const p = -10;
	  const position = {
	    width: width + (c * 2) + (p * 2),
      height: height + (p * 2),
      left: x + this.object.data.x - c - p,
      top: y + this.object.data.y - p
    };
    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, {
      classes: ["sheet", "token-sheet"],
      template: "templates/scene/token-config.html",
      width: 480,
      height: "auto",
      tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "character"}]
    });
  }

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

  /** @override */
  get id() {
    let id = this.token.id;
    if ( !id ) id = this.actor.id;
    return id ? `token-config-${id}` : `token-config`;
  }

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

  /**
   * 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(options) {
    const actor = this.token.actor;
    let hasAlternates = this.token.id ? actor.data.token.randomImg : false;
    const attributes = this.constructor.getTrackedAttributes(this.actor ? this.actor.data.data : {});
    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.constructor.getTrackedAttributeChoices(attributes),
      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;
      }, {}),
    };
  }

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

  /** @override */
  async render(...args) {
    if ( !game.user.can("TOKEN_CONFIGURE") || !this.token.owner ) {
      return ui.notifications.warn("You do not have permission to configure this Token!");
    }
    return super.render(...args);
  }

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

  /**
   * Inspect the Actor data model and identify the set of attributes which could be used for a Token Bar
   * @return {Array}
   */
  static getTrackedAttributeChoices(attributes) {
    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
   */
  static getTrackedAttributes(data, _path=[]) {

    // Track the path and record found attributes
    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.getTrackedAttributes(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);
    html.find(".bar-attribute").change(this._onBarChange.bind(this));
    html.find(".alternate-images").change(ev => ev.target.form.img.value = ev.target.value);
    html.find('button.assign-token').click(this._onAssignToken.bind(this));
  }

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

  /** @override */
  async _updateObject(event, formData) {

    // Verify the user has the ability to update a Token configuration
    if ( !this.token.owner || !game.user.can("TOKEN_CONFIGURE") ) {
      throw new Error("You do not have permission to configure this token");
    }

    // Configure prototype Token data
    if ( formData.actorId && this.options.configureDefault ) {
      await this._updateActorData(formData);
    }

    // Update a token on the canvas
    if ( this.token.parent !== null ) {
      await 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 the Actor
    return 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 ) {
      ui.notifications.warn(game.i18n.localize("TOKEN.AssignWarn"));
      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(game.i18n.format("TOKEN.AssignSuccess", {name: token.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 !== null ? attr.value : "";
    form.querySelector(`input.${bar}-max`).value = ((attr !== null) && (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.can("TOKEN_CONFIGURE"),
      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);
  }

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

  /**
   * 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
   */
  async _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.updateEmbeddedEntity("Wall", updateData);
    }

    // Update single wall
    else return 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 = 720;
	  options.popOut = true;
	  return options;
  }

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

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

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

  /** @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);
    const form = html[0].children[1];
    html.find("#decline").click(this._onDecline.bind(this));
    form.onsubmit = this._onSubmit.bind(this);
  }

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

  /**
   * Handle refusal of the EULA by checking the decline button
   * @param {MouseEvent} event    The originating click event
   */
  _onDecline(event) {
    const button = event.currentTarget;
    ui.notifications.error(`You have declined the End User License Agreement and cannot use the software.`);
    button.form.dataset.clicked = "decline";
  }

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

  /**
   * Validate form submission before sending it onwards to the server
   * @param event
   */
  _onSubmit(event) {
    const form = event.target;
    if ( form.dataset.clicked === "decline" ) {
      return setTimeout(() => window.location.href = CONST.WEBSITE_URL, 1000);
    }
    if ( !form.agree.checked ) {
      event.preventDefault();
      ui.notifications.error(`You must agree to the ${this.options.title} before proceeding.`);
    }
  }
}


class InstallPackage extends Dialog {
  constructor(data, options) {
    super(data, options);
    this.packages = data.packages;
    this.setup = data.setup;

    /**
     * A filtering timeout function reference used to rate limit string filtering operations
     * @type {number|null}
     */
    this._filterTimeout = null;
  }

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

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "install-package",
	    template: "templates/setup/install-package.html",
      classes: ["dialog"],
      width: 520,
      height: 520
    });
  }

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

  /** @override */
  getData() {
    const data = super.getData();
    data.packageType = this.data.packageType;
    data.packages = this.packages.map(p => {
      p.shortDesc = TextEditor.previewHTML(p.description, 100);
      return p;
    });
    return data;
  }

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

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html[0].children[0].onsubmit = ev => ev.preventDefault();
    html.find('input[name="filter"]').keyup(this._onFilterResults.bind(this));
    html.find(".package-title a").click(this._onClickPackageTitle.bind(this));
    html.find("button.install").click(this._onClickPackageInstall.bind(this));
  }

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

  _onClickPackageTitle(event) {
    event.preventDefault();
    const li = event.currentTarget.closest(".package");
    const form = li.closest("form");
    form.manifestURL.value = li.querySelector("button.install").dataset.manifest;
  }

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

  _onClickPackageInstall(event) {
    event.preventDefault();
    const button = event.currentTarget;
    this.setup._onInstallPackage(this.data.packageType, button.dataset.manifest);
    return this.close();
  }

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

  /**
   * 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('.package-list').children().each((i, li) => {
        let title = li.querySelector(".package-title a").textContent;
        li.classList.toggle("hidden", !(query.test(li.dataset.packageId) || query.test(title)));
      });
    };

    // Filter if we are done entering keys
    let query = new RegExp(RegExp.escape(input.value), "i");
    if ( this._filterTimeout ) {
      clearTimeout(this._filterTimeout);
      this._filterTimeout = null;
    }
    this._filterTimeout = setTimeout(() => filter(query), 100);
  }
}

/**
 * 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",
      popOut: false,
      scrollY: ["#world-list", "#system-list", "#module-list"],
      tabs: [{navSelector: ".tabs", contentSelector: ".content", initial: "worlds"}]
    });
  }

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

  /** @override */
  getData(options) {

    // Configuration options
    options = game.data.options;
    options.upnp = options.upnp !== false;

    // Prepare Systems
    const systems = this.systems.map(s => {
      this.constructor.tagPackageAvailability(s);
      return s;
    });

    // Prepare Modules
    const modules = this.modules.map(m => {
      this.constructor.tagPackageAvailability(m);
      return m;
    });

    // Prepare Worlds
    const worlds = this.worlds.map(w => {
      w.active = w.id === this.current;
      w.shortDesc = TextEditor.previewHTML(w.data.description);
      this.constructor.tagPackageAvailability(w);
      return w;
    });

    // Return data for rendering
    const coreVersion = game.data.version;
    return {
      coreVersion: coreVersion,
      coreVersionHint: game.i18n.format("SETUP.CoreVersionHint", {coreVersion}),
      systems: systems,
      modules: modules,
      worlds: worlds,
      options: options,
      adminKey: game.data.adminKey,
      world: this.worlds.find(w => w.id === this.current),
      current: this.current,
      updateChannels: Object.entries(CONST.SOFTWARE_UPDATE_CHANNELS).reduce((obj, c) => {
        obj[c[0]] = game.i18n.localize(c[1]);
        return obj;
      }, {}),
      coreUpdate: game.data.coreUpdate ? game.i18n.format("SETUP.UpdateAvailable", game.data.coreUpdate) : false
    };
  }

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

  static tagPackageAvailability(pkg) {
    const ac = CONST.PACKAGE_AVAILABILITY_CODES;
    switch(pkg.data.availability) {
      case ac.REQUIRES_SYSTEM:
        pkg.unavailable = game.i18n.localize("SETUP.RequireSystem");
        break;
      case ac.REQUIRES_DEPENDENCY:
        pkg.unavailable = game.i18n.localize("SETUP.RequireDep");
        break;
      case ac.REQUIRES_CORE:
        pkg.unavailable = game.i18n.localize("SETUP.RequireCore");
        break;
      case ac.REQUIRES_UPDATE:
        pkg.incompatible = game.i18n.localize("SETUP.CompatibilityRisk");
        break;
      case ac.UNKNOWN:
        pkg.incompatible = game.i18n.localize("SETUP.CompatibilityUnknown");
        break;
    }
  }

  /* -------------------------------------------- */
  /*  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._onInstallPackageDialog.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) {

    // Construct form data
    const formData = this._getFormData(this.form);
    for ( let [k, v] of Object.entries(data) ) {
      formData.set(k, v);
    }

    // Post the request and handle redirects
    const request = await fetch(SetupConfiguration.setupURL, { method: "POST", body: formData });
    if ( request.redirected ) return 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
   */
  async _onInstallPackageDialog(event) {
    event.preventDefault(event);

    // Get the package type
    let button = this._progressButton = event.currentTarget;
    const list = button.closest(".tab").querySelector(".package-list");
    const type = list.dataset.packageType;

    // Get available packages
    let packages = await SetupConfiguration.getPackages({type});
    if ( packages.error ) {
      ui.notifications.error(game.i18n.localize("ERROR.LoadPackages"));
      console.error(packages.error);
      packages = [];
    }

    // Display the form as a dialog
    new InstallPackage({
      title: game.i18n.localize("SETUP.Install"+type.titleCase()),
      packageType: type,
      packages: packages,
      setup: this,
      buttons: {
        install: {
          icon: '<i class="fas fa-download"></i>',
          label: game.i18n.localize("Install"),
          callback: html => {
            const manifestURL = html.find('input[name="manifestURL"]').val();
            this._onInstallPackage(type, manifestURL);
          }
        },
        cancel: {
          icon: '<i class="fas fa-times"></i>',
          label: game.i18n.localize("Cancel")
        },
      },
      default: "install"
    }).render(true);
  }

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

  /**
   * Handle installing a package by its manifest URL
   * @param {string} manifestURL
   * @return {Promise<void>}
   * @private
   */
  async _onInstallPackage(packageType, manifestURL) {
    const response = await SetupConfiguration.installPackage({type: packageType, manifest: manifestURL}).catch(err => {
      return {error: err.message};
    });
    this._progressButton = null;
    if ( response.error ) {
      const err = new Error(response.error);
      err.stack = response.stack;
      ui.notifications.error(game.i18n.localize("SETUP.InstallFailure") + ": " + err.message);
      console.error(err);
    } else {
      if ( response.warning ) ui.notifications.warn(response.warning, {permanent: true});
      ui.notifications.info(`${packageType.titleCase()} ${response.name} ${game.i18n.localize("SETUP.InstallSuccess")}`);
    }
    this._reload();
  }

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

  /**
   * 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 ) {
      const err = new Error(manifest.error);
      err.stack = manifest.stack;
      ui.notifications.error(game.i18n.localize("SETUP.UpdateFailure") + ": " + err.message);
      console.error(err);
      button.innerHTML = '<i class="fas fa-times"></i><label>Failed</label>';
    }
    else if ( manifest.installed ) {
      if ( manifest.warning ) ui.notifications.warn(manifest.warning, {permanent: true});
      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
    const li = button.closest(".package");
    const name = li.dataset.packageId;
    const type = li.closest(".package-list").dataset.packageType;

    // Get the target package
    let collection = game.data[type+"s"];
    let idx = collection.findIndex(p => p.id === name);
    let pack = collection[idx];

    // Define a warning message
    let warning = `<p>Are you sure you want to delete the ${type.titleCase()} <strong>${pack.data.title}</strong>?</p>`
    if ( type === "world" ) {
      warning += `<p class="notification"><strong>Warning:</strong> This will permanently delete your world content and <strong>can not</strong> be un-done. Are you sure you wish to do this?</strong>.</p>`
      warning += `<p>Type the exact title of your world <strong>${pack.data.title}</strong> in the box below to proceed</p>`;
      warning += `<p><input id="delete-confirm" type="text" required></p>`;
    } else {
      warning += `<p class="notification"><strong>Warning:</strong> This operation cannot be un-done.</p>`
    }

    // Confirm deletion request
    Dialog.confirm({
      title: `Delete ${type.titleCase()}: ${pack.data.title}`,
      content: warning,
      yes: async html => {

        // Confirm World deletion
        if ( type === "world" ) {
          const confirm = html.find("#delete-confirm").val();
          if ( confirm !== pack.data.title ) {
            return ui.notifications.error("You must enter the exact World name to confirm the deletion request.");
          }
        }

        // Submit the server request
        const response = await SetupConfiguration.uninstallPackage({type, name});
        if ( response.error ) {
          const err = new Error(response.error);
          err.stack = response.stack;
          ui.notifications.error(game.i18n.localize("SETUP.UninstallFailure") + ": " + err.message);
          console.error(err);
        } else {
          ui.notifications.info(`${type.titleCase()} ${name} ${game.i18n.localize("SETUP.UninstallSuccess")}.`);
          collection.splice(idx, 1);
        }

        // Re-render the setup form
        this.render();
      }
    }).then(() => 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];

    // Disable the form
    button.disabled = true;
    form.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}).catch(err => {
      button.disabled = false;
      form.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);
      return 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);
    })
  }

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

  /**
   * Update the display of an installation progress bar for a particular progress packet
   * @param {Object} data   The progress update data
   * @private
   */
  _updateProgressBar(data) {
    const tabName = data.type === "core" ? "update" : 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.notes
    }
  }

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

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("#return").click(ev => this.close());
  }
}

/**
 * The User Management setup application
 * @extends {FormApplication}
 */
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 */
  async _render(...args) {
    await getTemplate(this.constructor.USER_TEMPLATE);
    return super._render(...args);
  }

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

  /** @override */
  getData(options) {
    return {
      user: game.user,
      users: this.object.entities.map(u => u.data),
      roles: CONST.USER_ROLES,
      options: this.options,
      userTemplate: this.constructor.USER_TEMPLATE
    };
  }

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

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find('.create-user').click(this._onUserCreate.bind(this));
    html.find('input.show').change(this._onKeyShow.bind(this));
    html.on('click', '.user-delete', this._onUserDelete.bind(this));
  }

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

  /** @override */
  _onSubmit(event) {}

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

  /**
   * Reveal the access key for each player so that it can be learned without being changed
   * @param {Chan