foundry.js


/* ----------------------------------------- */
/*  Reusable Type Definitions                */
/* ----------------------------------------- */

/**
 * A single point, expressed as an object {x, y}
 * @typedef {PIXI.Point|{x: number, y: number}} Point
 */

/**
 * A single point, expressed as an array [x,y]
 * @typedef {number[]} PointArray
 */

/**
 * A Ray intersection point
 * @typedef {{x: number, y: number, t0: number, t1: number}|null} RayIntersection
 * @property [wall] Wall
 */

/**
 * A standard rectangle interface.
 * @typedef {PIXI.Rectangle|{x: number, y: number, width: number, height: number}} Rectangle
 */


/* ----------------------------------------- */
/*  Database Workflows                       */
/* ----------------------------------------- */

/**
 * The expected structure for a Data record
 * @typedef {{string, any}} Data
 * @property [_id] string
 */

/**
 * An object of optional keys and values which configure the behavior of a function
 * @typedef {{string, any}} Options
 */

const vtt = "Foundry VTT";
const VTT = "Foundry Virtual Tabletop";
const WEBSITE_URL = "https://foundryvtt.com";
const ASCII = `_______________________________________________________________
 _____ ___  _   _ _   _ ____  ______   __ __     _______ _____ 
|  ___/ _ \\| | | | \\ | |  _ \\|  _ \\ \\ / / \\ \\   / |_   _|_   _|
| |_ | | | | | | |  \\| | | | | |_) \\ V /   \\ \\ / /  | |   | |  
|  _|| |_| | |_| | |\\  | |_| |  _ < | |     \\ V /   | |   | |  
|_|   \\___/ \\___/|_| \\_|____/|_| \\_\\|_|      \\_/    |_|   |_|  
===============================================================`;

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

/**
 * Define the allowed ActiveEffect application modes
 * @type {{string, number}}
 */
const ACTIVE_EFFECT_MODES = {
  CUSTOM: 0,
  MULTIPLY: 1,
  ADD: 2,
  DOWNGRADE: 3,
  UPGRADE: 4,
  OVERRIDE: 5
};


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


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


/**
 * Enumerate the source types which can be used for an AmbientLight placeable object
 * @type {{UNIVERSAL: string, LOCAL: string, GLOBAL: string}}
 */
const SOURCE_TYPES = {
  LOCAL: "l",
  GLOBAL: "g",
  UNIVERSAL: "u"
};


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


/**
 * A list of MIME types which are treated as uploaded "media", which are allowed to overwrite existing files.
 * Any non-media MIME type is not allowed to replace an existing file.
 * @type {string[]}
 */
const MEDIA_MIME_TYPES = [
  "image/apng", "image/bmp", "image/gif", "image/jpeg", "image/png", "image/svg+xml", "image/tiff", "image/webp",
  "audio/wave", "audio/wav", "audio/webm", "audio/ogg", "audio/midi", "audio/mpeg", "audio/opus", "audio/aac",
  "video/mpeg", "video/mp4", "video/ogg",
  "application/json", "application/ogg", "application/pdf",
];


/**
 * 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 = {
  ASCII, vtt, VTT, WEBSITE_URL,
  ACTIVE_EFFECT_MODES, 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,
  SOURCE_TYPES, MEDIA_MIME_TYPES, 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                              */
/* -------------------------------------------- */

/**
 * Bound a number between some minimum and maximum value, inclusively
 * @param {number} num    The current value
 * @param {number} min    The minimum allowed value
 * @param {number} max    The maximum allowed value
 * @return {number}       The clamped number
 */
function clampNumber(num, min, max) {
  return Math.min(max, Math.max(num, min));
}

/**
 * Round a floating point number to a certain number of decimal places
 * @param {number} number  A floating point number
 * @param {number} places  An integer number of decimal places
 */
function roundDecimals(number, places) {
  places = Math.min(Math.trunc(places), 0);
  let scl = Math.pow(10, places);
  return Math.round(number * scl) / scl;
}

/**
 * Transform an angle in radians to a number in degrees
 * @param {number} angle    An angle in radians
 * @return {number}         An angle in degrees
 */
function toDegrees(angle) {
  return angle * (180 / Math.PI);
}

/**
 * Transform an angle in degrees to be bounded within the domain [0, 360]
 * @param {number} degrees  An angle in degrees
 * @return {number}         The same angle on the range [0, 360]
 */
function normalizeDegrees(degrees) {
  let nd = (degrees + 360) % 360;
  return (nd > 180) ? nd - 360 : nd;
}

/**
 * Transform an angle in degrees to an angle in radians
 * @param {number} angle    An angle in degrees
 * @return {number}         An angle in radians
 */
function toRadians(angle) {
  return (angle % 360) * (Math.PI / 180);
}

/**
 * Transform an angle in radians to be bounded within the domain [-PI, PI]
 * @param {number} radians  An angle in degrees
 * @return {number}         The same angle on the range [-PI, PI]
 */
function normalizeRadians(radians) {
  let pi2 = 2 * Math.PI;
  let nr = (radians + pi2) % pi2;
  return (nr > Math.PI) ? nr - pi2 : nr;
}

// Assign helper functions to the Math environment
Object.assign(Math, {
  clamped: clampNumber,
  decimals: roundDecimals,
  toDegrees,
  normalizeDegrees,
  toRadians,
  normalizeRadians
});


/* -------------------------------------------- */
/* 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
 * @param {any} [replace]   A replacement for the spliced element
 * @return {any|null}       The replacement element, the removed element, or null if no element was found.
 */
Array.prototype.findSplice = function(find, replace) {
  const idx = this.findIndex(find);
  if ( idx === -1 ) return null;
  if ( replace !== undefined ) {
    this.splice(idx, 1, replace);
    return replace;
  } else {
    const item = this[idx];
    this.splice(idx, 1);
    return item;
  }
};


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


/**
 * Obtain references to the parent classes of a certain class.
 * @param {Function} cls      An ES6 Class definition
 * @return {Function[]}       An array of parent Classes which the provided class extends
 */
function getParentClasses(cls) {
  if ( typeof cls !== "function" ) {
    throw new Error("The provided class is not a type of Function");
  }
  const parents = [];
  while ( !!cls.name ) {
    cls = Object.getPrototypeOf(cls);
    if ( cls.name ) parents.push(cls);
  }
  return parents;
}


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


/**
 * 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} [insertKeys]      Control whether to insert new top-level objects into the resulting structure
 *                                    which do not previously exist in the original object.
 * @param {boolean} [insertValues]    Control whether to insert new nested values into child objects in the resulting
 *                                    structure which did not previously exist in the original object.
 * @param {boolean} [overwrite]       Control whether to replace existing values in the source, or only merge values
 *                                    which do not already exist in the original object.
 * @param {boolean} [recursive]       Control whether to merge inner-objects recursively (if true), or whether to
 *                                    simply replace inner objects with a provided new value.
 * @param {boolean} [inplace]         Control whether to apply updates to the original object in-place (if true),
 *                                    otherwise the original object is duplicated and the copy is merged.
 * @param {boolean} [enforceTypes]    Control whether strict type checking requires that the value of a key in the
 *                                    other object must match the data type in the original data to be merged.
 * @param {number} [_d]               A privately used parameter to track recursion depth.
 *
 * @returns {Object}            The original source object including updated, inserted, or overwritten records.
 *
 * @example <caption>Control how new keys and values are added</caption>
 * mergeObject({k1: "v1"}, {k2: "v2"}, {insertKeys: false}); // {k1: "v1"}
 * mergeObject({k1: "v1"}, {k2: "v2"}, {insertKeys: true});  // {k1: "v1", k2: "v2"}
 * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {insertValues: false}); // {k1: {i1: "v1"}}
 * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {insertValues: true}); // {k1: {i1: "v1", i2: "v2"}}
 *
 * @example <caption>Control how existing data is overwritten</caption>
 * mergeObject({k1: "v1"}, {k1: "v2"}, {overwrite: true}); // {k1: "v2"}
 * mergeObject({k1: "v1"}, {k1: "v2"}, {overwrite: false}); // {k1: "v1"}
 *
 * @example <caption>Control whether merges are performed recursively</caption>
 * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {recursive: false}); // {k1: {i1: "v2"}}
 * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {recursive: true}); // {k1: {i1: "v1", i2: "v2"}}
 *
 * @example <caption>Deleting an existing object key</caption>
 * mergeObject({k1: "v1", k2: "v2"}, {"-=k1": null});   // {k2: "v2"}
 */
function mergeObject(original, other={}, {
    insertKeys=true,
    insertValues=true,
    overwrite=true,
    recursive=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") && recursive ) {
        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     An object comparing data against which to compare.
 * @param {object} other        An object containing potentially different data.
 * @param {boolean} [inner]     Only recognize differences in other for keys which also exist in original.
 * @return {object}             An object of the data in other which differs from that in original.
 */
function diffObject(original, other, {inner=false}={}) {
  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, {inner});
      return [!isObjectEmpty(d), d];
    }
    return [v0 !== v1, v1];
  }

  // Recursively call the _difference function
  return Object.keys(other).reduce((obj, key) => {
    if ( inner && (original[key] === undefined) ) return obj;
    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;
}


/* -------------------------------------------- */
/*  RegExp Helpers                              */
/* -------------------------------------------- */


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


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


/**
 * Wrap a callback in a debounced timeout.
 * Delay execution of the callback function until the function has not been called for delay milliseconds
 * @param {Function} callback       A function to execute once the debounced threshold has been passed
 * @param {number} delay            An amount of time in milliseconds to delay
 * @return {Function}
 */
debounce = function(callback, delay) {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      callback.apply(this, args)
    }, delay);
  }
};


/* -------------------------------------------- */
/*  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 {number[]}      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 {number[]}   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);
}


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


function benchmark(func, iterations) {
  const start = performance.now();
  for ( let i=0; i<iterations; i++ ) {
    func();
  }
  const end = performance.now();
  const t = Math.round((end - start) * 100) / 100;
  console.log(`Evaluated function ${iterations} times | ${t} ms | ${t / iterations} per`);
}


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



try {
  module.exports = {
    benchmark,
    debounce,
    duplicate,
    diffObject,
    filterObject,
    flattenObject,
    encodeURL,
    expandObject,
    invertObject,
    isObjectEmpty,
    mergeObject,
    hasProperty,
    getProperty,
    setProperty,
    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 {Howl[]}
     */
    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 for a given audio source URL
   * @param src
   * @param preload
   * @param autoplay
   * @return {Howl}
   */
  create({src, preload=false, autoplay=false, html5=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: html5,
      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;
  }

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

  /**
   * Test whether a source file has a supported audio extension type
   * @param {string} src      A requested audio source path
   * @return {boolean}        Does the filename end with a valid audio extension?
   */
  static hasAudioExtension(src) {
    let rgx = new RegExp("(\\."+CONST.AUDIO_FILE_EXTENSIONS.join("|\\.")+")(\\?.*)?", "i");
    return rgx.test(src);
  }

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

  /**
   * 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
   * @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=1.5) {
    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=1.5) {
    return Math.pow(volume, 1 / order);
  }

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

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

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

  /**
   * Registers a stream for periodic reports of audio levels.
   * Once added, the callback will be called with the maximum decibel level of
   * the audio tracks in that stream since the last time the event was fired.
   * The interval needs to be a multiple of AudioHelper.levelAnalyserNativeInterval which defaults at 50ms
   *
   * @param {string} id             An id to assign to this report. Can be used to stop reports
   * @param {MediaStream} stream    The MediaStream instance to report activity on.
   * @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) {
    if ( !stream || !id ) return;
    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;
  }

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

  /**
   * Get an entry from the Collection by name.
   * Use of this method assumes that the objects stored in the collection have a "name" attribute.
   * @param {string} name     The name of the entry 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 entry = this.find(e => e.name === name);
    if ( strict && (entry === undefined) ) {
      throw new Error(`An entry with name ${name} does not exist in the 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);
  }
}

/**
 * A simple event framework used throughout Foundry Virtual Tabletop.
 * When key actions or events occur, a "hook" is defined where user-defined callback functions can execute.
 * This class manages the registration and execution of hooked callback functions.
 */
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
   * @return {number}       An ID number of the hooked function which can be used to turn off the hook later
   */
  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
   * @return {number}       An ID number of the hooked function which can be used to turn off the hook later
   */
  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|number} fn    The function, or ID number for the function, that should be turned off
   */
  static off(hook, fn) {
    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);
    console.debug(`${vtt} | Unregistered callback for ${hook} hook`);
  }

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

  /**
   * 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 {...*} 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 helper class to provide common functionality for working with Image objects
 */
class ImageHelper {

  /**
   * Create thumbnail preview for a provided image path.
   * @param {string|PIXI.DisplayObject} src   The URL or display object of the texture to render to a thumbnail
   * @param {object} options    Additional named options passed to the compositeCanvasTexture function
   * @return {Promise<object>}  The parsed and converted thumbnail data
   */
  static async createThumbnail(src, options) {
    if ( !src ) return null;

    // Load the texture and create a Sprite
    let object = src;
    if ( !(src instanceof PIXI.DisplayObject) ) {
      const texture = await loadTexture(src);
      object = PIXI.Sprite.from(texture);
    }

    // Reduce to the smaller thumbnail texture
    const reduced = this.compositeCanvasTexture(object, options);
    const thumb = this.textureToImage(reduced);
    reduced.destroy(true);

    // Return the image data
    return { src, texture: reduced, thumb, width: object.width, height: object.height };
  }

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

  /**
   * Composite a canvas object by rendering it to a single texture
   *
   * @param {PIXI.DisplayObject} object   The object to render to a texture
   * @param {number} [width]              The desired width of the output texture
   * @param {number} [height]             The desired height of the output texture
   * @param {number} [tx]                 A horizontal translation to apply to the object
   * @param {number} [ty]                 A vertical translation to apply to the object
   * @param {boolean} [center]            Center the texture in the rendered frame?
   *
   * @return {PIXI.Texture}               The composite Texture object
   */
  static compositeCanvasTexture(object, {width, height, tx=0, ty=0, center=true}={}) {
    width = width ?? object.width;
    height = height ?? object.height;

    // Downscale the object to the desired thumbnail size
    const currentRatio = object.width / object.height;
    const targetRatio = width / height;
    const s = currentRatio > targetRatio ? (height / object.height) : (width / object.width);

    // Define a transform matrix
    const transform = PIXI.Matrix.IDENTITY.clone();
    transform.scale(s, s);

    // Translate position
    if ( center ) {
      tx = (width - (object.width * s)) / 2;
      ty = (height - (object.height * s)) / 2;
    } else {
      tx *= s;
      ty *= s;
    }
    transform.translate(tx, ty);

    // Create and render a texture with the desired dimensions
    const texture = PIXI.RenderTexture.create(width, height, PIXI.SCALE_MODES.LINEAR, 2);
    canvas.app.renderer.render(object, texture, undefined, transform);
    return texture;
  }

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

  /**
   * Extract a texture to a base64 PNG string
   * @param {PIXI.Texture} texture      The texture object to extract
   * @return {string}                   A base64 png string of the texture
   */
  static textureToImage(texture) {
    const s = new PIXI.Sprite(texture);
    return canvas.app.renderer.extract.base64(s);
  }
}

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

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

    // Interpret shift+scroll as vertical scroll
    let dy = event.deltaY;
    if ( event.shiftKey && (dy === 0) ) dy = event.deltaX;
    if ( dy === 0 ) return;

    // Take no actions if the canvas is not hovered
    if ( !canvas || !canvas.ready ) return;
    const hover = document.elementFromPoint(event.clientX, event.clientY);
    if ( !hover || (hover.id !== "board" )) return;

    // Identify scroll modifiers
    const isCtrl = event.ctrlKey || event.metaKey;
    const isShift = event.shiftKey;
    const layer = canvas.activeLayer;
    const canRotate = layer.options?.rotatableObjects && (layer.controlled.length || !!layer._hover);

    // Case 1 - rotate placeable objects
    if ( canRotate && ( isCtrl || isShift ) ) {
      event.preventDefault();
      const t = Date.now();
      if (( t - this._wheelTime ) < this.constructor.MOUSE_WHEEL_RATE_LIMIT ) return;
      this._wheelTime = t;
      layer._onMouseWheel(event);
    }

    // Case 2 - zoom the canvas
    else {
      event.preventDefault();
      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();

    // Handle CTRL+A
    if ( (modifiers.key === "a") && modifiers.isCtrl ) return this._onKeyA(event, up, modifiers);

    // 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 ) {
      if ( ["w", "a", "s", "d"].includes(modifiers.key) ) return;
      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);
    if ( modifiers.isAlt ) ui.hotbar.changePage(num);
    else {
      const slot = ui.hotbar.macros.find(m => m.key === num);
      if ( slot.macro ) slot.macro.execute();
    }
    this._handled.add(modifiers.key);
  }

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

  /**
   * Handle "A" keypress events (CTRL only) to select all objects
   * @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
   */
  _onKeyA(event, up, modifiers) {
    if ( up || modifiers.hasFocus ) return;
    if ( !modifiers.isCtrl ) return;
    canvas.activeLayer.controlAll();
    this._handled.add(modifiers.key);
  }

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

  /**
   * 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 PlaceablesLayer
    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);
      return layer.pasteObjects(pos, {hidden: modifiers.isAlt, snap: !modifiers.isShift});
    }
  }

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

  /**
   * 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 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 {string[]}
 */
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 {string} module   The module namespace under which the setting is registered
   * @param {string} key      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 ?? false) ? 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 {string} module   The module namespace under which the setting is registered
   * @param {string} key      The setting key to retrieve
   * @param {any} 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);
    if ( value === undefined ) value = setting.default;
    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});
    const response = await request.json();
    response["type"] = type;    // TODO: needed until the web server properly returns this
    return response
  }

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

  /**
   * Install a Package
   * @param {string} type       The type of package being installed, in ["module", "system", "world"]
   * @param {string} name       The canonical package name
   * @param {string} manifest   The package manifest URL
   * @return {Promise<object>}  A Promise which resolves to the installed package manifest
   */
  static async installPackage({type="module", name=null, manifest=null}={}) {
    const request = await this.post({action: "installPackage", type, name, manifest});
    const response = await request.json();

    // Handle errors
    if ( response.error ) {
      const err = new Error(response.error);
      err.stack = response.stack;
      ui.notifications.error(game.i18n.format("SETUP.InstallFailure", {message: err.message}));
      console.error(err);
      return response;
    }

    // Handle warnings
    if ( response.warning ) ui.notifications.warn(response.warning);
    ui.notifications.info(game.i18n.format("SETUP.InstallSuccess", {type: type.titleCase(), name: response.name}));

    // Trigger dependency installation (asynchronously)
    if ( response.dependencies?.length ) {
      this.installDependencies(response);
    }

    // Update application views
    if ( ui.setup ) await ui.setup.reload();
    return response
  }

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

  /**
   * Install a set of dependency modules which are required by an installed package
   * @param {object} pkg            The package which was installed that requested dependencies
   * @return {Promise<void>}
   */
  static async installDependencies(pkg) {
    const toInstall = [];
    const dependencies = pkg.dependencies || [];

    // Obtain known package data for requested dependency types
    for ( let d of dependencies ) {
      if (!d.name) continue;
      d.type = d.type || "module";
      const installed = game.data[`${d.type}s`].find(p => p.id === d.name);
      if ( installed ) {
        console.debug(`Dependency ${d.type} ${d.name} is already installed.`);
        continue;
      }

      // Manifest URL provided
      if (d.manifest) {
        toInstall.push(d);
        continue;
      }

      // Discover from package listing
      const packages = await InstallPackage.getPackages(d.type);
      const dep = packages.find(p => p.name === d.name);
      if (!dep) {
        console.warn(`Requested dependency ${d.name} not found in ${d.type} directory.`);
        continue;
      }
      d.manifest = dep.version.manifest;
      d.version = dep.version.version;
      toInstall.push(d);
    }
    if ( !toInstall.length ) return;

    // Prompt the user to confirm installation of dependency packages
    const html = await renderTemplate("templates/setup/install-dependencies.html", {
      dependencies: toInstall
    });
    let agree = false;
    await Dialog.confirm({
      title: game.i18n.localize("SETUP.PackageDependenciesTitle"),
      content: html,
      yes: () => agree = true
    });
    if ( !agree ) return ui.notifications.warn(game.i18n.format("SETUP.PackageDependenciesDecline", {
      title: pkg.title
    }));

    // Install dependency packages
    for ( let d of toInstall ) {
      await this.installPackage(d);
    }
    return ui.notifications.info(game.i18n.format("SETUP.PackageDependenciesSuccess", {
      title: pkg.title,
      number: toInstall.length
    }));
  }

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

  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 {object[]} 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 {object[]}          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];
  }

  /* -------------------------------------------- */
}
/**
 * A singleton class {@link game#time} which keeps the official Server and World time stamps.
 * Uses a basic implementation of https://www.geeksforgeeks.org/cristians-algorithm/ for synchronization.
 */
class GameTime {
  constructor(socket) {

    /**
     * The most recently synchronized timestamps retrieved from the server.
     * @type {{clientTime: number, serverTime: number, worldTime: number}}
     */
    this._time = {};

    /**
     * The average one-way latency across the most recent 5 trips
     * @type {number}
     */
    this._dt = 0;

    /**
     * The most recent five synchronization durations
     * @type {number[]}
     */
    this._dts = [];

    // Perform an initial sync
    if ( socket ) this.sync(socket);
  }

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

  /**
   * The current server time based on the last synchronization point and the approximated one-way latency.
   * @return {number}
   */
  get serverTime() {
    const t1 = Date.now();
    const dt = t1 - this._time.clientTime;
    if ( dt > GameTime.SYNC_INTERVAL_MS ) this.sync();
    return this._time.serverTime + dt;
  }

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

  /**
   * The current World time based on the last recorded value of the core.time setting
   * @return {number}
   */
  get worldTime() {
    return this._time.worldTime;
  }

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

  /**
   * Advance the game time by a certain number of seconds
   * @param {number} seconds        The number of seconds to advance (or rewind if negative) by
   * @return {Promise<number>}      The new game time
   */
  async advance(seconds) {
    return game.settings.set("core", "time", this.worldTime + seconds);
  }

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

  /**
   * Synchronize the local client game time with the official time kept by the server
   * @return {Promise<GameTime>}
   */
  async sync(socket) {
    socket = socket ?? game.socket;

    // Get the official time from the server
    const t0 = Date.now();
    const time = await new Promise(resolve => socket.emit("time", resolve));
    const t1 = Date.now();

    // Adjust for trip duration
    if ( this._dts.length >= 5 ) this._dts.unshift();
    this._dts.push(t1 - t0);

    // Re-compute the average one-way duration
    this._dt = Math.round(this._dts.reduce((total, t) => total + t, 0) / (this._dts.length * 2));

    // Adjust the server time and return the adjusted time
    time.clientTime = t1 - this._dt;
    this._time = time;
    console.log(`${vtt} | Synchronized official game time in ${this._dt}ms`);
    return this;
  }

  /* -------------------------------------------- */
  /*  Event Handlers and Callbacks                */
  /* -------------------------------------------- */

  /**
   * Handle follow-up actions when the official World time is changed
   * @param {number} worldTime  The new canonical World time.
   */
  onUpdateWorldTime(worldTime) {
    const dt = worldTime - this._time.worldTime;
    this._time.worldTime = worldTime;
    Hooks.callAll("updateWorldTime", worldTime, dt);
    if ( CONFIG.debug.time ) console.log(`The world time advanced by ${dt} seconds, and is now ${worldTime}.`);
  }
}

/**
 * The amount of time to delay before re-syncing the official server time.
 * @type {number}
 */
GameTime.SYNC_INTERVAL_MS = 1000 * 60 * 5;

/**
 * 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 ImageHelper.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.
 * @interface
 *
 * @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.
 *
 * Hooks:
 *   renderApplication
 *   closeApplication
 *   getApplicationHeaderButtons
 */
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 {Tabs[]}
     */
    this._tabs = this._createTabHandlers();

    /**
     * SearchFilter handlers which are active for this Application
     * @type {SearchFilter[]}
     */
    this._searchFilters = this._createSearchFilters();

    /**
     * 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 {Tabs[]}     An array of Tabs handlers
   * @private
   */
  _createTabHandlers() {
    return this.options.tabs.map(t => {
      t.callback = this._onChangeTab.bind(this);
      return new Tabs(t);
    });
  }

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

  /**
   * Create search filter handlers for this Application
   * @return {SearchFilter[]}  An array of SearchFilter handlers
   * @private
   */
  _createSearchFilters() {
    return this.options.filters.map(f => {
      f.callback = this._onSearchFilter.bind(this);
      return new SearchFilter(f);
    })
  }

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

  /**
   * 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() {
    return {
      baseApplication: null,
      width: null,
      height: null,
      top: null,
      left: null,
      popOut: true,
      minimizable: true,
      resizable: false,
      id: "",
      classes: [],
      dragDrop: [],
      tabs: [],
      filters: [],
      title: "",
      template: null,
      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 game.i18n.localize(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<void>}    A Promise that resolves to the Application once rendering is complete
   * @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
    for ( let cls of this.constructor._getInheritanceChain() ) {
      Hooks.call(`render${cls.name}`, this, html, data);
    }
    this._state = states.RENDERED;
  }

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

  /**
   * Return the inheritance chain for this Application class up to (and including) it's base Application class.
   * @return {Application[]}
   * @private
   */
  static _getInheritanceChain() {
    const parents = getParentClasses(this);
    const base = this.defaultOptions.baseApplication;
    const chain = [this];
    for ( let cls of parents ) {
      chain.push(cls);
      if ( cls.name === base ) break;
    }
    return chain;
  }

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

  /**
   * Persist the scroll positions of containers within the app before re-rendering the content
   * @param {jQuery} html           The HTML object being traversed
   * @param {string[]} selectors    CSS selectors which designate elements to save
   * @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           The HTML object being traversed
   * @param {string[]} selectors    CSS selectors which designate elements to restore
   * @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 after a slight timeout to prevent immediate interaction
    setTimeout(() => {
      html.find(".header-button").click(event => {
        event.preventDefault();
        const button = windowData.headerButtons.find(b => event.currentTarget.classList.contains(b.class));
        button.onclick(event);
      });
    }, 500);

    // 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.
   * The header buttons which are added to the application can be modified by the getApplicationHeaderButtons hook.
   * @typedef {{label: string, class: string, icon: string, onclick: Function|null}} ApplicationHeaderButton
   * @fires Application#hook:getApplicationHeaderButtons
   * @return {ApplicationHeaderButton[]}
   * @private
   */
  _getHeaderButtons() {
    const buttons = [
      {
        label: "Close",
        class: "close",
        icon: "fas fa-times",
        onclick: () => this.close()
      }
    ];
    for ( let cls of this.constructor._getInheritanceChain() ) {
      Hooks.call(`get${this.constructor.name}HeaderButtons`, this, buttons);
    }
    return buttons;
  }

	/* -------------------------------------------- */
	/* 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) {
    const el = html[0];
    this._tabs.forEach(t => t.bind(el));
    this._dragDrop.forEach(d => d.bind(el));
    this._searchFilters.forEach(f => f.bind(el));
  }

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

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

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

  /**
   * Handle changes to search filtering controllers which are bound to the Application
   * @param {KeyboardEvent} event   The key-up event from keyboard input
   * @param {RegExp} query          The regular expression to test against
   * @param {HTMLElement} html      The HTML element which should be filtered
   * @private
   */
  _onSearchFilter(event, query, html) {}

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

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

  /**
   * Bring the application to the top of the rendering stack
   */
  bringToTop() {
    const app = this.element[0];
    const z = document.defaultView.getComputedStyle(app).zIndex;
    if ( z < _maxZ ) {
      app.style.zIndex = Math.min(++_maxZ, 99999);
    }
  }

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

  /**
   * 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<void>}    A Promise which resolves once the application is closed
   */
  async close(options) {
    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
    for ( let cls of this.constructor._getInheritanceChain() ) {
      Hooks.call(`close${cls.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<void>}  A Promise which resolves 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();
        });
      });
    })
  }

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

  /**
   * 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<void>}    A Promise which resolves 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();
        });
      });
    })
  }

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

  /**
   * 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}
 * @abstract
 * @interface
 *
 * @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 {FilePicker[]}
     */
    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,
      title: this.title
    }
  }

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

  /** @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] instanceof HTMLFormElement ? html[0] : html.find("form")[0];
    return html;
  }

	/* -------------------------------------------- */
	/*  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();
    const states = this.constructor.RENDER_STATES;
    if ( (this._state === states.NONE) || !this.options.editable || this._submitting ) return false;
    this._submitting = true;

    // Flag if the application is staged to close to prevent callback renders
    const priorState = this._state;
    if ( this.options.closeOnSubmit ) this._state = states.CLOSING;
    if ( preventRender && (this._state !== states.CLOSING )) this._state = states.RENDERING;

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

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

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

  /**
   * Get an object of update data used to update the form's target object
   * @param {object} updateData     Additional data that should be merged with the form data
   * @return {object}               The prepared update data
   * @private
   */
  _getSubmitData(updateData={}) {
    if ( !this.form ) throw new Error(`The FormApplication subclass has no registered form element`);
    const fd = new FormDataExtended(this.form, {editors: this.editors});
    let data = fd.toObject();
    if ( updateData ) data = flattenObject(mergeObject(data, updateData));
    return data;
  }

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

  /**
   * 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 named TinyMCE text editor
   * @param {string} name             The named data field which the editor modifies.
   * @param {object} options          TinyMCE initialization options passed to TextEditor.create
   * @param {string} initialContent   Initial text content for the editor area.
   */
  activateEditor(name, options={}, initialContent="") {
    const editor = this.editors[name];
    if ( !editor ) throw new Error(`${name} is not a registered editor name!`);
    options = mergeObject(editor.options, options);
    options.height = options.target.offsetHeight;
    TextEditor.create(options, initialContent || editor.initial).then(mce => {
      editor.mce = mce;
      editor.changed = false;
      editor.active = true;
      mce.focus();
      mce.on('change', ev => editor.changed = true);
    });
  }

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

  /**
   * Handle saving the content of a specific editor by name
   * @param {string} name           The named editor to save
   * @param {boolean} [remove]      Remove the editor after saving its content
   * @return {Promise<void>}
   */
  async saveEditor(name, {remove=true}={}) {
    const editor = this.editors[name];
    if ( !editor || !editor.mce ) throw new Error(`${name} is not an active editor name!`);
    editor.active = false;

    // Get the editor data
    const mce = editor.mce;
    const element = mce.getElement();
    const content = mce.getContent();
    element.innerHTML = content;

    // Update the form object
    const submit = this._onSubmit(new Event("mcesave"));

    // Remove the editor and reset the button
    if ( remove ) mce.remove();
    if ( editor.hasButton ) editor.button.style.display = "block";

    // Once submission has finished, destroy the editor
    return submit.then(() => {
      if ( remove ) {
        mce.destroy();
        editor.mce = null;
      }
      editor.changed = false;
    });
  }

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

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

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

    // Determine the preferred editor height
    const 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;
    const initialContent = getProperty(data, name);
    const editorOptions = {
      target: div,
      height: height,
      save_onsavecallback: mce => this.saveEditor(name)
    };

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

    // If we are using a toggle button, delay activation until it is clicked
    if (hasButton) button.onclick = event => {
      button.style.display = "none";
      this.activateEditor(name, editorOptions, initialContent);
    };

    // Otherwise activate immediately
    else this.activateEditor(name, editorOptions, initialContent);
  }

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

  /** @override */
  async close(options={}) {
    const states = Application.RENDER_STATES;
    if ( ![states.RENDERED, states.ERROR].includes(this._state) ) return;
    this._state = states.CLOSING;

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

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

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

    // Close the application itself
    this._state = states.RENDERED;
    return super.close(options);
  }

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

  /**
   * Submit the contents of a Form Application, processing its content as defined by the Application
   * @param {object} [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;
  }

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

  /**
   * @deprecated since 0.7.2
   * @see {@link FormDataExtended}
   */
  static processForm(formElement) {
    console.warn(`You are using FormData.processForm(form) which has been deprecated in favor of the FormDataExtended helper class`);
    return FormDataExtended(formElement);
  };

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

  /**
   * @deprecated since 0.7.3
   * @see {@link FormApplication#activateEditor}
   */
  _createEditor(...args) {
    console.warn("You are using the FormApplication#_createEditor method which has been deprecated in favor of FormApplication#activateEditor")
    return this.activateEditor(...args)
  }
}


/**
 * @deprecated since 0.7.0
 * @see {@link FormApplication.processForm}
 */
validateForm = function(formElement) {
  console.warn(`You are using the validateForm(formData) function which has been deprecated in favor of the FormApplication.processForm static method`);
  return new FormDataExtended(formElement);
};


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


/**
 * 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}
 * @abstract
 * @interface
 *
 * @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);
    this.entity.apps[this.appId] = this;
  }

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

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

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

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

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

  /** @override */
	get isEditable() {
	  return this.options.editable && this.entity.owner;
  }


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

  /** @override */
  get title() {
    return this.entity.name;
  }

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

  /** @override */
  render(force, options) {
    if ( !this.object.compendium && !this.object.hasPerm(game.user, this.options.viewPermission) ) {
      if ( !force ) return; // If rendering is not being forced, fail silently
      const err = game.i18n.localize("SHEETS.EntitySheetPrivate");
      ui.notifications.warn(err);
      return console.warn(err);
    }
    return super.render(force, options);
  }

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

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

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

  /** @override */
  _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 r = event.target;
  if ( (r.tagName !== "INPUT") || (r.type !== "range")) return;
  event.preventDefault();
  event.stopPropagation();

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

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


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

    // Obtain the default language from application settings
    const [defaultLanguage, defaultModule] = (language || "en.core").split(".");

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

    /**
     * The package authorized to provide default language configurations
     * @type {string}
     */
    this.defaultModule = defaultModule;

    /**
     * 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<void>}      A Promise which resolves once languages are initialized
   */
  async initialize() {
    const clientLanguage = await game.settings.get("core", "language");

    // Discover which modules available to the client
    this._discoverSupportedLanguages();

    // Activate the configured language
    await this.setLanguage(clientLanguage || this.lang);

    // Define type labels
    if ( game.system && isObjectEmpty(CONFIG.Actor.typeLabels) ) {
      CONFIG.Actor.typeLabels = game.system.entityTypes.Actor.reduce((obj, t) => {
        obj[t] = `ACTOR.Type${t.titleCase()}`;
        return obj;
      }, {});
    }
    if ( game.system && isObjectEmpty(CONFIG.Item.typeLabels) ) {
      CONFIG.Item.typeLabels = game.system.entityTypes.Item.reduce((obj, t) => {
        obj[t] = `ITEM.Type${t.titleCase()}`;
        return obj;
      }, {});
    }
  }

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

  /**
   * Set a language as the active translation source for the session
   * @param {string} lang       A language string in CONFIG.supportedLanguages
   * @returns {Promise<void>}   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";
    }
    this.lang = lang;

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

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

  /**
   * Discover the available supported languages from the set of packages which are provided
   * @private
   */
  _discoverSupportedLanguages() {
    const sl = CONFIG.supportedLanguages;
    if ( game.view === "join" ) return;

    // 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<object>}    The retrieved translations object
   * @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() ) {
        if ( !module.active && (module.id !== this.defaultModule) ) continue;
        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).catch(err => { return {} });
    if ( resp.status !== 200 ) {
      console.error(`${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 {};
    });
  }

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

  /**
   * Return whether a certain string has a known translation defined.
   * @param {string} stringId     The string key being translated
   * @param {boolean} [fallback]  Allow fallback translations to count?
   * @return {boolean}
   */
  has(stringId, fallback=true) {
    return hasProperty(this.translations, stringId) || hasProperty(this._fallback, stringId);
  }

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

  /**
   * 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
   *
   * @example
   * {
   *   "MYMODULE.MYSTRING": "Hello, this is my module!"
   * }
   * game.i18n.localize("MYMODULE.MYSTRING"); // Hello, this is my module!
   */
  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
   * {
   *   "MYMODULE.GREETING": "Hello {name}, this is my module!"
   * }
   * game.i18n.format("MYMODULE.GREETING", {name: "Andrew"}); // Hello Andrew, this is my module!
   */
  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 = {};


/* -------------------------------------------- */
/*  HTML Template Loading                       */
/* -------------------------------------------- */

/**
 * 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<string>}	  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 {string[]} paths    An array of template file paths to load
 * @return {Promise<string[]>}
 */
async function loadTemplates(paths) {
  return Promise.all(paths.map(p => 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
    });
  });
}


/* -------------------------------------------- */
/*  Handlebars Template Helpers                 */
/* -------------------------------------------- */


/**
 * A collection of Handlebars template helpers which can be used within HTML templates.
 */
class HandlebarsHelpers {

  /**
   * For checkboxes, if the value of the checkbox is true, add the "checked" property, otherwise add nothing.
   * @return {string}
   */
  static checked(value) {
    return Boolean(value) ? "checked" : "";
  }

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

  /**
   * Construct an editor element for rich text editing with TinyMCE
   * @return {Handlebars.SafeString}
   */
  static editor(options) {
    const target = options.hash['target'];
    if ( !target ) throw new Error("You must define the name of a target field.");

    // Enrich the content
    const owner = Boolean(options.hash['owner']);
    const content = TextEditor.enrichHTML(options.hash['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
    const button = Boolean(options.hash['button']);
    const editable = Boolean(options.hash['editable']);
    if ( button && editable ) editor.append($('<a class="editor-edit"><i class="fas fa-edit"></i></a>'));
    return new Handlebars.SafeString(editor[0].outerHTML);
  }

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

  /**
   * Render a file-picker button linked to an <input> field
   * @return {Handlebars.SafeString|string}
   */
  static filePicker(options) {
    const type = options.hash['type'];
    const 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
    const tooltip = game.i18n.localize("FILES.BrowseTooltip");
    return new Handlebars.SafeString(`
    <button type="button" class="file-picker" data-type="${type}" data-target="${target}" title="${tooltip}" tabindex="-1">
        <i class="fas fa-file-import fa-fw"></i>
    </button>`);
  }

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

  /**
   * Translate a provided string key by using the loaded dictionary of localization strings.
   * @return {string}
   *
   * @example <caption>Translate a provided localization string, optionally including formatting parameters</caption>
   * <label>{{localize "ACTOR.Create"}}</label> <!-- "Create Actor" -->
   * <label>{{localize "CHAT.InvalidCommand", command=foo}}</label> <!-- "foo is not a valid chat message command." -->
   */
  static localize(value, options) {
    const data = options.hash;
    return isObjectEmpty(data) ? game.i18n.localize(value) : game.i18n.format(value, data);
  }

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

  /**
   * A string formatting helper to display a number with a certain fixed number of decimals and an explicit sign.
   * @return {string}
   */
  static numberFormat(value, options) {
    const dec = options.hash['decimals'] ?? 0;
    const sign = options.hash['sign'] || false;
    value = parseFloat(value).toFixed(dec);
    if (sign ) return ( value >= 0 ) ? "+"+value : value;
    return value;
  }

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

  /**
   * A helper to create a set of radio checkbox input elements in a named set.
   * The provided keys are the possible radio values while the provided values are human readable labels.
   *
   * @param {string} name         The radio checkbox field name
   * @param {object} choices      A mapping of radio checkbox values to human readable labels
   * @param {string} options.checked    Which key is currently checked?
   * @param {boolean} options.localize  Pass each label through string localization?
   * @return {Handlebars.SafeString}
   *
   * @example <caption>The provided input data</caption>
   * let groupName = "importantChoice";
   * let choices = {a: "Choice A", b: "Choice B"};
   * let chosen = "a";
   *
   * @example <caption>The template HTML structure</caption>
   * <div class="form-group">
   *   <label>Radio Group Label</label>
   *   <div class="form-fields">
   *     {{radioBoxes groupName choices checked=chosen localize=true}}
   *   </div>
   * </div>
   */
  static radioBoxes(name, choices, options) {
    const checked = options.hash['checked'] || null;
    const localize = options.hash['localize'] || false;
    let html = "";
    for ( let [key, label] of Object.entries(choices) ) {
      if ( localize ) label = game.i18n.localize(label);
      const isChecked = checked === key;
      html += `<label class="checkbox"><input type="radio" name="${name}" value="${key}" ${isChecked ? "checked" : ""}> ${label}</label>`;
    }
    return new Handlebars.SafeString(html);
  }

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

  /**
  * A helper to assign 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
  * @return {Handlebars.SafeString}
  */
  static select(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");
  }

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

  /**
   * A helper to create a set of <option> elements in a <select> block based on a provided dictionary.
   * The provided keys are the option values while the provided values are human readable labels.
   * This helper supports both single-select as well as multi-select input fields.
   *
   * @param {object} choices      A mapping of radio checkbox values to human readable labels
   * @param {string|string[]} options.selected    Which key or array of keys that are currently selected?
   * @param {boolean} options.localize  Pass each label through string localization?
   * @return {Handlebars.SafeString}
   *
   * @example <caption>The provided input data</caption>
   * let choices = {a: "Choice A", b: "Choice B"};
   * let value = "a";
   *
   * @example <caption>The template HTML structure</caption>
   * <select name="importantChoice">
   *   {{selectOptions choices selected=value localize=true}}
   * </select>
   */
  static selectOptions(choices, options) {
    const localize = options.hash['localize'] ?? false;
    let selected = options.hash['selected'] ?? null;
    let blank = options.hash['blank'] || null;
    selected = selected instanceof Array ? selected.map(String) : [String(selected)];

    // Create an option
    const option = (key, label) => {
      if ( localize ) label = game.i18n.localize(label);
      let isSelected = selected.includes(key);
      html += `<option value="${key}" ${isSelected ? "selected" : ""}>${label}</option>`
    };

    // Create the options
    let html = "";
    if ( blank ) option("", blank);
    Object.entries(choices).forEach(e => option(...e));
    return new Handlebars.SafeString(html);
  }
}

/**
 * Register all handlebars helpers
 */
Handlebars.registerHelper({
  checked: HandlebarsHelpers.checked,
  editor: HandlebarsHelpers.editor,
  filePicker: HandlebarsHelpers.filePicker,
  numberFormat: HandlebarsHelpers.numberFormat,
  localize: HandlebarsHelpers.localize,
  radioBoxes: HandlebarsHelpers.radioBoxes,
  select: HandlebarsHelpers.select,
  selectOptions: HandlebarsHelpers.selectOptions,
  timeSince: timeSince,
  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 {string} view         The named view which is active for this game instance.
 * @param {Object} data         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(view, data, sessionId, socket) {

    /**
     * The named view which is currently active.
     * Game views include: join, setup, players, license, game, stream
     * @type {string}
     */
    this.view = view;

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

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

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

    /**
     * A mapping of installed modules
     * @type {Map}
     */
    this.modules = new Map((data.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(data.settings || []);

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

    /**
     * A singleton GameTime instance which manages the progression of time within the game world.
     * @type {GameTime}
     */
    this.time = new GameTime(socket);

    /**
     * The id of the active World user, if any
     * @type {string}
     */
    this.userId = data.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<Game>}  A Promise which resolves to the created Game instance
   */
  static async create() {

    // Display ASCII welcome
    console.log(CONST.ASCII);
    let socket = null;

    // Get the current URL
    const url = new URL(window.location.href);
    const view = url.pathname.split("/").pop();

    // Retrieve an existing client session from cookies
    const cookies = Game.getCookies();
    let sessionId = view !== "join" ? cookies.session : null;
    if ( sessionId ) {
      console.log(`${vtt} | Reestablishing existing session ${sessionId}`);
      socket = await this.connect(sessionId);
    } else if ( view !== "join" ) {
      console.error(`No client session ID available, redirecting to login`);
      window.location.href = ROUTE_PREFIX ? `/${ROUTE_PREFIX}/join` : "/join";
    }

    // Obtain necessary world data
    let gameData = {};
    if ( socket ) {
      const worldActive = await this.getWorldStatus(socket);
      gameData = worldActive ? await this.getWorldData(socket) : await this.getSetupData(socket);
    }

    // Create the Game instance
    return new Game(view, 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<object>}  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", () => {
        console.log(`${vtt} | Connected to server socket using session ${sessionId}`);
        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;
  }

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

  /**
   * Get the current World status upon initial connection.
   * @return {Promise<boolean>}
   */
  static async getWorldStatus(socket) {
    const status = await new Promise(resolve => {
      socket.emit("getWorldStatus", resolve);
    });
    console.log(`${vtt} | The game World is currently ${status ? "active" : "not active"}`);
    return status;
  }

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

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

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

  /**
   * Request setup data from server and return it
   * @return {Promise<object>}
   */
  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 Game instance`);
    this.ready = false;
    Hooks.callAll('init');

    // 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 (/\/stream/.test(url)) await this._initializeStreamView();
    else if (/\/players/.test(url)) await this._initializePlayersView();
    else if (/\/join/.test(url)) await this._initializeJoinView();

    // 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<Object>}    A Promise which resolves to the response object from the server
   */
  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
   * @returns {Promise<void>}
   */
  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
   * @returns {Collection<string,Compendium>}
   */
  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() {
    this.webrtc = new AVMaster();
    return this.webrtc.connect()
  }

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

  /**
   * 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);
    ui.controls.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) {
    for ( let f of CONFIG.fontFamilies ) {
      document.fonts.load(`1rem ${f}`);
    }
    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;
        if ( ui.controls ) ui.controls.initialize();
        if ( ui.sidebar ) 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: AVSettings.DEFAULT_WORLD_SETTINGS,
      type: Object,
      onChange: () => game.webrtc.settings.changed()
    });

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

    // Language preference
    game.settings.register("core", "language", {
      name: "SETTINGS.LangN",
      hint: "SETTINGS.LangL",
      scope: "client",
      config: true,
      default: game.i18n.lang,
      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
    });

    // World time
    game.settings.register("core", "time", {
      name: "World Time",
      scope: "world",
      config: false,
      default: 0,
      type: Number,
      onChange: time => this.time.onUpdateWorldTime(time)
    });

    // 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: () => {
        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
    });

    // Left-Click Deselection
    game.settings.register("core", "leftClickRelease", {
      name: "SETTINGS.LClickReleaseN",
      hint: "SETTINGS.LClickReleaseL",
      scope: "client",
      config: true,
      default: false,
      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: () => canvas ? canvas.draw() : null
    });

    // Live Token Drag Preview
    game.settings.register("core", "tokenDragPreview", {
      name: "SETTINGS.TokenDragPreviewN",
      hint: "SETTINGS.TokenDragPreviewL",
      scope: "world",
      config: true,
      default: false,
      type: Boolean
    });

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

    // Animated Token Vision
    game.settings.register("core", "visionAnimation", {
      name: "SETTINGS.AnimVisionN",
      hint: "SETTINGS.AnimVisionL",
      config: true,
      type: Boolean,
      default: true
    });

    // Light Source Flicker
    game.settings.register("core", "lightAnimation", {
      name: "SETTINGS.AnimLightN",
      hint: "SETTINGS.AnimLightL",
      config: true,
      type: Boolean,
      default: true,
      onChange: () => canvas.lighting.activateAnimation()
    });

    // Mipmap Antialiasing
    game.settings.register("core", "mipmap", {
      name: "SETTINGS.MipMapN",
      hint: "SETTINGS.MipMapL",
      config: true,
      type: Boolean,
      default: true,
      onChange: () => canvas ? canvas.draw() : null
    });

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

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

    // Game shutdown
    socket.on("shutdown", data => {
      ui.notifications.info("The game world is shutting down and you will be returned to the server homepage.", {
        permanent: true
      });
      setTimeout(() => window.location.href = ROUTE_PREFIX+"/", 2000);
    });
  }

  /* -------------------------------------------- */
  /*  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", this._onLeftClick);

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

    // Support mousewheel interaction for range input elements
    window.addEventListener("wheel", _handleMouseWheelInputChange, {passive: false});

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

    // Handle changes to the state of the browser window
    window.addEventListener("beforeunload", this._onWindowBeforeUnload);
    window.addEventListener("blur", this._onWindowBlur);
    window.addEventListener("resize", this._onWindowResize);
    if ( this.view === "game" ) {
      history.pushState(null, null, location.href);
      window.addEventListener("popstate", this._onWindowPopState);
    }

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

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

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

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

  /**
   * On a left-click event, remove any currently displayed inline roll tooltip
   * @param {MouseEvent} event    The originating left-click event
   * @private
   */
  _onLeftClick(event) {
    if ([3, 4, 5].includes(event.button)) event.preventDefault();
    const inlineRoll = document.querySelector(".inline-roll.expanded");
    if ( inlineRoll && !event.target.closest(".inline-roll") ) {
      return Roll._collapseInlineResult(inlineRoll);
    }
  }

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

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

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

  /**
   * 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
   */
  _onWindowBeforeUnload(event) {
    if ( canvas?.ready ) canvas.sight.saveFog();
  }

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

  /**
   * Handle cases where the browser window loses focus to reset detection of currently pressed keys
   * @param {Event} event   The originating window.blur event
   * @private
   */
  _onWindowBlur(event) {
    if ( !game.keyboard ) return;
    const dk = game.keyboard._downKeys;
    if (dk.has("Alt")) game.keyboard._onAlt(event, true, {});
    dk.clear();
    game.keyboard._handled.clear();
  }

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

  _onWindowPopState(event) {
    if ( game._goingBack ) return;
    history.pushState(null, null, location.href);
    if ( confirm(game.i18n.localize("APP.NavigateBackConfirm")) ) {
      game._goingBack = true;
      history.back();
      history.back();
    }
  }

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

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

  /**
   * Initialization steps specifically for the game setup view
   * This view is unique because a Game object does not exist for a non-authenticated player
   * @private
   */
  async _initializeJoinView() {
    if ( !SIGNED_EULA ) window.location.href = ROUTE_PREFIX+"/license";
    ui.notifications = new Notifications().render(true);

    // Populate the next session time
    const nextTime = document.getElementById("nextDatetime").value;
    if ( nextTime ) {
      const date = new Date(nextTime);
      document.getElementById("next-date").value = date.toISOString().slice(0, 10);
      document.getElementById("next-time").value = date.toTimeString().split(" ")[0];
      const fmt = new Intl.DateTimeFormat(undefined, {timeZoneName: "short"});
      const tz = fmt.formatToParts().find(p => p.type === "timeZoneName");
      if ( tz ) document.getElementById("next-tz").innerText = ` (${tz.value})`;
    }

    // Handle the join form submission
    const forms = document.querySelectorAll("form");
    for ( let form of forms ) {
      form.submit.disabled = false;
      form.addEventListener("submit", async event => {
        event.preventDefault();

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

        // 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(game.i18n.localize(response.message));
          setTimeout(() => window.location.href = response.redirect, 500 );
        }

        // Notify on failure
        else if ( response.status === "failed" ) {
          ui.notifications.error(game.i18n.localize(response.error ?? response.message));
          form.submit.disabled = false;
        }
      });
    }
  }
}


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

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

/**
 * 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 terms of the roll formula
 * console.log(r.terms);    // [Die, +, 2, +, 4]
 *
 * // Execute the roll
 * r.evaluate();
 *
 * // 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 = this._prepareData(data);

    /**
     * An array of inner terms which were rolled parenthetically
     * @type {DiceTerm[]}
     */
    this._dice = [];

    /**
     * The evaluated results of the Roll
     * @type {Array<number|string>}
     */
    this.results = [];

    /**
     * The identified terms of the Roll
     * @type {Array<Roll|DicePool|DiceTerm|number|string>}
     */
    this.terms = this._identifyTerms(formula);

    /**
     * The original formula before evaluation
     * @type {string}
     */
    this._formula = this.constructor.cleanFormula(this.terms);

    /**
     * An internal flag for whether the Roll object has been rolled
     * @type {boolean}
     * @private
     */
    this._rolled = false;

    /**
     * Cache the evaluated total to avoid re-evaluating it
     * @type {number|null}
     * @private
     */
    this._total = null;
  }

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

  /**
   * A factory method which constructs a Roll instance using the default configured Roll class.
   * @param {any[]} args      Arguments passed to the Roll instance constructor
   * @return {Roll}           The constructed Roll instance
   */
  static create(...args) {
    const cls = CONFIG.Dice.rolls[0];
    return new cls(...args);
  }

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

  /**
   * Replace referenced data attributes in the roll formula with values from the provided data.
   * Data references in the formula use the @attr syntax and would reference the corresponding attr key.
   *
   * @param {string} formula          The original formula within which to replace
   * @param {object} data             The data object which provides replacements
   * @param {string} [missing]        The value that should be assigned to any unmatched keys.
   *                                  If null, the unmatched key is left as-is.
   * @param {boolean} [warn]          Display a warning notification when encountering an un-matched key.
   * @static
   */
  static replaceFormulaData(formula, data, {missing, warn=false}={}) {
    let dataRgx = new RegExp(/@([a-z.0-9_\-]+)/gi);
    return formula.replace(dataRgx, (match, term) => {
      let value = getProperty(data, term);
      if ( value !== undefined ) return String(value).trim();
      if ( warn ) ui.notifications.warn(game.i18n.format("DICE.WarnMissingData", {match}));
      if ( missing !== undefined ) return String(missing);
      else return match;
    });
  }

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

  /**
   * Return an Array of the individual DiceTerm instances contained within this Roll.
   * @return {DiceTerm[]}
   */
  get dice() {
    return this._dice.concat(this.terms.reduce((dice, t) => {
      if ( t instanceof DiceTerm ) dice.push(t);
      else if ( t instanceof DicePool ) dice = dice.concat(t.dice);
      return dice;
    }, []));
  }

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

  /**
   * Return a standardized representation for the displayed formula associated with this Roll.
   * @return {string}
   */
  get formula() {
    return this.constructor.cleanFormula(this.terms);
  }

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

  /**
   * The resulting arithmetic expression after rolls have been evaluated
   * @return {string|null}
   */
  get result() {
    if ( !this._rolled ) return null;
    return this.results.join(" ");
  }

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

  /**
   * Return the total result of the Roll expression if it has been evaluated, otherwise null
   * @type {number|null}
   */
  get total() {
    if ( !this._rolled ) return null;
    return this._total;
  }

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

  /**
   * Alter the Roll expression by adding or multiplying the number of dice which are rolled
   * @param {number} multiply   A factor to multiply. Dice are multiplied before any additions.
   * @param {number} add        A number of dice to add. Dice are added after multiplication.
   * @param {boolean} [multiplyNumeric]  Apply multiplication factor to numeric scalar terms
   * @return {Roll}             The altered Roll expression
   */
  alter(multiply, add, {multiplyNumeric=false}={}) {
    if ( this._rolled ) throw new Error("You may not alter a Roll which has already been rolled");
    multiply = parseInt(multiply);
    this.terms = this.terms.map(t => {
      if ( t.alter ) return t.alter(multiply, add, {multiplyNumeric});
      else if ( (typeof t === "number") && multiplyNumeric ) return t * multiply;
      return t;
    });

    // Update the altered formula and return the altered Roll
    this._formula = this.formula;
    return this;
  }

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

  /**
   * Execute the Roll, replacing dice and evaluating the total result
   *
   * @param {boolean} [minimize]    Produce the minimum possible result from the Roll instead of a random result.
   * @param {boolean} [maximize]    Produce the maximum possible result from the Roll instead of a random result.
   *
   * @returns {Roll}    The rolled Roll object, able to be chained into other methods
   *
   * @example
   * let r = new Roll("2d6 + 4 + 1d4");
   * r.evaluate();
   * console.log(r.result); // 5 + 4 + 2
   * console.log(r.total);  // 11
   */
  evaluate({minimize=false, maximize=false}={}) {
    if ( this._rolled ) throw new Error("This Roll object has already been rolled.");

    // Step 1 - evaluate any inner Rolls and recompile the formula
    let hasInner = false;
    this.terms = this.terms.map((t, i, terms) => {
      if ( t instanceof Roll ) {
        hasInner = true;
        t.evaluate({minimize, maximize});
        this._dice = this._dice.concat(t.dice);
        const priorMath = (i > 0) && (terms[i-1].split(" ").pop() in Math);
        return priorMath ? `(${t.total})` : String(t.total);
      }
      return t;
    });

    // Step 2 - if inner rolls occurred, re-compile the formula and re-identify terms
    if ( hasInner ) {
      const formula = this.constructor.cleanFormula(this.terms);
      this.terms = this._identifyTerms(formula);
    }

    // Step 3 - evaluate any remaining terms
    this.results = this.terms.map(term => {
      if ( term.evaluate ) return term.evaluate({minimize, maximize}).total;
      else return term;
    });

    // Step 4 - safely evaluate the final total
    const total = this._safeEval(this.results.join(" "));
    if ( !Number.isNumeric(total) ) {
      throw new Error(game.i18n.format("DICE.ErrorNonNumeric", {formula: this.formula}));
    }

    // Store final outputs
    this._total = total;
    this._rolled = true;
    return this;
  }

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

  /**
   * Clone the Roll instance, returning a new Roll instance that has not yet been evaluated
   * @return {Roll}
   */
  clone() {
    return new this.constructor(this._formula, this.data);
  }

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

  /**
   * Evaluate and return the Roll expression.
   * This function simply calls the evaluate() method but is maintained for backwards compatibility.
   * @return {Roll}   The Roll instance, containing evaluated results and the rolled total.
   */
  roll() {
    return this.evaluate();
  }

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

  /**
   * 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.
   *
   * @return {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();
  }

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

  /**
   * Simulate a roll and evaluate the distribution of returned results
   * @param {string} formula    The Roll expression to simulate
   * @param {number} n          The number of simulations
   * @return {number[]}         The rolled totals
   */
  static simulate(formula, n=10000) {
    const results = [...Array(n)].map(i => {
      let r = new this(formula);
      return r.evaluate().total;
    }, []);
    const summary = results.reduce((sum, v) => {
      sum.total = sum.total + v;
      if ( (sum.min === null) || (v < sum.min) ) sum.min = v;
      if ( (sum.max === null) || (v > sum.max) ) sum.max = v;
      return sum;
    }, {total: 0, min: null, max: null});
    summary.mean = summary.total / n;
    console.log(`Formula: ${formula} | Iterations: ${n} | Mean: ${summary.mean} | Min: ${summary.min} | Max: ${summary.max}`);
    return results;
  }

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

  /**
   * Validate that a provided roll formula can represent a valid
   * @param {string} formula    A candidate formula to validate
   * @return {boolean}          Is the provided input a valid dice formula?
   */
  static validate(formula) {

    // Replace all data references with an arbitrary number
    formula = formula.replace(/@([a-z.0-9_\-]+)/gi, "1");

    // Attempt to evaluate the roll
    try {
      const r = new this(formula);
      r.evaluate();
      return true;
    }

    // If we weren't able to evaluate, the formula is invalid
    catch(err) {
      return false;
    }
  }

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

  /**
   * Create a formula string from an array of Dice terms.
   * @return {string}
   */
  static cleanFormula(terms) {
    terms = this.cleanTerms(terms).map(t => {
      if ( t instanceof Roll ) return `(${t.formula})`;
      return t.formula || String(t);
    }).join("");
    let formula = terms.replace(/ /g, "");
    return formula.replace(new RegExp(this.ARITHMETIC.map(o => "\\"+o).join("|"), "g"), " $& ");
  }

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

  /**
   * Clean the terms of a Roll equation, removing empty space and de-duping arithmetic operators
   * @param {Array<DiceTerm|string|number>} terms  The input array of terms
   * @return {Array<DiceTerm|string|number>}       The cleaned array of terms
   */
  static cleanTerms(terms) {
    return terms.reduce((cleaned, t, i, terms) => {
      if ( typeof t === "string" ) t = t.trim();
      if ( t === "" ) return cleaned;
      let prior = terms[i-1];

      // De-dupe addition and multiplication
      if ( ["+", "*"].includes(t) && prior === t ) return cleaned;

      // Negate double subtraction
      if ( (t === "-") && (prior === "-" ) ) {
        cleaned[i-1] = "+";
        return cleaned;
      }

      // Negate double division
      if ( (t === "/") && (prior === "/") ) {
        cleaned[i-1] = "*";
        return cleaned;
      }

      // Subtraction and negative values
      if ( ["-+", "+-"].includes(t+prior) ) {
        cleaned[i-1] = "-";
        return cleaned;
      }

      // Return the clean array
      cleaned.push(t);
      return cleaned;
    }, []);
  }

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

  /**
   * Split a provided Roll formula to identify it's component terms.
   * Some terms are very granular, like a Number of an arithmetic operator
   * Other terms are very coarse like an entire inner Roll from a parenthetical expression.
   * As a general rule, this function should return an Array of terms which are ready to be evaluated immediately.
   * Some terms may require recursive evaluation.
   * @private
   *
   * @param {string} formula  The formula to parse
   * @return {Array<Roll|DicePool|DiceTerm|number|string>}       An array of identified terms
   */
  _identifyTerms(formula) {
    if ( typeof formula !== "string" ) throw new Error("The formula provided to a Roll instance must be a string");

    // Step 1 - Update the Roll formula using provided data
    formula = this.constructor.replaceFormulaData(formula, this.data, {missing: "0", warn: true});

    // Step 2 - identify separate parenthetical terms
    let terms = this._splitParentheticalTerms(formula);

    // Step 3 - expand pooled terms
    terms = this._splitPooledTerms(terms);

    // Step 4 - expand remaining arithmetic terms
    terms = terms.reduce((terms, term, i, array) => {
      if ( typeof term !== "string" ) return terms.concat([term]);
      if ( array[i-1] instanceof Roll || array[i+1] instanceof Roll ) return terms.concat([term]);
      return terms.concat(this._splitDiceTerms(term));
    }, []);

    // Step 5 - clean and de-dupe terms
    terms = this.constructor.cleanTerms(terms);
    return terms;
  }

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

  /**
   * Prepare the data structure used for the Roll.
   * This is factored out to allow for custom Roll classes to do special data preparation using provided input.
   * @param {object} data   Provided roll data
   * @private
   */
  _prepareData(data) {
    return data;
  }

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

  /**
   * Identify and split a formula into separate terms by arithmetic terms
   * @private
   */
  _splitDiceTerms(formula) {
    const operators = this.constructor.ARITHMETIC.concat(["(", ")"]);
    const arith = new RegExp(operators.map(o => "\\"+o).join("|"), "g");
    const split = formula.replace(arith, ";$&;");
    const terms = split.split(";").reduce((arr, term) => {
      term = term.trim();
      if ( term === "" ) return arr;
      if ( this.constructor.ARITHMETIC.includes(term) ) arr.push(term);
      else if ( Number.isNumeric(term) ) arr.push(Number(term));
      else {
        const die = DiceTerm.fromExpression(term);
        arr.push(die || term);
      }
      return arr;
    }, []);
    return terms;
  }

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

  /**
   * Identify and split a formula into separate terms by parenthetical expressions
   * @private
   */
  _splitParentheticalTerms(formula) {

    // Augment parentheses with semicolons and split into terms
    const split = formula.replace(/\(/g, ";(;").replace(/\)/g, ";);");

    // Match outer-parenthetical groups
    let nOpen = 0;
    const terms = split.split(";").reduce((arr, t, i, terms) => {
      if ( t === "" ) return arr;

      // Identify whether the left-parentheses opens a math function
      let mathFn = false;
      if ( t === "(" ) {
        const fn = terms[i-1].match(/(?:\s)?([A-z0-9]+)$/);
        mathFn = fn && !!Roll.MATH_PROXY[fn[1]];
      }

      // Combine terms using open parentheses and math expressions
      if ( (nOpen > 0) || mathFn ) arr[arr.length - 1] += t;
      else arr.push(t);

      // Increment the count
      if ( (t === "(") ) nOpen++;
      else if ( (t === ")") && ( nOpen > 0 ) ) nOpen--;
      return arr;
    }, []);

    // Close any un-closed parentheses
    for ( let i=0; i<nOpen; i++ ) terms[terms.length - 1] += ")";

    // Substitute parenthetical dice rolls groups to inner Roll objects
    return terms.reduce((terms, term) => {
      const prior = terms.length ? terms[terms.length-1] : null;
      if ( term[0] === "(" ) {

        // Handle inner Roll parenthetical groups
        if ( /[dD]/.test(term) ) {
          terms.push(Roll.fromTerm(term, this.data));
          return terms;
        }

        // Evaluate arithmetic-only parenthetical groups
        term = this._safeEval(term);
        term = Number.isInteger(term) ? term : term.toFixed(2);

        // Continue wrapping math functions
        const priorMath = prior && (prior.split(" ").pop() in Math);
        if ( priorMath ) term = `(${term})`;
      }

      // Append terms to to non-Rolls
      if ((prior !== null) && !(prior instanceof Roll)) terms[terms.length-1] += term;
      else terms.push(term);
      return terms;
    }, []);
  }

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

  /**
   * Identify and split a formula into separate terms by curly braces which represent pooled expressions
   * @private
   */
  _splitPooledTerms(terms) {

    // First re-organize the terms by splitting on curly braces
    let nOpen = 0;
    terms = terms.reduce((terms, term) => {

      // Force immediate processing of inner objects which are encountered within an open outer pool
      if ( (term instanceof Roll) || (term instanceof DicePool) ) {
        if ( nOpen > 0 ) {
          term.evaluate();
          this._dice = this._dice.concat(term.dice);
          term = term.total;
        }
        else {
          terms.push(term);
          return terms;
        }
      }
      term = String(term);

      // Match outer-bracketed groups
      const parts = term.replace(/{/g, ';{;').replace(/}([A-z0-9<=>]+)?/g, '$&;').split(";");
      for ( let t of parts ) {
        if ( t === "" ) continue;
        if ( nOpen > 0 ) terms[terms.length - 1] += t;
        else terms.push(t);
        if ( t === "{" ) nOpen = Math.max(1, nOpen + 1);
        if ( /}/.test(t) ) nOpen = Math.max(0, nOpen - 1);
      }
      return terms;
    }, []);

    // Close any un-closed pools
    for ( let i=0; i<nOpen; i++ ) terms[terms.length - 1] += "}";

    // Convert term groups to DicePool objects
    return terms.reduce((terms, term) => {
      if ( term === "" ) return terms;
      const isClosedPool = (term[0] === "{") && (term.indexOf("}") !== -1);
      if ( isClosedPool ) terms.push(DicePool.fromExpression(term, {}, this.data));
      else terms.push(term);
      return terms;
    }, []);
  }

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

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

  /* -------------------------------------------- */
  /*  Chat Messages                               */
  /* -------------------------------------------- */

  /**
   * Render the tooltip HTML for a Roll instance
   * @return {Promise<HTMLElement>}
   */
  getTooltip() {
    const parts = this.dice.map(d => {
      const cls = d.constructor;
      return {
        formula: d.formula,
        total: d.total,
        faces: d.faces,
        flavor: d.options.flavor,
        rolls: d.results.map(r => {
          const hasSuccess = r.success !== undefined;
          const hasFailure = r.failure !== undefined;
          const isMax = r.result === d.faces;
          const isMin = r.result === 1;
          return {
            result: cls.getResultLabel(r.result),
            classes: [
              cls.name.toLowerCase(),
              "d" + d.faces,
              r.success ? "success" : null,
              r.failure ? "failure" : null,
              r.rerolled ? "rerolled" : null,
              r.exploded ? "exploded" : null,
              r.discarded ? "discarded" : null,
              !(hasSuccess || hasFailure) && isMin ? "min" : null,
              !(hasSuccess || hasFailure) && isMax ? "max" : null
            ].filter(c => c).join(" ")
          }
        })
      };
    });
    return renderTemplate(this.constructor.TOOLTIP_TEMPLATE, { parts });
  }

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

  /**
   * 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: this.constructor.CHAT_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);
  }

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

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

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

  /* -------------------------------------------- */
  /*  Saving and Loading                          */
  /* -------------------------------------------- */

  /**
   * Represent the data of the Roll as an object suitable for JSON serialization.
   * @return {Object}     Structured data which can be serialized into JSON
   */
  toJSON() {
    if ( !this._rolled ) throw new Error(`You cannot serialize an un-rolled Roll object`);
    return {
      class: this.constructor.name,
      dice: this._dice,
      formula: this._formula,
      terms: this.terms,
      results: this.results,
      total: this._total
    }
  }

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

  /**
   * Recreate a Roll instance using a provided data object
   * @param {object} data   Unpacked data representing the Roll
   * @return {Roll}         A reconstructed Roll instance
   */
  static fromData(data) {
    if (!("terms" in data)) {
      data = this._backwardsCompatibleRoll(data);
    }

    // Create the Roll instance
    const roll = new this(data.formula);
    roll.results = data.results;
    roll._total = data.total;
    roll._dice = data.dice.map(t => DiceTerm.fromData(t));

    // Expand terms
    roll.terms = data.terms.map(t => {
      if ( t.class ) {
        if ( t.class === "DicePool" ) return DicePool.fromData(t);
        else return DiceTerm.fromData(t);
      }
      return t;
    });

    // Return the reconstructed roll
    roll._rolled = true;
    return roll;
  }

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

  /**
   * Recreate a Roll instance using a provided JSON string
   * @param {string} json   Serialized JSON data representing the Roll
   * @return {Roll}         A reconstructed Roll instance
   */
  static fromJSON(json) {
    const data = JSON.parse(json);
    const cls = CONFIG.Dice.rolls.find(cls => cls.name === data.class);
    if ( !cls ) throw new Error(`Unable to recreate ${data.class} instance from provided data`);
    return cls.fromData(data);
  }

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

  /**
   * Construct a new Roll object from a parenthetical term of an outer Roll.
   * @param {string} term     The isolated parenthetical term, for example (4d6)
   * @param {object} data     The Roll data object, provided by the outer Roll
   * @return {Roll}           An inner Roll object constructed from the term
   */
  static fromTerm(term, data) {
    const match = term.match(this.PARENTHETICAL_RGX);
    return new this(match ? match[1] : term, data);
  }

  /* -------------------------------------------- */
  /*  Interface Helpers                           */
  /* -------------------------------------------- */

  /**
   * Expand an inline roll element to display it's contained dice result as a tooltip
   * @param {HTMLAnchorElement} a     The inline-roll button
   * @return {Promise<void>}
   * @private
   */
  static async _expandInlineResult(a) {
    if ( !a.classList.contains("inline-roll") ) return;
    if ( a.classList.contains("expanded") ) return;

    // Create a new tooltip
    const roll = Roll.fromJSON(unescape(a.dataset.roll));
    const tip = document.createElement("div");
    tip.innerHTML = await roll.getTooltip();

    // Add the tooltip
    const tooltip = tip.children[0];
    a.appendChild(tooltip);
    a.classList.add("expanded");

    // Set the position
    const pa = a.getBoundingClientRect();
    const pt = tooltip.getBoundingClientRect();
    tooltip.style.left = `${Math.min(pa.x, window.innerWidth - (pt.width + 3))}px`;
    tooltip.style.top = `${Math.min(pa.y + pa.height + 3, window.innerHeight - (pt.height + 3))}px`;
    const zi = getComputedStyle(a).zIndex;
    tooltip.style.zIndex = Number.isNumeric(zi) ? zi + 1 : 100;
  }

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

  /**
   * Collapse an expanded inline roll to conceal it's tooltip
   * @param {HTMLAnchorElement} a     The inline-roll button
   * @private
   */
  static _collapseInlineResult(a) {
    if ( !a.classList.contains("inline-roll") ) return;
    if ( !a.classList.contains("expanded") ) return;
    const tooltip = a.querySelector(".dice-tooltip");
    if ( tooltip ) tooltip.remove();
    return a.classList.remove("expanded");
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * Provide backwards compatibility for Roll data prior to 0.7.0
   * @deprecated since 0.7.0
   * @private
   */
  static _backwardsCompatibleRoll(data) {
    data.terms = data.parts.map(p => {
      if ( /_d[0-9]+/.test(p) ) {
        let i = parseInt(p.replace("_d", ""));
        return data.dice[i];
      }
      return p;
    });
    delete data.parts;
    data.dice = [];
    data.results = data.result.split(" + ").map(Number);
    delete data.result;
    return data;
  }

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

  /**
   * @deprecated since 0.7.0
   * @see {@link Roll#terms}
   */
  get parts() {
    console.warn(`You are referencing Roll#parts which is now deprecated in favor of Roll#terms`);
    return this.terms;
  }

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

  /**
   * @deprecated since 0.7.0
   * @see {@link Roll#evaluate}
   */
  static minimize(formula) {
    console.warn(`The Roll.minimize(formula) function is deprecated in favor of Roll#evaluate({minimize: true})`);
    return new this(formula).evaluate({minimize: true});
  }

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

  /**
   * @deprecated since 0.7.0
   * @see {@link Roll#evaluate}
   */
  static maximize(formula) {
    console.warn(`The Roll.maximize(formula) function is deprecated in favor of Roll#evaluate({maximize: true})`);
    return new this(formula).evaluate({maximize: true});
  }
}

/**
 * Allowed arithmetic operators which can join together terms in a Roll expression
 * @type {string[]}
 */
Roll.ARITHMETIC = ["+", "-", "*", "/"];

/**
 * A Proxy environment for safely evaluating a string using only available Math functions
 * @type {Math}
 */
Roll.MATH_PROXY = new Proxy(Math, {has: () => true, get: (t, k) => k === Symbol.unscopables ? undefined : t[k]});

/**
 * A regular expression used to identify the Roll formula for parenthetical terms
 * @type {RegExp}
 */
Roll.PARENTHETICAL_RGX = /^\((.*)\)$/;

Roll.CHAT_TEMPLATE = "templates/dice/roll.html";
Roll.TOOLTIP_TEMPLATE = "templates/dice/tooltip.html";
/**
 * An abstract base class for any term which appears in a dice roll formula
 * @abstract
 *
 * @param {object} termData                 Data used to create the Dice Term, including the following:
 * @param {number} termData.number          The number of dice of this term to roll, before modifiers are applied
 * @param {number} termData.faces           The number of faces on each die of this type
 * @param {string[]} termData.modifiers     An array of modifiers applied to the results
 * @param {object} termData.options         Additional options that modify the term
 */
class DiceTerm {
  constructor({number=1, faces=6, modifiers=[], options={}}={}) {

    /**
     * The number of dice of this term to roll, before modifiers are applied
     * @type {number}
     */
    this.number = number;

    /**
     * The number of faces on the die
     * @type {number}
     */
    this.faces = faces;

    /**
     * An Array of dice term modifiers which are applied
     * @type {string[]}
     */
    this.modifiers = modifiers;

    /**
     * An object of additional options which modify the dice term
     * @type {object}
     */
    this.options = options;

    /**
     * The array of dice term results which have been rolled
     * @type {object[]}
     */
    this.results = [];

    /**
     * An internal flag for whether the dice term has been evaluated
     * @type {boolean}
     * @private
     */
    this._evaluated = false;
  }

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

  /**
   * Return a standardized representation for the displayed formula associated with this DiceTerm
   * @return {string}
   */
  get formula() {
    const x = this.constructor.DENOMINATION === "d" ? this.faces : this.constructor.DENOMINATION;
    return `${this.number}d${x}${this.modifiers.join("")}`;
  }

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

  /**
   * Return the total result of the DiceTerm if it has been evaluated
   * @type {number|null}
   */
  get total() {
    if ( !this._evaluated ) return null;
    return this.results.reduce((t, r) => {
      if ( !r.active ) return t;
      if ( r.count !== undefined ) return t + r.count;
      else return t + r.result;
    }, 0);
  }

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

  /**
   * Return an array of rolled values which are still active within this term
   * @type {number[]}
   */
  get values() {
    return this.results.reduce((arr, r) => {
      if ( !r.active ) return arr;
      arr.push(r.result);
      return arr;
    }, []);
  }

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

  /**
   * Alter the DiceTerm by adding or multiplying the number of dice which are rolled
   * @param {number} multiply   A factor to multiply. Dice are multiplied before any additions.
   * @param {number} add        A number of dice to add. Dice are added after multiplication.
   * @return {DiceTerm}         The altered term
   */
  alter(multiply, add) {
    if ( this._evaluated ) throw new Error(`You may not alter a DiceTerm after it has already been evaluated`);
    multiply = parseInt(multiply);
    if ( multiply >= 0 ) this.number *= multiply;
    add = parseInt(add);
    if ( add ) this.number += add;
    return this;
  }

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

  /**
   * Evaluate the roll term, populating the results Array.
   * @param {boolean} [minimize]    Apply the minimum possible result for each roll.
   * @param {boolean} [maximize]    Apply the maximum possible result for each roll.
   * @returns {DiceTerm}    The evaluated dice term
   */
  evaluate({minimize=false, maximize=false}={}) {
    if ( this._evaluated ) {
      throw new Error(`This ${this.constructor.name} has already been evaluated and is immutable`);
    }

    // Roll the initial number of dice
    for ( let n=1; n <= this.number; n++ ) {
      this.roll({minimize, maximize});
    }

    // Apply modifiers
    this._evaluateModifiers();

    // Return the evaluated term
    this._evaluated = true;
    return this;
  }

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

  /**
   * Roll the DiceTerm by mapping a random uniform draw against the faces of the dice term.
   * @param {boolean} [minimize]    Apply the minimum possible result instead of a random result.
   * @param {boolean} [maximize]    Apply the maximum possible result instead of a random result.
   * @return {object}
   */
  roll({minimize=false, maximize=false}={}) {
    const rand = CONFIG.Dice.randomUniform();
    let result = Math.ceil(rand * this.faces);
    if ( minimize ) result = 1;
    if ( maximize ) result = this.faces;
    const roll = {result, active: true};
    this.results.push(roll);
    return roll;
  }

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

  /**
   * Return a string used as the label for each rolled result
   * @param {string} result     The numeric result
   * @return {string}           The result label
   */
  static getResultLabel(result) {
    return result;
  }

  /* -------------------------------------------- */
  /*  Modifier Helpers                            */
  /* -------------------------------------------- */

  /**
   * Sequentially evaluate each dice roll modifier by passing the term to its evaluation function
   * Augment or modify the results array.
   * @private
   */
  _evaluateModifiers() {
    const cls = this.constructor;
    for ( let m of this.modifiers ) {
      const command = m.match(/[A-z]+/)[0].toLowerCase();
      let fn = cls.MODIFIERS[command];
      if ( typeof fn === "string" ) fn = this[fn];
      if ( fn instanceof Function ) {
        fn.call(this, m);
      }
    }
  }

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

  /**
   * A helper comparison function.
   * Returns a boolean depending on whether the result compares favorably against the target.
   * @param {number} result         The result being compared
   * @param {string} comparison     The comparison operator in [=,<,<=,>,>=]
   * @param {number} target         The target value
   * @return {boolean}              Is the comparison true?
   */
  static compareResult(result, comparison, target) {
    switch ( comparison ) {
      case "=":
        return result === target;
      case "<":
        return result < target;
      case "<=":
        return result <= target;
      case ">":
        return result > target;
      case ">=":
        return result >= target;
    }
  }

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

  /**
   * A helper method to modify the results array of a dice term by flagging certain results are kept or dropped.
   * @param {object[]} results      The results array
   * @param {number} number         The number to keep or drop
   * @param {boolean} [keep]        Keep results?
   * @param {boolean} [highest]     Keep the highest?
   * @return {object[]}             The modified results array
   */
  static _keepOrDrop(results, number, {keep=true, highest=true}={}) {

    // Determine the direction and the number to discard
    const ascending = keep === highest;
    number = keep ? results.length - number : number;

    // Determine the cut point to discard
    const values = results.map(r => r.result);
    values.sort((a, b) => ascending ? a - b : b - a);
    const cut = values[number];

    // Track progress
    let discarded = 0;
    const ties = [];
    let comp = ascending ? "<" : ">";

    // First mark results on the wrong side of the cut as discarded
    results.forEach(r => {
      let discard = this.compareResult(r.result, comp, cut);
      if ( discard ) {
        r.discarded = true;
        r.active = false;
        discarded++;
      }
      else if ( r.result === cut ) ties.push(r);
    });

    // Next discard ties until we have reached the target
    ties.forEach(r => {
      if ( discarded < number ) {
        r.discarded = true;
        r.active = false;
        discarded++;
      }
    });
    return results;
  }

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

  /**
   * A reusable helper function to handle the identification and deduction of failures
   */
  static _applyCount(results, comparison, target, {flagSuccess=false, flagFailure=false}={}) {
    for ( let r of results ) {
      let success = this.compareResult(r.result, comparison, target);
      if (flagSuccess) {
        r.success = success;
        if (success) delete r.failure;
      }
      else if (flagFailure ) {
        r.failure = success;
        if (success) delete r.success;
      }
      r.count = success ? 1 : 0;
    }
  }

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

  /**
   * A reusable helper function to handle the identification and deduction of failures
   */
  static _applyDeduct(results, comparison, target, {deductFailure=false, invertFailure=false}={}) {
    for ( let r of results ) {

      // Flag failures if a comparison was provided
      if (comparison) {
        const fail = this.compareResult(r.result, comparison, target);
        if ( fail ) {
          r.failure = true;
          delete r.success;
        }
      }

      // Otherwise treat successes as failures
      else {
        if ( r.success === false ) {
          r.failure = true;
          delete r.success;
        }
      }

      // Deduct failures
      if ( deductFailure ) {
        if ( r.failure ) r.count = -1;
      }
      else if ( invertFailure ) {
        if ( r.failure ) r.count = -1 * r.result;
      }
    }
  }

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

  /**
   * Construct a DiceTerm from a provided data object
   * @param {object} data         Provided data from an un-serialized term
   * @return {DiceTerm}           The constructed DiceTerm
   */
  static fromData(data) {
    // TODO: Backwards compatibility for pre-0.7.0 dice
    if (!("number" in data)) data = this._backwardsCompatibleTerm(data);
    const cls = Object.values(CONFIG.Dice.terms).find(c => c.name === data.class) || Die;
    return cls.fromResults(data, data.results);
  }

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

  /**
   * Parse a provided roll term expression, identifying whether it matches this type of term.
   * @param {string} expression
   * @param {object} options            Additional term options
   * @return {DiceTerm|null}            The constructed DiceTerm instance
   */
  static fromExpression(expression, options={}) {
    const match = this.matchTerm(expression);
    if ( !match ) return null;
    let [number, denomination, modifiers, flavor] = match.slice(1);

    // Get the denomination of DiceTerm
    denomination = denomination.toLowerCase();
    const term = denomination in CONFIG.Dice.terms ? CONFIG.Dice.terms[denomination] : Die;
    if ( !term ) throw new Error(`Die denomination ${denomination} not registered in CONFIG.Dice.terms`);

    // Get the term arguments
    number = Number.isNumeric(number) ? parseInt(number) : 1;
    const faces = Number.isNumeric(denomination) ? parseInt(denomination) : null;
    modifiers = Array.from((modifiers || "").matchAll(DiceTerm.MODIFIER_REGEX)).map(m => m[0]);
    if ( flavor ) options.flavor = flavor;

    // Construct a term of the appropriate denomination
    return new term({number, faces, modifiers, options});
  }

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

  /**
   * Check if the expression matches this type of term
   * @param {string} expression
   * @return {RegExpMatchArray|null}
   */
  static matchTerm(expression) {
    const rgx = new RegExp(`^([0-9]+)?[dD]([A-z]|[0-9]+)${DiceTerm.MODIFIERS_REGEX}${DiceTerm.FLAVOR_TEXT_REGEX}`);
    const match = expression.match(rgx);
    return match || null;
  }

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

  /**
   * Create a "fake" dice term from a pre-defined array of results
   * @param {object} options        Arguments used to initialize the term
   * @param {object[]} results      An array of pre-defined results
   * @return {DiceTerm}
   *
   * @example
   * let d = new Die({faces: 6, number: 4, modifiers: ["r<3"]});
   * d.evaluate();
   * let d2 = Die.fromResults({faces: 6, number: 4, modifiers: ["r<3"]}, d.results);
   */
  static fromResults(options, results) {
    const term = new this(options);
    term.results = results;
    term._evaluated = true;
    return term;
  }

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

  /**
   * Serialize the DiceTerm to a JSON string which allows it to be saved in the database or embedded in text.
   * This method should return an object suitable for passing to the JSON.stringify function.
   * @return {object}
   */
  toJSON() {
    return {
      class: this.constructor.name,
      number: this.number,
      faces: this.faces,
      modifiers: this.modifiers,
      options: this.options,
      results: this.results
    }
  }

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

  /**
   * Reconstruct a DiceTerm instance from a provided JSON string
   * @param {string} json   A serialized JSON representation of a DiceTerm
   * @return {DiceTerm}     A reconstructed DiceTerm from the provided JSON
   */
  static fromJSON(json) {
    let data;
    try {
      data = JSON.parse(json);
    } catch(err) {
      throw new Error("You must pass a valid JSON string");
    }
    return this.fromData(data);
  }

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

  /**
   * Provide backwards compatibility for Die syntax prior to 0.7.0
   * @private
   */
  static _backwardsCompatibleTerm(data) {
    const match = this.matchTerm(data.formula);
    data.number = parseInt(match[1]);
    data.results = data.rolls.map(r => {
      r.result = r.roll;
      delete r.roll;
      r.active = r.active !== false;
      return r;
    });
    delete data.rolls;
    delete data.formula;
    return data;
  }
}

/**
 * Define the denomination string used to register this Dice type in CONFIG.Dice.terms
 * @return {string}
 * @public
 */
DiceTerm.DENOMINATION = "";

/**
 * Define the modifiers that can be used for this particular DiceTerm type.
 * @type {{string: (string|Function)}}
 * @public
 */
DiceTerm.MODIFIERS = {};

/**
 * A regular expression pattern which identifies a potential DiceTerm modifier
 * @type {RegExp}
 * @public
 */
DiceTerm.MODIFIER_REGEX = /([A-z]+)([^A-z\s()+\-*\/]+)?/g;

/**
 * A regular expression pattern which indicates the end of a DiceTerm
 * @type {string}
 * @public
 */
DiceTerm.MODIFIERS_REGEX = "([^ ()+\\-/*\\[]+)?";

/**
 * A regular expression pattern which identifies part-specific flavor text
 * @type {string}
 * @public
 */
DiceTerm.FLAVOR_TEXT_REGEX = "(?:\\[(.*)\\])?";

/**
 * A standalone, pure JavaScript implementation of the Mersenne Twister pseudo random number generator.
 *
 * @author Raphael Pigulla <pigulla@four66.com>
 * @version 0.2.3
 * @license
 * Copyright (C) 1997 - 2002, Makoto Matsumoto and Takuji Nishimura,
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 * 1. Redistributions of source code must retain the above copyright
 * notice, this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright
 * notice, this list of conditions and the following disclaimer in the
 * documentation and/or other materials provided with the distribution.
 *
 * 3. The names of its contributors may not be used to endorse or promote
 * products derived from this software without specific prior written
 * permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
class MersenneTwister {
  /**
   * Instantiates a new Mersenne Twister.
   * @param {number} [seed]   The initial seed value, if not provided the current timestamp will be used.
   * @constructor
   */
  constructor(seed) {

    // Initial values
    this.MAX_INT = 4294967296.0;
    this.N = 624;
    this.M = 397;
    this.UPPER_MASK = 0x80000000;
    this.LOWER_MASK = 0x7fffffff;
    this.MATRIX_A = 0x9908b0df;

    // Initialize sequences
    this.mt = new Array(this.N);
    this.mti = this.N + 1;
    this.SEED = this.seed(seed ?? new Date().getTime());
  };

  /**
   * 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) {
    this.SEED = seed;
    let s;
    this.mt[0] = seed >>> 0;

    for (this.mti = 1; this.mti < this.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;
    }
    return seed;
  };

  /**
   * 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 = this.N > vector.length ? this.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 >= this.N) {
        this.mt[0] = this.mt[this.N-1];
        i = 1;
      }
      if (j >= vector.length) {
        j = 0;
      }
    }

    for (k = this.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 >= this.N) {
        this.mt[0] = this.mt[this.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 = [0, this.MATRIX_A];

    if (this.mti >= this.N) {
      if (this.mti === this.N+1) {
        this.seed(5489);
      }

      for (kk = 0; kk < this.N - this.M; kk++) {
        y = (this.mt[kk] & this.UPPER_MASK) | (this.mt[kk + 1] & this.LOWER_MASK);
        this.mt[kk] = this.mt[kk + this.M] ^ (y >>> 1) ^ mag01[y & 1];
      }

      for (; kk < this.N - 1; kk++) {
        y = (this.mt[kk] & this.UPPER_MASK) | (this.mt[kk + 1] & this.LOWER_MASK);
        this.mt[kk] = this.mt[kk + (this.M - this.N)] ^ (y >>> 1) ^ mag01[y & 1];
      }

      y = (this.mt[this.N - 1] & this.UPPER_MASK) | (this.mt[0] & this.LOWER_MASK);
      this.mt[this.N - 1] = this.mt[this.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 / (this.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 / this.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 / this.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;
    const b = this.int() >>> 6;
    return (a * 67108864.0 + b) * (1.0 / 9007199254740992.0);
  };

  /**
   * A pseudo-normal distribution using the Box-Muller transform.
   * @param {number} mu     The normal distribution mean
   * @param {number} sigma  The normal distribution standard deviation
   * @returns {number}
   */
  normal(mu, sigma) {
    let u = 0;
    while(u === 0) u = this.random(); //Converting [0,1) to (0,1)
    let v = 0;
    while(v === 0) v = this.random(); //Converting [0,1) to (0,1)
    let n = Math.sqrt( -2.0 * Math.log(u) ) * Math.cos(2.0 * Math.PI * v);
    return (n * sigma) + mu
  }

  /**
   * A factory method for generating random uniform rolls
   * @return {number}
   */
  static random() {
    return twist.random();
  }

  /**
   * A factory method for generating random normal rolls
   * @return {number}
   */
  static normal(...args) {
    return twist.normal(...args);
  }
}

// Global singleton
const twist = new MersenneTwister(Date.now());

/**
 * Define a two-sided coin term that can be used as part of a Roll formula
 * @implements {DiceTerm}
 */
class Coin extends DiceTerm {
  constructor(termData) {
    super(termData);
    this.faces = 2;
  }

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

  /** @override */
  roll(options) {
    const roll = super.roll(options);
    roll.result -= 1;
    this.results[this.results.length - 1].result = roll.result;
    return roll;
  }

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

  /** @override */
  static getResultLabel(result) {
    return {
      "0": "T",
      "1": "H"
    }[result];
  }

  /* -------------------------------------------- */
  /*  Term Modifiers                              */
  /* -------------------------------------------- */

  /**
   * Call the result of the coin flip, marking any coins that matched the called target as a success
   *
   * 3dcc1      Flip 3 coins and treat "heads" as successes
   * 2dcc0      Flip 2 coins and treat "tails" as successes
   *
   * @param {string} modifier     The matched modifier query
   */
  call(modifier) {

    // Match the modifier
    const rgx = /[cC]([01])/
    const match = modifier.match(rgx);
    let [_, target] = match;
    target = parseInt(target);

    // Treat each result which matched the call as a success
    for ( let r of this.results ) {
      r.count = r.result === target ? 1 : 0;
    }
  }
}
Coin.DENOMINATION = "c";
Coin.MODIFIERS = {
  "c": "call"
};

/**
 * Define a fair n-sided die term that can be used as part of a Roll formula
 * @implements {DiceTerm}
 *
 * @example
 * // Roll 4 six-sided dice
 * let die = new Die({faces: 6, number: 4}).evaluate();
 */
class Die extends DiceTerm {
  constructor(termData) {
    super(termData);
    if ( typeof this.faces !== "number" ) {
      throw new Error("A Die term must have a numeric number of faces.");
    }
  }

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

  /** @override */
  get total() {
    const total = super.total;
    if ( this.options.marginSuccess ) return total - parseInt(this.options.marginSuccess);
    else if ( this.options.marginFailure ) return parseInt(this.options.marginFailure) - total;
    else return total;
  }

  /* -------------------------------------------- */
  /*  Term Modifiers                              */
  /* -------------------------------------------- */

  /**
   * Re-roll the Die, rolling additional results for any values which fall within a target set.
   * If no target number is specified, re-roll the lowest possible result.
   *
   * 20d20r         reroll all 1s
   * 20d20r1        reroll all 1s
   * 20d20r=1       reroll all 1s
   * 20d20r1=1      reroll a single 1
   *
   * @param {string} modifier     The matched modifier query
   */
  reroll(modifier) {

    // Match the re-roll modifier
    const rgx = /[rR]([0-9]+)?([<>=]+)?([0-9]+)?/;
    const match = modifier.match(rgx);
    if ( !match ) return this;
    let [max, comparison, target] = match.slice(1);

    // If no comparison was set, treat the max as the target
    if ( !comparison ) {
      target = max;
      max = null;
    }

    // Determine threshold values
    max = parseInt(max) || this.results.length;
    target = parseInt(target) || 1;
    comparison = comparison || "=";

    // Re-roll results from the initial set to a maximum number of times
    const n = this.results.length;
    for ( let i=0; i<n; i++ ) {
      let r = this.results[i];
      if (!r.active) continue;

      // Maybe we have run out of re-rolls
      if (max <= 0) break;

      // Determine whether to re-roll the result
      if ( DiceTerm.compareResult(r.result, comparison, target) ) {
        r.rerolled = true;
        r.active = false;
        this.roll();
        max -= 1;
      }
    }
  }

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

  /**
   * Explode the Die, rolling additional results for any values which match the target set.
   * If no target number is specified, explode the highest possible result.
   * Explosion can be a "small explode" using a lower-case x or a "big explode" using an upper-case "X"
   *
   * @param {string} modifier     The matched modifier query
   * @param {boolean} recursive   Explode recursively, such that new rolls can also explode?
   */
  explode(modifier, {recursive=true}={}) {

    // Match the explode or "explode once" modifier
    const rgx = /[xX][oO]?([0-9]+)?([<>=]+)?([0-9]+)?/;
    const match = modifier.match(rgx);
    if ( !match ) return this;
    let [max, comparison, target] = match.slice(1);

    // If no comparison was set, treat the max as the target
    if ( !comparison ) {
      target = max;
      max = null;
    }

    // Determine threshold values
    max = parseInt(max) || null;
    target = parseInt(target) || this.faces;
    comparison = comparison || "=";

    // Recursively explode until there are no remaining results to explode
    let checked = 0;
    let initial = this.results.length;
    while ( checked < this.results.length ) {
      let r = this.results[checked];
      checked++;
      if (!r.active) continue;

      // Maybe we have run out of explosions
      if ( (max !== null) && (max <= 0) ) break;

      // Determine whether to explode the result and roll again!
      if ( DiceTerm.compareResult(r.result, comparison, target) ) {
        r.exploded = true;
        this.roll();
        if ( max !== null ) max -= 1;
      }

      // Limit recursion if it's a "small explode"
      if ( !recursive && (checked >= initial) ) checked = this.results.length;
      if ( checked > 1000 ) throw new Error("Maximum recursion depth for exploding dice roll exceeded");
    }
  }

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

  /**
   * @see {@link Die#explode}
   */
  explodeOnce(modifier) {
    return this.explode(modifier, {recursive: false});
  }

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

  /**
   * Keep a certain number of highest or lowest dice rolls from the result set.
   *
   * 20d20k       Keep the 1 highest die
   * 20d20kh      Keep the 1 highest die
   * 20d20kh10    Keep the 10 highest die
   * 20d20kl      Keep the 1 lowest die
   * 20d20kl10    Keep the 10 lowest die
   *
   * @param {string} modifier     The matched modifier query
   */
  keep(modifier) {
    const rgx = /[kK]([hHlL])?([0-9]+)?/;
    const match = modifier.match(rgx);
    if ( !match ) return this;
    let [direction, number] = match.slice(1);
    direction = direction ? direction.toLowerCase() : "h";
    number = parseInt(number) || 1;
    DiceTerm._keepOrDrop(this.results, number, {keep: true, highest: direction === "h"});
  }

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

  /**
   * Drop a certain number of highest or lowest dice rolls from the result set.
   *
   * 20d20d       Drop the 1 lowest die
   * 20d20dh      Drop the 1 highest die
   * 20d20dl      Drop the 1 lowest die
   * 20d20dh10    Drop the 10 highest die
   * 20d20dl10    Drop the 10 lowest die
   *
   * @param {string} modifier     The matched modifier query
   */
  drop(modifier) {
    const rgx = /[dD]([hHlL])?([0-9]+)?/;
    const match = modifier.match(rgx);
    if ( !match ) return this;
    let [direction, number] = match.slice(1);
    direction = direction ? direction.toLowerCase() : "l";
    number = parseInt(number) || 1;
    DiceTerm._keepOrDrop(this.results, number, {keep: false, highest: direction !== "l"});
  }

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

  /**
   * Count the number of successful results which occurred in a given result set.
   * Successes are counted relative to some target, or relative to the maximum possible value if no target is given.
   * Applying a count-success modifier to the results re-casts all results to 1 (success) or 0 (failure)
   *
   * 20d20cs      Count the number of dice which rolled a 20
   * 20d20cs>10   Count the number of dice which rolled higher than 10
   * 20d20cs<10   Count the number of dice which rolled less than 10
   *
   * @param {string} modifier     The matched modifier query
   */
  countSuccess(modifier) {
    const rgx = /(?:cs|CS)([<>=]+)?([0-9]+)?/;
    const match = modifier.match(rgx);
    if ( !match ) return this;
    let [comparison, target] = match.slice(1);
    comparison = comparison || "=";
    target = parseInt(target) || this.faces;
    DiceTerm._applyCount(this.results, comparison, target, {flagSuccess: true});
  }

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

  /**
   * Count the number of failed results which occurred in a given result set.
   * Failures are counted relative to some target, or relative to the lowest possible value if no target is given.
   * Applying a count-failures modifier to the results re-casts all results to 1 (failure) or 0 (non-failure)
   *
   * 6d6cf      Count the number of dice which rolled a 1 as failures
   * 6d6cf<=3   Count the number of dice which rolled less than 3 as failures
   * 6d6cf>4    Count the number of dice which rolled greater than 4 as failures
   *
   * @param {string} modifier     The matched modifier query
   */
  countFailures(modifier) {
    const rgx = /(?:cf|CF)([<>=]+)?([0-9]+)?/;
    const match = modifier.match(rgx);
    if ( !match ) return this;
    let [comparison, target] = match.slice(1);
    comparison = comparison || "=";
    target = parseInt(target) || 1;
    DiceTerm._applyCount(this.results, comparison, target, {flagFailure: true});
  }

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

  /**
   * Deduct the number of failures from the dice result, counting each failure as -1
   * Failures are identified relative to some target, or relative to the lowest possible value if no target is given.
   * Applying a deduct-failures modifier to the results counts all failed results as -1.
   *
   * 6d6df      Subtract the number of dice which rolled a 1 from the non-failed total.
   * 6d6cs>3df  Subtract the number of dice which rolled a 3 or less from the non-failed count.
   * 6d6cf<3df  Subtract the number of dice which rolled less than 3 from the non-failed count.
   *
   * @param {string} modifier     The matched modifier query
   */
  deductFailures(modifier) {
    const rgx = /(?:df|DF)([<>=]+)?([0-9]+)?/;
    const match = modifier.match(rgx);
    if ( !match ) return this;
    let [comparison, target] = match.slice(1);
    if ( comparison || target ) {
      comparison = comparison || "=";
      target = parseInt(target) || 1;
    }
    DiceTerm._applyDeduct(this.results, comparison, target, {deductFailure: true});
  }

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

  /**
   * Subtract the value of failed dice from the non-failed total, where each failure counts as its negative value.
   * Failures are identified relative to some target, or relative to the lowest possible value if no target is given.
   * Applying a deduct-failures modifier to the results counts all failed results as -1.
   *
   * 6d6df<3    Subtract the value of results which rolled less than 3 from the non-failed total.
   *
   * @param {string} modifier     The matched modifier query
   */
  subtractFailures(modifier) {
    const rgx = /(?:sf|SF)([<>=]+)?([0-9]+)?/;
    const match = modifier.match(rgx);
    if ( !match ) return this;
    let [comparison, target] = match.slice(1);
    if ( comparison || target ) {
      comparison = comparison || "=";
      target = parseInt(target) || 1;
    }
    DiceTerm._applyDeduct(this.results, comparison, target, {invertFailure: true});
  }

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

  /**
   * Subtract the total value of the DiceTerm from a target value, treating the difference as the final total.
   * Example: 6d6ms>12    Roll 6d6 and subtract 12 from the resulting total.
   * @param {string} modifier     The matched modifier query
   */
  marginSuccess(modifier) {
    const rgx = /(?:ms|MS)([<>=]+)?([0-9]+)?/;
    const match = modifier.match(rgx);
    if ( !match ) return this;
    let [comparison, target] = match.slice(1);
    target = parseInt(target);
    if ( [">", ">=", "=", undefined].includes(comparison) ) this.options["marginSuccess"] = target;
    else if ( ["<", "<="].includes(comparison) ) this.options["marginFailure"] = target;
  }

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

  /**
   * @deprecated since 0.7.0
   * TODO: Remove in 0.8.x
   * @see {@link Die#results}
   */
  get rolls() {
    console.warn(`You are using the Die#rolls attribute which is deprecated in favor of Die#results.`);
    return this.results;
  }
}

/** @override */
Die.DENOMINATION = "d";

/** @override */
Die.MODIFIERS = {
  "r": "reroll",
  "x": "explode",
  "xo": "explodeOnce",
  "k": "keep",
  "kh": "keep",
  "kl": "keep",
  "d": "drop",
  "dh": "drop",
  "dl": "drop",
  "cs": "countSuccess",
  "cf": "countFailures",
  "df": "deductFailures",
  "sf": "subtractFailures",
  "ms": "marginSuccess",
};

/**
 * Define a three-sided Fate/Fudge dice term that can be used as part of a Roll formula
 * Mathematically behaves like 1d3-2
 * @extends {DiceTerm}
 */
class FateDie extends DiceTerm {
  constructor(termData) {
    super(termData);
    this.faces = 3;
  }

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

  /** @override */
  roll(options) {
    const roll = super.roll(options);
    roll.result = roll.result - 2;
    this.results[this.results.length - 1].result = roll.result;
    return roll;
  }

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

  /** @override */
  static getResultLabel(result) {
    return {
      "-1": "-",
      "0": "&nbsp;",
      "1": "+"
    }[result];
  }
}
FateDie.DENOMINATION = "f";

/**
 * 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({
 *   rolls: [r1,r2,r3],
 *   modifiers: ["kh"]
 * });
 * pool.evaluate();
 *
 * @example
 * // Construct a DicePool from a string formula
 * let pool = DicePool.fromExpression("{4d6,3d8,2d10}kh");
 */
class DicePool {
  constructor({rolls=[], modifiers=[], options={}}={}) {

    /**
     * The elements of a Dice Pool must be Roll objects or numbers
     * @type {Array<Roll|number>}
     */
    this.rolls = rolls;

    /**
     * The string modifiers applied to resolve the pool
     * @type {string[]}
     */
    this.modifiers = modifiers;

    /**
     * An object of additional options which modify the pool
     * @type {object}
     */
    this.options = options;

    /**
     * The array of dice pool results which have been rolled
     * @type {Array<{result: number, active: boolean}>}
     */
    this.results = [];

    /**
     * An internal flag for whether the dice term has been evaluated
     * @type {boolean}
     * @private
     */
    this._evaluated = false;

    /**
     * Cache the evaluated total to avoid re-evaluating it
     * @type {number|null}
     * @private
     */
    this._total = null;
  }

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

  /**
   * Define the modifiers that can be used for this particular DiceTerm type.
   * @type {Object<string, Function>}
   * @public
   */
  static MODIFIERS = {
    "k": "keep",
    "kh": "keep",
    "kl": "keep",
    "d": "drop",
    "dh": "drop",
    "dl": "drop",
    "cs": "countSuccess",
    "cf": "countFailures"
  };

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

  /**
   * A regular expression pattern which identifies a potential DicePool modifier
   * @type {RegExp}
   */
  static MODIFIER_REGEX = /([A-z]+)([^A-z\s()+\-*\/]+)?/g;

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

  /**
   * A regular expression used to identify a valid Dice Pool
   * @type {RegExp}
   */
  static POOL_REGEX = /^{([^}]+)}([A-z][A-z0-9<=>]+)?$/;

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

  /**
   * Return an Array of each individual DiceTerm instances contained within the DicePool.
   * @return {DiceTerm[]}
   */
  get dice() {
    return this.rolls.reduce((dice, r) => dice.concat(r.dice), []);
  }

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

  /**
   * Return a standardized representation for the displayed formula associated with this DicePool.
   * @return {string}
   */
  get formula() {
    const pool = this.rolls.map(r => {
      return r.terms.some(t => t instanceof DicePool) ? `(${r.formula})` : r.formula;
    });
    return `{${pool.join(",")}}${this.modifiers.join("")}`;
  }

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

  /**
   * Return the total result of the DicePool if it has been evaluated
   * @type {number|null}
   */
  get total() {
    if ( !this._evaluated ) return null;
    return this.results.reduce((t, r) => {
      if ( !r.active ) return t;
      if ( r.count !== undefined ) return t + r.count;
      else return t + r.result;
    }, 0);
  }

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

  /**
   * Return an array of rolled values which are still active within the DicePool
   * @type {number[]}
   */
  get values() {
    return this.results.reduce((arr, r) => {
      if ( !r.active ) return arr;
      arr.push(r.result);
      return arr;
    }, []);
  }

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

  /**
   * Alter the DiceTerm by adding or multiplying the number of dice which are rolled
   * @param {any[]} args        Arguments passed to each contained Roll#alter method.
   * @return {DicePool}         The altered pool
   */
  alter(...args) {
    if ( this._evaluated ) throw new Error(`You may not alter a DicePool after it has already been evaluated`);
    this.rolls.forEach(r => r.alter(...args));
    return this;
  }

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

  /**
   * Evaluate the DicePool, populating the results Array.
   * @param {boolean} [minimize]    Apply the minimum possible result for each roll.
   * @param {boolean} [maximize]    Apply the maximum possible result for each roll.
   * @returns {DiceTerm}    The evaluated dice term
   */
  evaluate({minimize=false, maximize=false}={}) {
    if ( this._evaluated ) {
      throw new Error(`This ${this.constructor.name} has already been evaluated and is immutable`);
    }

    // Evaluate the members of the pool
    this.results = this.rolls.map(r => {
      let result = null;
      if ( r instanceof Roll ) {
        if ( !r._rolled ) r.evaluate();
        result = r.total;
      }
      else result = Number(r);
      return {
        result: result,
        active: true
      };
    });

    // Apply modifiers
    this._evaluateModifiers();

    // Return the evaluated term
    this._evaluated = true;
    return this;
  }

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

  /**
   * Sequentially evaluate each dice roll modifier by passing the term to its evaluation function
   * Augment or modify the results array.
   * @private
   */
  _evaluateModifiers() {
    const cls = this.constructor;
    for ( let m of this.modifiers ) {
      const command = m.match(/[A-z]+/)[0].toLowerCase();
      let fn = cls.MODIFIERS[command];
      if ( typeof fn === "string" ) fn = this[fn];
      if ( fn instanceof Function ) {
        fn.call(this, m);
      }
    }
  }

  /* -------------------------------------------- */
  /*  Saving and Loading                          */
  /* -------------------------------------------- */

  /**
   * Reconstruct a DicePool instance from a provided data Object
   * @param {Object} data   The provided data
   * @return {DicePool}     The constructed Dice Pool
   */
  static fromData(data) {
    const rolls = data.rolls.map(r => Roll.fromData(r));
    const pool = new this({rolls, modifiers: data.modifiers, options: data.options});
    pool.results = data.results;
    pool._evaluated = true;
    return pool;
  }

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

  /**
   * Given a string formula, create and return an evaluated DicePool object
   * @param {string} formula    The string formula to parse
   * @param {object} [options]  Additional options applied to the DicePool
   * @param {object} [data]     A data object which defines data substitutions for Rolls in the DicePool
   *
   * @return {DicePool|null}    The evaluated DicePool object or null if the formula is invalid
   */
  static fromExpression(formula, options={}, data={}) {
    const rgx = formula.trim().match(this.POOL_REGEX);
    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) => {
      arr.push(Roll.create(t.trim(), data));
      return arr;
    }, []);

    // Identify modifiers
    modifiers = Array.from((modifiers || "").matchAll(DiceTerm.MODIFIER_REGEX)).map(m => m[0]);

    // Construct and return the Dice Pool
    return new this({rolls, modifiers, options});
  }

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

  /**
   * 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) {
    let data;
    try {
      data = JSON.parse(json);
    } catch(err) {
      throw new Error("You must pass a valid JSON string");
    }
    return this.fromData(data);
  }

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

  /**
   * Convert the DicePool instance into an Object which can be serialized to JSON
   * @return {Object}     The converted data
   */
  toJSON() {
    return {
      class: this.constructor.name,
      rolls: this.rolls,
      modifiers: this.modifiers,
      options: this.options,
      results: this.results
    }
  }

  /* -------------------------------------------- */
  /*  Modifiers                                   */
  /* -------------------------------------------- */

  /**
   * Keep a certain number of highest or lowest dice rolls from the result set.
   *
   * {1d6,1d8,1d10,1d12}kh2       Keep the 2 best rolls from the pool
   * {1d12,6}kl                   Keep the lowest result in the pool
   *
   * @param {string} modifier     The matched modifier query
   */
  keep(modifier) {
    return Die.prototype.keep.call(this, modifier);
  }

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

  /**
   * Keep a certain number of highest or lowest dice rolls from the result set.
   *
   * {1d6,1d8,1d10,1d12}dl3       Drop the 3 worst results in the pool
   * {1d12,6}dh                   Drop the highest result in the pool
   *
   * @param {string} modifier     The matched modifier query
   */
  drop(modifier) {
    return Die.prototype.drop.call(this, modifier);
  }

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

  /**
   * Count the number of successful results which occurred in the pool.
   * Successes are counted relative to some target, or relative to the maximum possible value if no target is given.
   * Applying a count-success modifier to the results re-casts all results to 1 (success) or 0 (failure)
   *
   * 20d20cs      Count the number of dice which rolled a 20
   * 20d20cs>10   Count the number of dice which rolled higher than 10
   * 20d20cs<10   Count the number of dice which rolled less than 10
   *
   * @param {string} modifier     The matched modifier query
   */
  countSuccess(modifier) {
    return Die.prototype.countSuccess.call(this, modifier);
  }

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

  /**
   * Count the number of failed results which occurred in a given result set.
   * Failures are counted relative to some target, or relative to the lowest possible value if no target is given.
   * Applying a count-failures modifier to the results re-casts all results to 1 (failure) or 0 (non-failure)
   *
   * 6d6cf      Count the number of dice which rolled a 1 as failures
   * 6d6cf<=3   Count the number of dice which rolled less than 3 as failures
   * 6d6cf>4    Count the number of dice which rolled greater than 4 as failures
   *
   * @param {string} modifier     The matched modifier query
   */
  countFailures(modifier) {
    return Die.prototype.countFailures.call(this, modifier);
  }
}

/**
 * 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
    });
    this.app.renderer.plugins.interaction.moveWhenInside = true;

    // Register custom blend modes
    for ( let [k,v] of Object.entries(BLEND_MODES) ) {
      PIXI.BLEND_MODES[k] = this.app.renderer.state.blendModes.push(v) - 1;
    }

    // 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 {object[]}
     */
    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
  /* -------------------------------------------- */

  /**
   * An Array of all CanvasLayer instances which are active on the Canvas board
   * @type {CanvasLayer[]}
   */
  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
   * @return {Promise<void>}
   */
  async tearDown() {
    this.stage.visible = false;
    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.
   * @return {Promise<Canvas>}    A Promise which resolves once the Canvas is fully drawn
   */
  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");

    // Assign configuration globals
    PIXI.settings.MIPMAP_TEXTURES = PIXI.MIPMAP_MODES[game.settings.get("core", "mipmap") ? "ON" : "OFF"];

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

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

    // Scene background color
    this.backgroundColor = scene.data.backgroundColor ? colorStringToHex(scene.data.backgroundColor) : 0x666666;
    this.app.renderer.backgroundColor = this.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();

    // Mark the canvas as ready and call hooks
    this.stage.visible = this.ready = true;
    Hooks.call("canvasReady", this);
    this._reload = {};

    // Perform a final resize to ensure the rendered dimensions are correct
    this._onResize();
    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 2x grid size.
   * The rounding accomplishes that the padding buffer around the map always contains whole grid spaces.
   *
   * @param {Object} data     The scene dimensions data being established
   */
  static getDimensions({width, height, grid, gridDistance, padding, shiftX, shiftY}={}) {
    const w = width || (grid * 30);
    const h = height || (grid * 20);
    const d = {
      sceneWidth: w,
      sceneHeight: h,
      size: parseInt(grid),
      distance: parseFloat(gridDistance),
      shiftX: parseInt(shiftX),
      shiftY: parseInt(shiftY),
      ratio: w / h
    };

    // Define padding and final dimensions
    d.paddingX = Math.ceil((padding * w) / grid) * grid;
    d.width = w + (2 * d.paddingX);
    d.paddingY = Math.ceil((padding * h) / grid) * grid;
    d.height = h + (2 * d.paddingY);

    // Define helper rectangles
    d.rect = new PIXI.Rectangle(0, 0, d.width, d.height);
    d.sceneRect = new PIXI.Rectangle(d.paddingX-d.shiftX, d.paddingY-d.shiftY, d.sceneWidth, d.sceneHeight);
    return d;
  }

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

  /**
   * Once the canvas is drawn, initialize control, visibility, and audio states
   * @return {Promise<void>}
   */
  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
    this.initializeSources();
    await this.sight.initialize();
    // this.sounds.initialize();  TODO

    // Broadcast user presence in the Scene
    game.user.broadcastActivity({sceneId: this.scene.id});
  }

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

  initializeSources() {

    // Clear sources
    this.lighting.sources.clear();
    this.sight.sources.clear();
    // this.sounds.sources.clear(); // TODO

    // Assign variables
    this.lighting.globalLight = this.scene.data.globalLight;

    // Update sources
    this.tokens.placeables.forEach(token => token.updateSource({defer: true}));
    this.lighting.placeables.forEach(light => light.updateSource({defer: true}));
    // this.sounds.placeables.forEach(sound => sound.updateSource()); // TODO

    // Refresh layer displays
    this.lighting.refresh(this.scene.data.darkness);
    this.sight.refresh();
    // this.sounds.refresh(); // TODO
  }

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

  /**
   * 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() {
    this.layers.forEach(l => l.deactivate());
    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 blur as we zoom
    this._updateBlur(constrained.scale);

    // 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<void>}     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 blur as we zoom
    this._updateBlur(constrained.scale);

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

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

  /**
   * Update the blur strength depending on the scale of the canvas stage
   * @param {number} scale
   * @private
   */
  _updateBlur(scale) {
    const blur = Math.ceil(Math.clamped(scale * CONFIG.Canvas.blurStrength, 0, CONFIG.Canvas.blurStrength));
    canvas.lighting.illumination.filter.blur = blur;
    canvas.sight.filter.blur = blur;
  }

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

  /**
   * 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 = { clickRight2: false };
    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);
    const release = game.settings.get("core", "leftClickRelease");
    if ( isSelect && !release ) 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 layer = this.activeLayer;
    const isRuler = game.activeTool === "ruler";
    const isCtrlRuler = game.keyboard.isCtrl(event) && (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
   */
  _onResize(event=null) {
    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});
  }

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

  /**
   * Handle mousewheel events which adjust the scale of the canvas
   * @param {WheelEvent} event    The mousewheel event that zooms the canvas
   * @private
   */
  _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;

    // Handle the drop with a Hooked function
    const allowed = Hooks.call("dropCanvasData", this, data);
    if ( allowed === false ) return;

    // Handle different data types
    switch ( data.type ) {
      case "Actor":
        return canvas.tokens._onDropActorData(event, data);
      case "JournalEntry":
        return canvas.notes._onDropData(event, data);
      case "Macro":
        return game.user.assignHotbarMacro(null, data.slot);
      case "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 ) {
      if ( !fn ) continue;
      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}
 * @abstract
 * @interface
 */
class CanvasLayer extends PIXI.Container {
  constructor() {
    super();

    /**
     * Track whether the canvas layer is currently active for interaction
     * @type {boolean}
     */
    this._active = false;

    // Set initial state
    this.interactive = false;
    this.interactiveChildren = false;
  }

  /* -------------------------------------------- */
  /*  Properties and Attributes
  /* -------------------------------------------- */

  /**
   * The canonical name of the CanvasLayer
   * @return {string}
   */
  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 the CanvasLayer, deactivating other layers and marking this layer's children as interactive
   */
  activate() {
    if ( this._active ) return;
    this._active = true;
    for ( let l of canvas.layers ) {
      if ( (l !== this) && l._active ) l.deactivate();
    }
    this.interactive = false;
    this.interactiveChildren = true;
    if ( ui.controls ) ui.controls.initialize({layer: this.constructor.name});
  }

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

  /**
   * Deactivate the CanvasLayer, removing interactivity from its children
   */
  deactivate() {
    this._active = false;
    this.interactive = false;
    this.interactiveChildren = false;
  }
}
/**
 * An Abstract Base Class which defines a Placeable Object which represents an Entity placed on the Canvas
 * @extends {PIXI.Container}
 * @abstract
 * @interface
 */
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 bounding box for this PlaceableObject.
   * This is required if the layer uses a Quadtree, otherwise it is optional
   * @return {NormalizedRectangle}
   */
  get bounds() {
    throw new Error("Each PlaceableObject implementation must define it's own bounding box.");
  }

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

  /**
   * 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;
    clone._controlled = this._controlled;
    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() {
    this.draw();
  }

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

  /**
   * Define additional steps taken when an existing placeable object of this type is updated with new data
   * @private
   */
  _onUpdate(data) {
    const layer = this.layer;

    // Z-index sorting
    if ( Object.keys(data).includes("z") ) {
      this.zIndex = parseInt(data.z) || 0;
    }

    // Quadtree location update
    if ( layer.quadtree ) {
      layer.quadtree.remove(this).insert({r: this.bounds, t: this});
    }

    // Refresh display
    this.refresh();

    // Refresh connected apps
    this.sheet.render(false);
  }

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

  /**
   * 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;
    if ( layer.quadtree ) layer.quadtree.remove(this);
  }

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

    // Release other controlled objects
    const wasControlled = this._controlled;
    this._controlled = false;
    if ( options.releaseOthers !== false ) {
      for ( let o of Object.values(this.layer._controlled) ) {
        if ( o !== this ) o.release();
      }
    }
    this._controlled = wasControlled;

    // Bail out if this object is already controlled, or not controllable
    if (this._controlled) return true;
    if (!this.can(game.user, "control")) return false;

    // Toggle control status
    this._controlled = true;
    this.layer._controlled[this.id] = this;

    // Trigger follow-up events and fire an on-control Hook
    this._onControl(options);
    Hooks.callAll("control"+this.constructor.name, this, this._controlled);
    canvas.triggerPendingOperations();
    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);
    canvas.triggerPendingOperations();
    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;

    // Standardize degrees
    degrees = (degrees + offset) % 360;
    while ( degrees <= 0 ) degrees += 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: false});
      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 ) {
      c.visible = false;
      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}
 * @abstract
 * @interface
 */
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 {object[]}
     */
    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 {object[]}
     */
    this._copy = [];

    /**
     * PlaceableObject layer options
     * @type {Object}
     */
    this.options = this.constructor.layerOptions;

    /**
     * A Quadtree which partitions and organizes Walls into quadrants for efficient target identification.
     * @type {Quadtree|null}
     */
    this.quadtree = null;
  }

  /* -------------------------------------------- */
  /*  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,
      quadtree: false,
      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 {PlaceableObject[]}
   */
  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 draw() {
    await super.draw();

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

    // Create a Quadtree container if the layer uses it
    if ( this.options.quadtree ) {
      const d = canvas.dimensions;
      this.quadtree = new Quadtree({x: 0, y: 0, width: d.width, height: d.height});
    } else this.quadtree = null;

    // 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 => {
      const obj = this.createObject(data);
      return obj.draw();
    });

    // Wait for all objects to draw
    this.visible = true;
    return Promise.all(promises);
  }

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

  /**
   * Draw a single placeable object
   * @return {PlaceableObject}
   */
  createObject(data) {
    const obj = new this.constructor.placeableClass(data, canvas.scene);
    obj.zIndex = data.z || 0;
    this.objects.addChild(obj);
    if ( this.quadtree ) this.quadtree.insert({r: obj.bounds, t: obj});
    return obj;
  }

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

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

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

  /** @override */
  activate() {
    super.activate();
    this.objects.visible = true;
    this.placeables.forEach(l => l.refresh());
  }

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

  /** @override */
  deactivate() {
    super.deactivate();
    this.objects.visible = false;
    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);
  }

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

  /**
   * Acquire control over all PlaceableObject instances which are visible and controllable within the layer.
   * @param {object} options      Options passed to the control method of each object
   * @return {PlaceableObject[]}  An array of objects that were controlled
   */
  controlAll(options={}) {
    if ( !this.options.controllableObjects ) return;
    options.releaseOthers = false;
    for ( let o of this.placeables ) {
      if (!o.visible || !o.can(game.user, "control")) continue;
      o.control(options);
    }
    return this.controlled;
  }

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

  /**
   * Release all controlled PlaceableObject instance from this layer.
   * @param {object} options   Options passed to the release method of each object
   * @returns {number}         The number of PlaceableObject instances which were released
   */
  releaseAll(options={}) {
    let released = 0;
    for ( let o of this.placeables ) {
      if ( !o._controlled ) continue;
      o.release(options);
      released++;
    }
    return released;
  }

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

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

    // Define rotation angles
    const rotationAngles = {
      square: [45, 135, 225, 315],
      hexR: [30, 150, 210, 330],
      hexQ: [60, 120, 240, 300]
    };

    // Determine the rotation angle
    let offsets = [dx, dy];
    let angle = 0;
    if ( rotate ) {
      let angles = rotationAngles.square;
      if ( canvas.grid.type >= CONST.GRID_TYPES.HEXODDQ ) angles = rotationAngles.hexQ;
      else if ( canvas.grid.type >= CONST.GRID_TYPES.HEXODDR ) angles = rotationAngles.hexR;
      if (offsets.equals([0, 1])) angle = 0;
      else if (offsets.equals([-1, 1])) angle = angles[0];
      else if (offsets.equals([-1, 0])) angle = 90;
      else if (offsets.equals([-1, -1])) angle = angles[1];
      else if (offsets.equals([0, -1])) angle = 180;
      else if (offsets.equals([1, -1])) angle = angles[2];
      else if (offsets.equals([1, 0])) angle = 270;
      else if (offsets.equals([1, 1])) angle = angles[3];
    }

    // 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<Scene>}
   */
  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 {object[]} data       An Array of update data Objects which provide incremental data
   * @param {object} options      Additional options which customize the update workflow
   *
   * @return {Promise<object[]>}  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 {object[]} data       An Array of update data Objects which provide incremental data
   * @param {object} options      Additional options which customize the update workflow
   *
   * @return {Promise<object[]>}  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 {string[]} ids        An Array of object IDs to target for deletion
   * @param {object} options      Additional options which customize the update workflow
   *
   * @return {Promise<string[]>}  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} objects from the Scene.`);
    }
    return Dialog.confirm({
      title: game.i18n.localize("CONTROLS.ClearAll"),
      content: `<p>${game.i18n.format("CONTROLS.ClearAllHint", {type: cls.name})}</p>`,
      yes: () => this.deleteMany(this.placeables.map(o => o.id), {}),
    });
  }

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

  /**
   * 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 {PlaceableObject[]}             The Array of copied PlaceableObject instances
   */
  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
   * @param {Point} position      The destination position for the copied data.
   * @param {boolean} [hidden]    Paste data in a hidden state, if applicable. Default is false.
   * @param {boolean} [snap]      Snap the resulting objects to the grid. Default is true.
   * @return {Promise.<PlaceableObject[]>}    An Array of created PlaceableObject instances
   */
  async pasteObjects(position, {hidden=false, snap=true}={}) {
    if ( !this._copy.length ) return [];
    const cls = this.constructor.placeableClass;

    // Adjust the pasted position for half a grid space
    if ( snap ) {
      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 dest = {x: position.x + (data.x - x), y: position.y + (data.y - y)};
      if ( snap ) dest = canvas.grid.getSnappedPosition(dest.x, dest.y);
      delete data._id;
      toCreate.push(mergeObject(data, {
        x: dest.x,
        y: dest.y,
        hidden: data.hidden || hidden
      }));
    }

    // Call paste hooks
    Hooks.call(`paste${cls.name}`, this._copy, toCreate);

    // Create all objects
    let created = await canvas.scene.createEmbeddedEntity(cls.name, toCreate);
    ui.notifications.info(`Pasted data for ${toCreate.length} ${cls.name} objects.`);
    created = created instanceof Array ? created : [created];
    return created.map(c => this.get(c._id));
  }

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

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

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

    // 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 {object[]} 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 {object[]}
     */
    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 container = target[0].parentElement;

    // Append to target and get the context bounds
    target.css('position', 'relative');
    html.css("visibility", "hidden");
    target.append(html);
    const contextRect = html[0].getBoundingClientRect();
    const parentRect = target[0].getBoundingClientRect();
    const containerRect = container.getBoundingClientRect();

    // Determine whether to expand upwards
    const contextTop = parentRect.top - contextRect.height;
    const contextBottom = parentRect.bottom + contextRect.height;
    const canOverflowUp = (contextTop > containerRect.top) || (getComputedStyle(container).overflowY === "visible");

    // If it overflows the container bottom, but not the container top
    const containerUp = ( contextBottom > containerRect.bottom ) && ( contextTop >= containerRect.top );
    const windowUp = ( contextBottom > window.innerHeight ) && ( contextTop > 0 ) && canOverflowUp;
    this._expandUp = containerUp || windowUp;

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

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

/**
 * @typedef {Object} DialogButton
 * @property {string} icon            A Font Awesome icon for the button
 * @property {string} label           The label for the button
 * @property {Function} [callback]    A callback function that fires when the button is clicked
 */

/**
 * Create a dialog window displaying a title, a message, and a set of buttons which trigger callback functions.
 * @implements {Application}
 *
 * @param {Object} data               An object of dialog data which configures how the modal window is rendered
 * @param {string} data.title         The window title
 * @param {string} data.content       HTML content
 * @param {Function} [data.render]    A callback function invoked when the dialog is rendered
 * @param {Function} [data.close]     Common callback operations to perform when the dialog is closed
 * @param {Object<string, DialogButton>} data.buttons The buttons which are displayed as action choices for the dialog
 *
 * @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
 * @param {boolean} options.jQuery    Whether to provide jQuery objects to callback functions (if true) or plain
 *                                    HTMLElement instances (if false). This is currently true by default but in the
 *                                    future will become false by default.
 *
 * @example <caption>Constructing a custom dialog instance</caption>
 * 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",
 *  render: html => console.log("Register interactivity in the rendered dialog"),
 *  close: html => console.log("This always is logged no matter which option is chosen")
 * });
 * d.render(true);
 */
class Dialog extends Application {
  constructor(data, options) {
    super(options);
    this.data = data;
  }

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

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    template: "templates/hud/dialog.html",
      classes: ["dialog"],
      width: 400,
      jQuery: true
    });
  }

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

  /** @override */
  get title() {
    return this.data.title || "Dialog";
  }

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

  /** @override */
  getData(options) {
    let buttons = Object.keys(this.data.buttons).reduce((obj, key) => {
      let b = this.data.buttons[key];
      b.cssClass = [key, this.data.default === key ? "default" : ""].filterJoin(" ");
      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));
    if ( this.data.render instanceof Function ) this.data.render(this.options.jQuery ? html : html[0]);
  }

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

  /**
   * 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 {
      if (button.callback) button.callback(this.options.jQuery ? this.element : this.element[0]);
      this.close();
    } catch(err) {
      ui.notifications.error(err);
      throw new Error(err);
    }
  }

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

  /** @override */
  close() {
    if ( this.data.close ) this.data.close(this.options.jQuery ? this.element : this.element[0]);
    super.close();
    $(document).off('keydown.chooseDefault');
  }

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

  /**
   * 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 {Function} render       A function to call when the dialog is rendered
   * @param {boolean} defaultYes    Make "yes" the default choice?
   * @param {boolean} rejectClose   Reject the Promise if the Dialog is closed without making a 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, render, defaultYes=true, rejectClose=false, options={}}={}, old) {

    // TODO: Support the old second-paramter options until 0.8.x release
    if ( old ) {
      console.warn("You are passing an options object as a second parameter to Dialog.confirm. This should now be passed in as the options key of the first parameter.")
      options = old;
    }
    return new Promise((resolve, reject) => {
      const dialog = new this({
        title: title,
        content: content,
        buttons: {
          yes: {
            icon: '<i class="fas fa-check"></i>',
            label: game.i18n.localize("Yes"),
            callback: html => {
              const result = yes ? yes(html) : true;
              resolve(result);
            }
          },
          no: {
            icon: '<i class="fas fa-times"></i>',
            label: game.i18n.localize("No"),
            callback: html => {
              const result = no ? no(html) : false;
              resolve(result);
            }
          }
        },
        default: defaultYes ? "yes" : "no",
        render: render,
        close: () => {
          if ( rejectClose ) reject("The confirmation Dialog was closed without a choice being made");
          else resolve(null);
        },
      }, options);
      dialog.render(true);
    });
  }

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

  /**
   * A helper factory method to display a basic "prompt" style Dialog with a single button
   * @param {string} title          The confirmation window title
   * @param {string} content        The confirmation message
   * @param {string} label          The confirmation button text
   * @param {Function} callback     A callback function to fire when the button is clicked
   * @param {Function} render       A function that fires after the dialog is rendered
   * @param {object} options        Additional rendering options
   * @return {Promise<*>}           A promise which resolves when clicked, or rejects if closed
   */
  static async prompt({title, content, label, callback, render, options={}}={}) {
    return new Promise((resolve, reject) => {
      const dialog = new this({
        title: title,
        content: content,
        buttons: {
          ok: {
            icon: '<i class="fas fa-check"></i>',
            label: label,
            callback: html => {
              const result = callback(html);
              resolve(result);
            }
          },
        },
        default: "ok",
        render: render,
        close: () => reject,
      }, 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 = {};

    /**
     * Throttle mousemove event handling to 60fps
     * @type {number}
     */
    this._moveTime = 0;

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

    // Limit dragging to 60 updates per second
    const now = Date.now();
    if ( (now - this._moveTime) < (1000/60) ) return;
    this._moveTime = now;

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

    // Limit dragging to 60 updates per second
    const now = Date.now();
    if ( (now - this._moveTime) < (1000/60) ) return;
    this._moveTime = now;

    // 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) {
    this.callback(event, "dragstart");
    if ( event.dataTransfer.items.length ) event.stopPropagation();
  }

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

  /**
   * 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 async 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;
    const editors = await tinyMCE.init(mceConfig);
    return editors[0];
  }

  /* -------------------------------------------- */
  /*  HTML Manipulation Helpers
  /* -------------------------------------------- */

  /**
   * 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       Include secret tags in the final HTML? If false secret blocks will be removed.
   * @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}={}) {

    // Create the HTML element
    const html = document.createElement("div");
    html.innerHTML = String(content);

    // Remove secret blocks
    if ( !secrets ) {
      let elements = html.querySelectorAll("section.secret");
      elements.forEach(e => e.parentNode.removeChild(e));
    }

    // Plan text content replacements
    let updateTextArray = true;
    let text = [];

    // Replace entity links
    if ( entities ) {
      if ( updateTextArray ) text = this._getTextNodes(html);
      const entityTypes = CONST.ENTITY_LINK_TYPES.concat("Compendium");
      const rgx = new RegExp(`@(${entityTypes.join("|")})\\[([^\\]]+)\\](?:{([^}]+)})?`, 'g');
      updateTextArray = this._replaceTextContent(text, rgx, this._createEntityLink);
    }

    // Replace hyperlinks
    if ( links ) {
      if ( updateTextArray ) text = this._getTextNodes(html);
      const rgx = /(https?:\/\/)(www\.)?([^\s<]+)/gi;
      updateTextArray = this._replaceTextContent(text, rgx, this._createHyperlink);
    }

    // Replace inline rolls
    if ( rolls ) {
      if (updateTextArray) text = this._getTextNodes(html);
      const rgx = /\[\[(\/[a-zA-Z]+\s)?(.*?)([\]]{2,3})/gi;
      updateTextArray = this._replaceTextContent(text, rgx, (...args) => this._createInlineRoll(...args, rollData));
    }

    // 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;
    div.innerText = this.truncateText(div.innerText, {maxLength: length});
  }

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

  /**
   * Truncate a fragment of text to a maximum number of characters.
   * @param {string} text           The original text fragment that should be truncated to a maximum length
   * @param {number} [maxLength]    The maximum allowed length of the truncated string.
   * @param {boolean} [splitWords]  Whether to truncate by splitting on white space (if true) or breaking words.
   * @param {string|null} [suffix]  A suffix string to append to denote that the text was truncated.
   * @return {*}
   */
  static truncateText(text, {maxLength=50, splitWords=true, suffix="&mldr;"}={}) {
    if ( text.length <= maxLength ) return text;

    // Split the string (on words if desired)
    let short = "";
    if ( splitWords ) {
      short = text.slice(0, maxLength + 10);
      while ( short.length > maxLength ) {
        if ( /\s/.test(short) ) short = short.replace(/[\s]+([\S]+)?$/, "");
        else short = short.slice(0, maxLength);
      }
    } else {
      short = text.slice(0, maxLength);
    }

    // Add a suffix and return
    suffix = suffix ?? "";
    return short + suffix;
  }

  /* -------------------------------------------- */
  /*  Text Node Manipulation
  /* -------------------------------------------- */

  /**
   * Recursively identify the text nodes within a parent HTML node for potential content replacement.
   * @param {HTMLElement} parent    The parent HTML Element
   * @return {Text[]}               An array of contained Text nodes
   * @private
   */
  static _getTextNodes(parent) {
    const text = [];
    const walk = document.createTreeWalker(parent, NodeFilter.SHOW_TEXT, null, false);
    while( walk.nextNode() ) text.push(walk.currentNode);
    return text;
  }

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

  /**
   * Facilitate the replacement of text node content using a matching regex rule and a provided replacement function.
   * @private
   */
  static _replaceTextContent(text, rgx, func) {
    let replaced = false;
    for ( let t of text ) {
      const matches = t.textContent.matchAll(rgx);
      for ( let match of Array.from(matches).reverse() ) {
        const replacement = func(...match);
        if ( replacement ) {
          this._replaceTextNode(t, match, replacement);
          replaced = true;
        }
      }
    }
    return replaced;
  }

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

  /**
   * Replace a matched portion of a Text node with a replacement Node
   * @param {Text} text
   * @param {RegExpMatchArray} match
   * @param {Node} replacement
   * @private
   */
  static _replaceTextNode(text, match, replacement) {
    let target = text;
    if ( match.index > 0 ) {
      target = text.splitText(match.index);
    }
    if ( match[0].length < target.length ) {
      target.splitText(match[0].length);
    }
    target.replaceWith(replacement);
  }

  /* -------------------------------------------- */
  /*  Text Replacement Functions
  /* -------------------------------------------- */

  /**
   * Create a dynamic entity link from a regular expression match
   * @param {string} match          The full matched string
   * @param {string} type           The matched entity type or "Compendium"
   * @param {string} target         The requested match target (_id or name)
   * @param {string} name           A customized or over-ridden display name for the link
   * @return {HTMLAnchorElement}    An HTML element for the entity link
   * @private
   */
  static _createEntityLink(match, type, target, name) {

    // Prepare replacement data
    const data = {
      cls: ["entity-link"],
      icon: null,
      dataset: {},
      name: name
    };
    let broken = false;

    // Get a matched World entity
    if (CONST.ENTITY_TYPES.includes(type)) {
      const config = CONFIG[type];

      // Get the linked Entity
      const collection = config.entityClass.collection;
      const entity = /^[a-zA-Z0-9]{16}$/.test(target) ? collection.get(target) : collection.getName(target);
      if (!entity) broken = true;

      // Update link data
      data.name = data.name || (broken ? target : entity.name);
      data.icon = config.sidebarIcon;
      data.dataset = {entity: type, id: broken ? null : entity.id};
    }

    // Get a matched Compendium entity
    else if (type === "Compendium") {

      // Get the linked Entity
      let [scope, packName, id] = target.split(".");
      const pack = game.packs.get(`${scope}.${packName}`);
      if ( pack ) {
        if (pack.index.length) {
          const entry = pack.index.find(i => (i._id === id) || (i.name === id));
          if (!entry) broken = true;
          else id = entry._id;
          data.name = data?.name || entry?.name || id;
        }

        // Update link data
        const config = CONFIG[pack.metadata.entity];
        data.icon = config.sidebarIcon;
        data.dataset = {pack: pack.collection, id: id};
      }
      else broken = true;
    }

    // Flag a link as broken
    if (broken) {
      data.icon = "fas fa-unlink";
      data.cls.push("broken");
    }

    // Construct the formed link
    const a = document.createElement('a');
    a.classList.add(...data.cls);
    a.draggable = true;
    for (let [k, v] of Object.entries(data.dataset)) {
      a.dataset[k] = v;
    }
    a.innerHTML = `<i class="${data.icon}"></i> ${data.name}`;
    return a;
  }

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

  /**
   * Replace a hyperlink-like string with an actual HTML <a> tag
   * @param {string} match          The full matched string
   * @return {HTMLAnchorElement}    An HTML element for the entity link
   * @private
   */
  static _createHyperlink(match) {
    const a = document.createElement('a');
    a.classList.add("hyperlink");
    a.href = match;
    a.target = "_blank";
    a.rel = "nofollow noopener";
    a.textContent = match;
    return a;
  }

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

  /**
   * Replace an inline roll formula with a rollable <a> element 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 {string} closing    The closing brackets for the inline roll
   * @return {string}           The replaced match
   */
  static _createInlineRoll(match, command, formula, closing, ...args) {
    const isDeferred = !!command;
    const rollData = args.pop();
    let roll;

    // Define default inline data
    const data = {
      cls: ["inline-roll"],
      dataset: {}
    };

    // Handle the possibility of closing brackets
    if ( closing.length === 3 ) formula += "]";

    // Extract roll data as a parsed chat command
    if ( isDeferred ) {
      const chatCommand = `${command}${formula}`;
      let parsedCommand = null;
      try {
        parsedCommand = ChatLog.parse(chatCommand);
      }
      catch(err) { return null; }
      const flavor = parsedCommand[1][3];

      // Set roll data
      data.cls.push(parsedCommand[0]);
      data.dataset.mode = parsedCommand[0];
      data.dataset.flavor = flavor ? flavor.trim() : "";
      data.dataset.formula = parsedCommand[1][2].trim();
      data.result = parsedCommand[1][2].trim();
      data.title = data.dataset.flavor || data.dataset.formula;
    }

    // Perform the roll immediately
    else {
      try {
        roll = Roll.create(formula, rollData).roll();
        data.cls.push("inline-result");
        data.result = roll.total;
        data.title = formula;
        data.dataset.roll = escape(JSON.stringify(roll));
      }
      catch(err) { return null; }
    }

    // Construct and return the formed link element
    const a = document.createElement('a');
    a.classList.add(...data.cls);
    a.title = data.title;
    for (let [k, v] of Object.entries(data.dataset)) {
      a.dataset[k] = v;
    }
    a.innerHTML = `<i class="fas fa-dice-d20"></i> ${data.result}`;
    return 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") ) {
      if ( a.classList.contains("expanded") ) {
        return Roll._collapseInlineResult(a);
      } else {
        return Roll._expandInlineResult(a);
      }
    }

    // Get the current speaker
    const msg = CONFIG.ChatMessage.entityClass;
    const speaker = msg.getSpeaker();
    let actor = msg.getSpeakerActor(speaker);
    let rollData = actor ? actor.getRollData() : {};

    // Obtain roll data from the contained sheet, if the inline roll is within an Actor or Item sheet
    const sheet = a.closest(".sheet");
    if ( sheet ) {
      const app = ui.windows[sheet.dataset.appid];
      if ( ["Actor", "Item"].includes(app?.object?.entity) ) rollData = app.object.getRollData();
    }

    // Execute a deferred roll
    const roll = Roll.create(a.dataset.formula, rollData).roll();
    return roll.toMessage({flavor: a.dataset.flavor, speaker}, {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: game.i18n.localize("FILES.SourceUser"),
        icon: "fas fa-database"
      },
      public: {
        target: "",
        label: game.i18n.localize("FILES.SourceCore"),
        icon: "fas fa-server"
      },
      s3: {
        buckets: [],
        bucket: "",
        target: "",
        label: game.i18n.localize("FILES.SourceS3"),
        icon: "fas fa-cloud"
      }
    }).reduce((obj, s) => {
      if ( game.data.files.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 {string[]}
     */
    this.extensions = this._getExtensions(this.type);

    // Infer the source
    const [source, target] = this._inferCurrentDirectory(this.request);
    this.activeSource = source;
    this.sources[source].target = target;

    // Track whether we have loaded files
    this._loaded = false;
  }

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

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    template: "templates/apps/filepicker.html",
      classes: ["filepicker"],
      width: 520,
      tabs: [{navSelector: ".tabs"}],
      dragDrop: [{dragSelector: ".file", dropSelector: ".filepicker-body"}],
      tileSize: false,
      filters: [{inputSelector: 'input[name="filter"]', contentSelector: ".filepicker-body"}]
    });
  }

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

  /**
   * Given a current file path, determine the directory it belongs to
   * @param {string} target   The currently requested target path
   * @return {string[]}       An array of the inferred source and target directory path
   */
  _inferCurrentDirectory(target) {

    // Determine target
    const ignored = [CONST.DEFAULT_TOKEN];
    if ( !target || ignored.includes(target) ) target = this.constructor.LAST_BROWSED_DIRECTORY;
    let source = "data";

    // Check for s3 matches
    const s3Match = this.constructor.matchS3URL(target);
    if ( s3Match ) {
      this.sources.s3.bucket = s3Match.groups.bucket;
      source = "s3";
      target = s3Match.groups.key;
    }

    // Non-s3 URL matches
    else if ( ["http://", "https://"].some(c => target.startsWith(c)) ) {
      target = "";
    }

    // Local file matches
    else {
      const publicDirs = ["css", "fonts", "icons", "lang", "scripts", "sounds", "ui"];
      if (publicDirs.some(d => target.startsWith(d))) source = "public";
    }

    // Split off the file name and retrieve just the directory path
    const dir = target.split("/").slice(0, -1).join("/");
    return [source, dir];
  }

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

  /**
   * Get the valid file extensions for a given named file picker type
   * @param {string} type
   * @return {string[]}
   * @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;
    }, []);
  }

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

  /**
   * Test a URL to see if it matches a well known s3 key pattern
   * @param {string} url          An input URL to test
   * @return {RegExpMatchArray|null}   A regular expression match
   */
  static matchS3URL(url) {
    const endpoint = game.data.files.s3?.endpoint;
    if ( !endpoint ) return null;

    // Match new style S3 urls
    const s3New = new RegExp(`^${endpoint.protocol}//(?<bucket>.*).${endpoint.host}/(?<key>.*)`);
    const matchNew = url.match(s3New);
    if ( matchNew ) return matchNew;

    // Match old style S3 urls
    const s3Old = new RegExp(`^${endpoint.protocol}//${endpoint.host}/(?<bucket>[^/]+)/(?<key>.*)`);
    return url.match(s3Old);
  }

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

  /**
   * 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";
    return game.i18n.localize(type === "imagevideo" ? "FILES.TitleImageVideo" : `FILES.Title${type.capitalize()}`);
  }

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

  /**
   * 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;
    options = mergeObject({
      extensions: this.extensions,
      wildcard: false
    }, options);

    // Determine the S3 buckets which may be used
    if ( source === "s3" ) {
      if ( this.constructor.S3_BUCKETS === null ) {
        const buckets = await this.constructor.browse("s3", "");
        this.constructor.S3_BUCKETS = buckets.dirs;
      }
      this.sources.s3.buckets = this.constructor.S3_BUCKETS;
      if ( !this.source.bucket ) this.source.bucket = this.constructor.S3_BUCKETS[0];
      options.bucket = this.source.bucket;
    }

    // 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 ( source === "s3" ) 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 {string[]} 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
   * @returns {Promise<object>}
   * @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"));
    }

    // Attempt to obtain the response
    const response = await request.json().catch(err => { return {} });
    if (response.error) {
      ui.notifications.error(response.error);
      return false;
    }
    else if ( !response.path ) {
      return ui.notifications.error(game.i18n.localize("FILES.ErrorSomethingWrong"));
    }

    // Display additional response messages
    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();

    // 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
      });
      if ( response ) 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;
    const form = li.closest("form");
    if ( li.classList.contains("dir") ) return this.browse(li.dataset.path);
    for ( let l of li.parentElement.children ) {
      l.classList.toggle("picked", l === li);
    }
    if ( form.file ) 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: async html => {
        const dirname = html.querySelector("input").value;
        const path = [source.target, dirname].filterJoin("/");
        await this.constructor.createDirectory(this.activeSource, path, {bucket: source.bucket});
        return this.browse(this.target);
      },
      options: {jQuery: false}
    })
  }

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

  /**
   * Handle changes to the bucket selector
   * @private
   */
  _onChangeBucket(event) {
    event.preventDefault();
    const select = event.currentTarget;
    this.sources.s3.bucket = select.value;
    return this.browse("/");
  }

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

  /** @override */
  _onSearchFilter(event, query, html) {
    const rgx = new RegExp(RegExp.escape(query), "i");
    for ( let ol of html.querySelectorAll(".directory") ) {
      for ( let li of ol.children ) {
        const f = li.dataset.path.split("/").pop();
        li.style.display = !rgx.test(f) ? "none" : "";
      }
    }
  }

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

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

    // Handle errors
    if ( response.error ) {
      console.error(response.error);
      return ui.notifications.error(response.error);
    }

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

/**
 * Cache the names of S3 buckets which can be used
 * @type {Array|null}
 */
FilePicker.S3_BUCKETS = null;

/**
 * A controller class for managing a text input widget that filters the contents of some other UI element
 * @see {@link Application}
 *
 * @param {string} inputSelector    The CSS selector used to target the text input element.
 * @param {string} contentSelector  The CSS selector used to target the content container for these tabs.
 * @param {string} initial          The initial value of the search query.
 * @param {Function} callback       A callback function which executes when the filter changes.
 * @param {number} delay            The number of milliseconds to wait for text input before processing.
 */
class SearchFilter {
  constructor({inputSelector, contentSelector, initial="", callback, delay=100}={}) {

    /**
     * The value of the current query string
     * @type {string}
     */
    this.query = 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._inputSelector = inputSelector;

    /**
     * A reference to the HTML navigation element the tab controller is bound to
     * @type {HTMLElement|null}
     */
    this._input = 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;

    /**
     * A debounced function which applies the search filtering
     * @type {Function}
     */
    this._filter = debounce(this.callback, delay);
  }

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

  /**
   * Bind the SearchFilter controller to an HTML application
   * @param {HTMLElement} html
   */
  bind(html) {

    // Identify navigation element
    this._input = html.querySelector(this._inputSelector);
    if ( !this._input ) return;
    this._input.value = this.query;

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

    // Register the handler for input changes
    this._input.addEventListener("keyup", this._onKeyUp.bind(this));
    this._input.addEventListener("keydown", event => {
      if ( event.key === "Enter" ) event.preventDefault();
    });

    // Set the initial filtering conditions
    const event = new KeyboardEvent("keyup", {"key": "Enter", "code": "Enter"});
    this.callback(event, this.query, this._content)
  }

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

  /**
   * Handle key-up events within the filter input field
   * @param {KeyboardEvent} event   The key-up event
   * @private
   */
  _onKeyUp(event) {
    event.preventDefault();
    let input = event.currentTarget;
    this.query = input.value.trim();
    this._filter(event, this.query, this._content);
  }
}
/**
 * An extension of the native FormData implementation.
 *
 * This class functions the same way that the default FormData does, but it is more opinionated about how
 * input fields of certain types should be evaluated and handled.
 *
 * It also adds support for certain Foundry VTT specific concepts including:
 *  Support for defined data types and type conversion
 *  Support for TinyMCE editors
 *  Support for editable HTML elements
 *
 * @extends {FormData}
 *
 * @param {HTMLFormElement} form        The form being processed
 * @param {object[]} [editors]          An array of TinyMCE editor instances which are present in this form
 * @param {{string, string}} [dtypes]   A mapping of data types for form fields
 */
class FormDataExtended extends FormData {
  constructor(form, {editors=[], dtypes={}}={}) {
    super();

    /**
     * A mapping of data types requested for each form field
     * @type {{string, string}}
     */
    this.dtypes = dtypes;

    /**
     * A record of TinyMCE editors which are linked to this form
     * @type {object[]}
     */
    this.editors = editors;

    // Process the provided form
    this.process(form);
  }

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

  /**
   * Process the HTML form element to populate the FormData instance.
   * @param {HTMLFormElement} form      The HTML form
   */
  process(form) {

    // Process standard form elements
    for ( let el of form.elements ) {
      if ( !el.name || el.disabled || (el.tagName === "BUTTON") ) continue;
      if ( this.has(el.name) ) continue;
      const field = form.elements[el.name];
      this.dtypes[el.name] = el.dataset.dtype ?? "String";

      // Radio Checkboxes
      if ( el.type === "radio" ) {
        const chosen = Array.from(field).find(r => r.checked);
        this.set(el.name, chosen ? chosen.value : null);
        continue;
      }

      // Multi-Select
      if ( el.type === "select-multiple" ) {
        const chosen = [];
        for ( let opt of el.options ) {
          if ( opt.selected ) chosen.push(opt.value);
        }
        this.dtypes[el.name] = "JSON";
        this.set(el.name, JSON.stringify(chosen));
        continue;
      }

      // Duplicate Fields
      if ( field instanceof RadioNodeList ) {
        const values = [];
        for ( let f of field ) {
          if ( f.disabled ) values.push(null);
          else if ( f.type === "checkbox" ) values.push(f.checked);
          else values.push(f.value)
        }
        this.set(el.name, JSON.stringify(values));
        this.dtypes[el.name] = "JSON";
        continue;
      }

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

      // Other Inputs
      if ( ["number", "range"].includes(el.type) ) {
        this.dtypes[el.name] = "Number";
      }
      this.set(el.name, el.value.trim());
    }

    // Process MCE editors
    for ( let [name, editor] of Object.entries(this.editors) ) {
      if ( editor.mce ) {
        this.set(name, editor.mce.getContent());
        this.delete(editor.mce.id); // Delete hidden MCE inputs
      }
    }

    // Process editable HTML fields
    const editableFields = form.querySelectorAll('[data-edit]');
    const path = [window.location.origin, ROUTE_PREFIX].filterJoin("/") + "/";
    for ( let el of editableFields ) {
      const name = el.dataset.edit;
      if ( this.has(name) || el.getAttribute("disabled") || (name in this.editors) ) continue;
      if (el.tagName === "IMG") this.set(name, el.src.replace(path, ""));
      else this.set(name, el.innerHTML.trim());
      this.dtypes[name] = el.dataset.dtype ?? "String";
    }
  }

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

  /**
   * Export the FormData as an object
   * @return {object}
   */
  toObject() {
    const data = {};
    for ( let [k, v] of this.entries() ) {
      const dtype = this.dtypes[k];
      if ( dtype === "Boolean" ) v = v === "true";
      else if ( dtype === "JSON" ) v = JSON.parse(v);
      else if ( (dtype === "String") && !v ) v = "";
      else {
        if ( v === "" ) v = null;
        if ( (v !== null) && ( window[dtype] instanceof Function ) ) v = window[dtype](v);
      }
      data[k] = v;
    }
    return data;
  }
}

/**
 * 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 {object[]}
     */
    this.queue = [];

    /**
     * Notifications which are currently displayed
     * @type {object[]}
     */
    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 */
  _renderInner(...args) {
    return $('<ol id="notifications"></ol>');
  }

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

  /** @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
   * @returns {void}
   */
	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
   * @returns {void}
   */
  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
   * @returns {void}
   */
  error(message, options) {
	  this.notify(message, "error", options);
  }

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

  /**
   * Retrieve a pending notification from the queue and display it
   * @private
   * @returns {void}
   */
	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();
      return this.fetch();
    };

    // Construct a new notification
    const cls = ["notification", next.type, next.permanent ? "permanent": null].filterJoin(" ");
    const li = $(`<li class="${cls}">${next.message}<i class="close fas fa-times-circle"></i></li>`);
``
    // Add click listener to dismiss
    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 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" data-group="primary-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" data-group="primary-tabs">Content 1</div>
 *   <div class="tab" data-tab="tab2" data-group="primary-tabs">Content 2</div>
 * </section>
 *
 * @example
 * // JavaScript
 * const tabs = new Tabs({navSelector: ".tabs", contentSelector: ".content", initial: "tab1"});
 * tabs.bind(html);
 */
class Tabs {
  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 group = this._nav.dataset.group;
    const items = this._nav.querySelectorAll("[data-tab]");
    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("[data-tab]");
      for ( let t of tabs ) {
        if ( t.dataset.group && (t.dataset.group !== group) ) continue;
        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("[data-tab]");
    if ( !tab ) return;
    event.preventDefault();
    const tabName = tab.dataset.tab;
    if ( tabName !== this.active) this.activate(tabName, {triggerCallback: true});
  }
}

const TabsV2 = Tabs;
/**
 * Render the Sidebar container, and after rendering insert Sidebar tabs
 */
class Sidebar extends Application {
  constructor(...args) {
    super(...args);

    /**
     * Sidebar application instances
     * @type {Application[]}
     */
    this.apps = [];

    /**
     * Track whether the sidebar container is currently collapsed
     * @type {boolean}
     */
    this._collapsed = false;
  }

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

  /** @inheritdoc */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "sidebar",
      template: "templates/sidebar/sidebar.html",
      popOut: false,
      width: 300,
      tabs: [{navSelector: ".tabs", contentSelector: "#sidebar", initial: "chat"}]
    });
  }

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

  /**
   * Return the name of the active Sidebar tab
   * @type {string}
   */
  get activeTab() {
    return this._tabs[0].active;
  }

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

  /**
   * Return an Array of pop-out sidebar tab Application instances
   * @type {Application[]}
   */
  get popouts() {
    return this.apps.map(a => a._popout).filter(p => p);
  }

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

  /** @override */
  getData(options) {
    return {
      coreUpdate: game.data.coreUpdate ? game.i18n.format("SETUP.UpdateAvailable", game.data.coreUpdate) : false,
      user: game.user
    };
  }

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

  /** @override */
	async _render(...args) {

	  // Render the Sidebar container only once
    if ( !this.rendered ) await super._render(...args);

    // Define the sidebar tab names to render
	  const tabs = ["chat", "combat", "actors", "items", "journal", "tables", "playlists", "compendium", "settings"];
	  if ( game.user.isGM ) tabs.push("scenes");

    // Render sidebar Applications
    for ( let name of tabs ) {
      const app = ui[name];
      try {
        await app._render(true, {})
      } catch(err) {
        console.error(`Failed to render Sidebar tab ${name}`);
        console.error(err);
      }
    }
  }

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

  /**
   * Activate a Sidebar tab by it's name
   * @param {string} tabName      The tab name corresponding to it's "data-tab" attribute
   */
  activateTab(tabName) {
    this._tabs[0].activate(tabName, {triggerCallback: true});
  }

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

  /**
   * Expand the Sidebar container from a collapsed state.
   * Take no action if the sidebar is already expanded.
   */
  expand() {
    if ( !this._collapsed ) return;
    const sidebar = this.element;
    const tab = sidebar.find(".sidebar-tab.active");
    const icon = sidebar.find("#sidebar-tabs a.collapse i");

    // Animate the sidebar expansion
    tab.hide();
    sidebar.animate({width: this.options.width, height: this.position.height}, 150, () => {
      sidebar.css({width: "", height: ""});
      icon.removeClass("fa-caret-left").addClass("fa-caret-right");
      tab.fadeIn(250, () => tab.css("display", ""));
      this._collapsed = false;
      sidebar.removeClass("collapsed");
      Hooks.callAll("sidebarCollapse", this, this._collapsed);
    })
  }

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

  /**
   * Collapse the sidebar to a minimized state.
   * Take no action if the sidebar is already collapsed.
   */
  collapse() {
    if ( this._collapsed ) return;
    const sidebar = this.element;
    const tab = sidebar.find(".sidebar-tab.active");
    const icon = sidebar.find("#sidebar-tabs a.collapse i");

    // Animate the sidebar collapse
    tab.fadeOut(250, () => {
      sidebar.animate({width: 30, height: 370}, 150, () => {
        icon.removeClass("fa-caret-right").addClass("fa-caret-left");
        this._collapsed = true;
        sidebar.addClass("collapsed");
        tab.css("display", "");
        Hooks.callAll("sidebarCollapse", this, this._collapsed);
      })
    })
  }

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

  /** @inheritdoc */
	activateListeners(html) {
	  super.activateListeners(html);

    // Right click pop-out
    const nav = this._tabs[0]._nav;
    nav.addEventListener('contextmenu', this._onRightClickTab.bind(this));

    // Toggle Collapse
    const collapse = nav.querySelector(".collapse");
    collapse.addEventListener("click", this._onToggleCollapse.bind(this));
  }

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

  /** @override */
  _onChangeTab(event, tabs, active) {
    const app = ui[active];
    if ( (active === "chat") && app ) app.scrollBottom();
    if ( this._collapsed ) {
      if ( active !== "chat") app.renderPopout(app);
    }
  }

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

  /**
   * Handle right-click events on tab controls to trigger pop-out containers for each tab
   * @param {Event} event     The originating contextmenu event
   * @private
   */
  _onRightClickTab(event) {
    const li = event.target.closest(".item");
    if ( !li ) return;
    event.preventDefault();
    const tabName = li.dataset.tab;
    const tabApp = ui[tabName];
    if ( tabName !== "chat" ) tabApp.renderPopout(tabApp);
  }

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

  /**
   * Handle toggling of the Sidebar container's collapsed or expanded state
   * @param {Event} event
   * @private
   */
  _onToggleCollapse(event) {
    event.preventDefault();
    if ( this._collapsed ) this.expand();
    else this.collapse();
  }
}

/**
 * An abstract pattern followed by the different tabs of the sidebar
 * @type {Application}
 * @abstract
 * @interface
 */
class SidebarTab extends Application {
  constructor(...args) {
    super(...args);

    /**
     * The base name of this sidebar tab
     * @type {string}
     */
    this.tabName = this.constructor.defaultOptions.id;

    /**
     * A reference to the pop-out variant of this SidebarTab, if one exists
     * @type {SidebarTab}
     * @private
     */
    this._popout = null;

    /**
     * Denote whether or not this is the original version of the sidebar tab, or a pop-out variant
     * @type {SidebarTab}
     */
    this._original = null;
  }

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

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    popOut: false,
      width: 300,
      baseApplication: "SidebarTab"
    });
  }

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

  /** @override */
	async _renderInner(data) {
	  let html = await super._renderInner(data);
	  if ( ui.sidebar && ui.sidebar.activeTab === this.options.id ) html.addClass('active');
	  if ( this.popOut ) html.removeClass("tab");
	  return html;
  }

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

  /** @override */
	async _render(...args) {

	  // Trigger rendering of pop-out tabs
    if ( this._popout ) {
      this._popout.render(...args);
    }

	  // Resize pop-out tabs
	  if ( this._original ) {
	    this.position.height = "auto";
    }

	  // Parent rendering logic
    return super._render(...args);
  }

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

  /**
   * Activate this SidebarTab, switching focus to it
   */
  activate() {
    ui.sidebar.activateTab(this.tabName);
  }

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

  /** @override */
	async close() {
	  if ( this.popOut ) {
	    const base = this._original;
	    base._popout = null;
	    return super.close();
    }
    return false;
  }

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

  /**
   * Create a second instance of this SidebarTab class which represents a singleton popped-out container
   * @return {SidebarTab}   The popped out sidebar tab instance
   */
  createPopout() {
    if ( this._popout ) return this._popout;
    const pop = new this.constructor({
      id: `${this.options.id}-popout`,
      classes: this.options.classes.concat([["sidebar-popout"]]),
      popOut: true
    });
    this._popout = pop;
    pop._original = this;
    return pop;
  }

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

  /**
   * Render the SidebarTab as a pop-out container
   */
	renderPopout() {
	  const pop = this.createPopout();
	  pop.render(true);
  }

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

  /**
   * Handle lazy loading for sidebar images to only load them once they become observed
   * @param entries
   * @param observer
   */
  _onLazyLoadImage(entries, observer) {
    for ( let e of entries ) {
      if ( !e.isIntersecting ) continue;
      const li = e.target;

      // Background Image
      if ( li.dataset.backgroundImage ) {
        li.style["background-image"] = `url("${li.dataset.backgroundImage}")`;
        delete li.dataset.backgroundImage;
      }

      // Avatar image
      const img = li.querySelector("img");
      if ( img && img.dataset.src ) {
        img.src = img.dataset.src;
        delete img.dataset.src;
      }

      // No longer observe the target
      observer.unobserve(e.target);
    }
  }
}

/**
 * A shared pattern for the sidebar directory which Actors, Items, and Scenes all use
 * @extends {SidebarTab}
 * @abstract
 * @interface
 */
class SidebarDirectory extends SidebarTab {
  constructor(options) {
    super(options);

    /**
     * References to the set of Entities which are displayed in the Sidebar
     * @type {Entity[]}
     */
    this.entities = null;

    /**
     * Reference the set of Folders which exist in this Sidebar
     * @type {Folder[]}
     */
    this.folders = null;

    // Initialize sidebar content
    this.initialize();

    // Record the directory as an application of the collection if it is not a popout
    if ( !this.options.popOut ) this.constructor.collection.apps.push(this);
  }

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

  /** @override */
	static get defaultOptions() {
	  const el = this.entity.toLowerCase();
	  return mergeObject(super.defaultOptions, {
      id: `${el}s`,
      template: `templates/sidebar/${el}-directory.html`,
      title: `${this.entity}s Directory`,
      renderUpdateKeys: ["name", "img", "thumb", "permission", "sort", "folder"],
      height: "auto",
      scrollY: ["ol.directory-list"],
      dragDrop: [{ dragSelector: ".directory-item",  dropSelector: ".directory-list"}],
      filters: [{inputSelector: 'input[name="search"]', contentSelector: ".directory-list"}]
    });
  }

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

  /**
   * The named entity which this Sidebar Directory contains
   * @type {string}
   */
	static get entity() {
	  throw "A SidebarDirectory subclass must define the entity name which it displays."
  }

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

  /**
   * The Entity collection which this Sidebar Directory contains
   * @type {EntityCollection}
   */
  static get collection() {
    throw "A SidebarDirectory subclass must define the EntityCollection it displays."
  }

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

  /**
   * A reference to the Entity class which is displayed within this EntityCollection
   * @return {Entity}
   */
  static get cls() {
    return CONFIG[this.entity].entityClass;
  }

	/* -------------------------------------------- */
  /*  Initialization Helpers
	/* -------------------------------------------- */

  /**
   * Initialize the content of the directory by categorizing folders and entities into a hierarchical tree structure.
   */
  initialize() {

    // Assign Folders
    this.folders = game.folders.filter(f => f.type === this.constructor.entity);

    // Assign Entities
    this.entities = this.constructor.collection.filter(e => e.visible);

    // Build Tree
    this.tree = this.constructor.setupFolders(this.folders, this.entities);
  }

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

  /**
   * Given an entity type and a list of entities, set up the folder tree for that entity
   * @param {Folder[]} folders    The Array of Folder objects to organize
   * @param {Entity[]} entities   The Array of Entity objects to organize
   * @return {Object}             A tree structure containing the folders and entities
   */
  static setupFolders(folders, entities) {
    entities = entities.filter(a => a.visible);
    const depths = [];
    const handled = new Set();

    // Iterate parent levels
    const root = {_id: null};
    let batch = [root];
    for ( let i = 0; i < CONST.FOLDER_MAX_DEPTH; i++ ) {
      depths[i] = [];
      for ( let folder of batch ) {
        if ( handled.has(folder.id) ) continue;

        // Classify content for this folder
        try {
          [folders, entities] = this._populate(folder, folders, entities);
        } catch(err) {
          console.error(err);
          continue;
        }

        // Add child folders to the correct depth level
        depths[i] = depths[i].concat(folder.children);
        handled.add(folder.id);
      }
      batch = depths[i];
    }

    // Populate content to any remaining folders and assign them to the root level
    const remaining = depths[CONST.FOLDER_MAX_DEPTH-1].concat(folders);
    for ( let f of remaining ) {
      [folders, entities] = this._populate(f, folders, entities, {allowChildren: false});
    }
    depths[0] = depths[0].concat(folders);

    // Filter folder visibility
    for ( let i = CONST.FOLDER_MAX_DEPTH - 1; i >= 0; i-- ) {
      depths[i] = depths[i].reduce((arr, f) => {
        f.children = f.children.filter(c => c.displayed);
        if ( !f.displayed ) return arr;
        f.depth = i+1;
        arr.push(f);
        return arr;
      }, []);
    }

    // Return the root level contents of folders and entities
    return {
      root: true,
      content: root.content.concat(entities),
      children: depths[0]
    };
  }

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

  /**
   * Populate a single folder with child folders and content
   * This method is called recursively when building the folder tree
   * @private
   */
  static _populate(folder, folders, entities, {allowChildren=true}={}) {
    const id = folder._id;

    // Define sorting function for this folder
    const alpha = folder.data?.sorting === "a";
    const s = alpha ? (a, b) => a.name.localeCompare(b.name) : (a, b) => a.data.sort - b.data.sort;

    // Partition folders into children and unassigned folders
    let [u, children] = folders.partition(f => allowChildren && (f.data.parent === id));
    folder.children = children.sort(s);
    folders = u;

    // Partition entities into contents and unassigned entities
    const [e, content] = entities.partition(e => e.data.folder === id);
    folder.content = content.sort(s);
    entities = e;

    // Return the remainder
    return [folders, entities];
  }

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

  /** @override */
	render(force, context={}) {

	  // Only re-render the sidebar directory for certain types of updates
	  const {action, data, entityType} = context;
	  if ( action && !["create", "update", "delete"].includes(action) ) return;
	  if ( (entityType !== "Folder") && (action === "update") && !data.some(d => {
	    return this.options.renderUpdateKeys.some(k => k in d);
    }) ) return;

	  // Re-build the tree and render
    this.initialize();
	  super.render(force, context);
    return this;
  }

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

  /** @override */
  getData(options) {
    return {
      user: game.user,
      tree: this.tree,
      canCreate: this.constructor.cls.can(game.user, "create"),
      sidebarIcon: CONFIG[this.constructor.entity].sidebarIcon
    };
  }

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

  /** @override */
  _onSearchFilter(event, query, html) {
    const isSearch = !!query;
    let entityIds = new Set();
    let folderIds = new Set();

    // Match entities and folders
    if ( isSearch ) {
      const rgx = new RegExp(RegExp.escape(query), "i");

      // Match entity names
      for ( let e of this.entities ) {
        if ( rgx.test(e.name) ) {
          entityIds.add(e.id);
          if ( e.data.folder ) folderIds.add(e.data.folder);
        }
      }

      // Match folder tree
      const includeFolders = fids => {
        const folders = this.folders.filter(f => fids.has(f._id));
        const pids = new Set(folders.filter(f => f.data.parent).map(f => f.data.parent));
        if ( pids.size ) {
          pids.forEach(p => folderIds.add(p));
          includeFolders(pids);
        }
      };
      includeFolders(folderIds);
    }

    // Toggle each directory item
    for ( let el of html.querySelectorAll(".directory-item") ) {

      // Entities
      if (el.classList.contains("entity")) {
        el.style.display = (!isSearch || entityIds.has(el.dataset.entityId)) ? "flex" : "none";
      }

      // Folders
      if (el.classList.contains("folder")) {
        let match = isSearch && folderIds.has(el.dataset.folderId);
        el.style.display = (!isSearch || match) ? "flex" : "none";
        if (isSearch && match) el.classList.remove("collapsed");
        else el.classList.toggle("collapsed", !game.folders._expanded[el.dataset.folderId]);
      }
    }
  }

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

  /**
   * Collapse all subfolders in this directory
   */
  collapseAll() {
    this.element.find('li.folder').addClass("collapsed");
    for ( let f of this.folders ) {
      game.folders._expanded[f._id] = false;
    }
    if ( this.popOut ) this.setPosition();
  }

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

  /**
   * Activate event listeners triggered within the Actor Directory HTML
   */
	activateListeners(html) {
	  super.activateListeners(html);
    const directory = html.find(".directory-list");
    const entries = directory.find(".directory-item");

    // Directory-level events
    html.find('.create-entity').click(ev => this._onCreateEntity(ev));
    html.find('.collapse-all').click(this.collapseAll.bind(this));
    html.find(".folder .folder .folder .create-folder").remove(); // Prevent excessive folder nesting
    if ( game.user.isGM ) html.find('.create-folder').click(ev => this._onCreateFolder(ev));

	  // Entry-level events
    directory.on("click", ".entity-name", this._onClickEntityName.bind(this));
    directory.on("click", ".folder-header", this._toggleFolder.bind(this));
    const dh = this._onDragHighlight.bind(this);
    html.find(".folder").on("dragenter", dh).on("dragleave", dh);
    this._contextMenu(html);

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

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

  /**
   * Handle clicking on an Entity name in the Sidebar directory
   * @param {Event} event   The originating click event
   * @private
   */
  _onClickEntityName(event) {
    event.preventDefault();
    const element = event.currentTarget;
    const entityId = element.parentElement.dataset.entityId;
    const entity = this.constructor.collection.get(entityId);
    const sheet = entity.sheet;

    // If the sheet is already rendered:
    if ( sheet.rendered ) {
      sheet.maximize();
      sheet.bringToTop();
    }

    // Otherwise render the sheet
    else sheet.render(true);
  }

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

  /**
   * Handle new creation request
   * @param {MouseEvent} event    The originating button click event
   * @private
   */
  async _onCreateEntity(event) {
    event.preventDefault();
    event.stopPropagation();
    const button = event.currentTarget;
    const data = {folder: button.dataset.folder};
    const options = {width: 320, left: window.innerWidth - 630, top: button.offsetTop };
    return this.constructor.cls.createDialog(data, options);
  }

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

  /**
   * Create a new Folder in this SidebarDirectory
   * @param {MouseEvent} event    The originating button click event
   * @private
   */
	_onCreateFolder(event) {
	  event.preventDefault();
	  event.stopPropagation();
	  const button = event.currentTarget;
    const parent = button.dataset.parentFolder;
    const data = {parent: parent ? parent : null, type: this.constructor.entity};
    const options = {top: button.offsetTop, left: window.innerWidth - 310 - FolderConfig.defaultOptions.width};
	  Folder.createDialog(data, options);
  }

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

  /**
   * Handle toggling the collapsed or expanded state of a folder within the directory tab
   * @param {MouseEvent} event    The originating click event
   * @private
   */
  _toggleFolder(event) {
    let folder = $(event.currentTarget.parentElement);
    let collapsed = folder.hasClass("collapsed");
    game.folders._expanded[folder.attr("data-folder-id")] = collapsed;

    // Expand
    if ( collapsed ) folder.removeClass("collapsed");

    // Collapse
    else {
      folder.addClass("collapsed");
      const subs = folder.find('.folder').addClass("collapsed");
      subs.each((i, f) => game.folders._expanded[f.dataset.folderId] = false);
    }

    // Resize container
    if ( this.popOut ) this.setPosition();
  }

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

  /** @override */
	_onDragStart(event) {
	  let li = event.currentTarget.closest(".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;
  }

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

  /**
   * Highlight folders as drop targets when a drag event enters or exits their area
   * @param {DragEvent} event     The DragEvent which is in progress
   */
  _onDragHighlight(event) {
    const li = event.currentTarget;
    if ( !li.classList.contains("folder") ) return;
    event.stopPropagation();  // Don't bubble to parent folders

    // Remove existing drop targets
    if ( event.type === "dragenter" ) {
      for ( let t of li.closest(".directory-list").querySelectorAll(".droptarget") ) {
        t.classList.remove("droptarget");
      }
    }

    // Remove current drop target
    if ( event.type === "dragleave" ) {
      const el = document.elementFromPoint(event.clientX, event.clientY);
      const parent = el.closest(".folder");
      if ( parent === li ) return;
    }

    // Add new drop target
    li.classList.toggle("droptarget", event.type === "dragenter");
  }

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

  /** @override */
  _onDrop(event) {
    const cls = this.constructor.entity;

    // Try to extract the data
    let data;
    try {
      data = JSON.parse(event.dataTransfer.getData('text/plain'));
    }
    catch (err) {
      return false;
    }
    let correctType = (data.type === cls) || ((data.type === "Folder") && (data.entity === cls));
    if ( !correctType ) return false;

    // Call the drop handler
    this._handleDropData(event, data);
  }

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

  /**
   * Define the behavior of the sidebar tab when it received a dropped data object
   * @param {Event} event   The original drop event
   * @param {Object} data   The data being dropped
   * @private
   */
  _handleDropData(event, data) {

    // Determine the drop target
    const collection = this.constructor.collection;
    const sel = this._dragDrop[0].dragSelector;
    const dt = event.target.closest(sel) || null;
    const isFolder = dt && dt.classList.contains("folder");
    const targetId = dt ? (isFolder ? dt.dataset.folderId : dt.dataset.entityId) : null;

    // Determine the closest folder ID
    const closestFolder = dt ? dt.closest(".folder") : null;
    if ( closestFolder ) closestFolder.classList.remove("droptarget");
    const closestFolderId = closestFolder ? closestFolder.dataset.folderId : null;

    // Case 1 - New data is explicitly provided
    if ( data.data ) {
      const createData = mergeObject(data.data, { folder: closestFolderId });
      if ( collection.get(createData._id ) ) throw new Error("An Entity with this _id already exists!");
      return collection.object.create(createData);
    }

    // Case 2 - Data to import from a Compendium pack
    else if ( data.pack ) {
      const updateData = { folder: closestFolderId };
      const options = { renderSheet: true };
      return this.constructor.collection.importFromCollection(data.pack, data.id, updateData, options);
    }

    // Case 3 - Sort a Folder
    if ( data.type === "Folder" ) {
      const folder = game.folders.get(data.id);
      const sortData = {sortKey: "sort", sortBefore: true};
      if (isFolder && dt.classList.contains("collapsed") ) {    // Dropped on a collapsed Folder
        sortData.target = game.folders.get(targetId);
        sortData.parentId = sortData.target.data.parent;
      } else if ( isFolder )  {                                 // Dropped on an expanded Folder
        if ( Number(dt.dataset.folderDepth) === CONST.FOLDER_MAX_DEPTH ) return; // Prevent going beyond max depth
        sortData.target = null;
        sortData.parentId = targetId;
        if ( data.id === targetId ) return; // Don't drop folders on yourself
      } else {                                                  // Dropped on an Entity
        sortData.parentId = closestFolderId;
        sortData.target = closestFolder && closestFolder.classList.contains("collapsed") ? closestFolder : null;
      }

      // Determine Folder siblings
      sortData.siblings = game.folders.entities.filter(f => {
        return (f.data.parent === sortData.parentId) && (f.data.type === folder.data.type) && (f !== folder);
      });
      sortData.updateData = {parent: sortData.parentId};
      folder.sortRelative(sortData);
    }

    // Case 4 - Sort an Entity
    else {
      const entity = collection.get(data.id);
      const isEntity = dt && dt.classList.contains("entity");
      const sortData = {sortKey: "sort", sortBefore: true};

      // Handle different targets
      if ( isEntity ) {   // Drop on an Entity
        sortData.target = collection.get(targetId);
        sortData.folderId = sortData.target.data.folder;
      } else {            // Drop on a Folder or null
        sortData.target = null;
        sortData.folderId = closestFolderId;
      }

      // Determine Entity siblings and updateData
      sortData.siblings = collection.entities.filter(e => {
        return (e.data.folder === sortData.folderId) && (e._id !== data.id);
      });
      sortData.updateData = { folder: sortData.folderId };
      entity.sortRelative(sortData);
    }
  }

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

  /**
   * Default folder context actions
   * @param html {jQuery}
   * @private
   */
  _contextMenu(html) {

    // Folder Context
    const folderOptions = this._getFolderContextOptions();
    Hooks.call(`get${this.constructor.name}FolderContext`, html, folderOptions);
    if (folderOptions) new ContextMenu(html, ".folder .folder-header", folderOptions);

    // Entity Context
    const entryOptions = this._getEntryContextOptions();
    Hooks.call(`get${this.constructor.name}EntryContext`, html, entryOptions);
    if (entryOptions) new ContextMenu(html, ".entity", entryOptions);
  }

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

  /**
   * Get the set of ContextMenu options which should be used for Folders in a SidebarDirectory
   * @return {object[]}   The Array of context options passed to the ContextMenu instance
   * @private
   */
  _getFolderContextOptions() {
    return [
      {
        name: "FOLDER.Edit",
        icon: '<i class="fas fa-edit"></i>',
        condition: game.user.isGM,
        callback: header => {
          const li = header.parent()[0];
          const folder = game.folders.get(li.dataset.folderId);
          const options = {top: li.offsetTop, left: window.innerWidth - 310 - FolderConfig.defaultOptions.width};
          new FolderConfig(folder, options).render(true);
        }
      },
      {
        name: "PERMISSION.Configure",
        icon: '<i class="fas fa-lock"></i>',
        condition: () => game.user.isGM,
        callback: header => {
          const li = header.parent()[0];
          const folder = game.folders.get(li.dataset.folderId);
          new PermissionControl(folder, {
            top: Math.min(li.offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 720
          }).render(true);
        }
      },
      {
        name: "FOLDER.Export",
        icon: `<i class="fas fa-atlas"></i>`,
        condition: header => {
          const folder = game.folders.get(header.parent().data("folderId"));
          return CONST.COMPENDIUM_ENTITY_TYPES.includes(folder.type);
        },
        callback: header => {
          const li = header.parent();
          const folder = game.folders.get(li.data("folderId"));
          return folder.exportDialog(null, {
              top: Math.min(li[0].offsetTop, window.innerHeight - 350),
              left: window.innerWidth - 720,
              width: 400
          });
        }
      },
      {
        name: "FOLDER.CreateTable",
        icon: `<i class="${CONFIG.RollTable.sidebarIcon}"></i>`,
        condition: header => {
          const folder = game.folders.get(header.parent().data("folderId"));
          return CONST.COMPENDIUM_ENTITY_TYPES.includes(folder.type);
        },
        callback: header => {
          const li = header.parent()[0];
          const folder = game.folders.get(li.dataset.folderId);
          return Dialog.confirm({
            title: `${game.i18n.localize("FOLDER.CreateTable")}: ${folder.name}`,
            content: game.i18n.localize("FOLDER.CreateTableConfirm"),
            yes: () => RollTable.fromFolder(folder),
            options: {
              top: Math.min(li.offsetTop, window.innerHeight - 350),
              left: window.innerWidth - 680,
              width: 360
            }
          });
        }
      },
      {
        name: "FOLDER.Remove",
        icon: '<i class="fas fa-trash"></i>',
        condition: game.user.isGM,
        callback: header => {
          const li = header.parent();
          const folder = game.folders.get(li.data("folderId"));
          Dialog.confirm({
            title: `${game.i18n.localize("FOLDER.Remove")} ${folder.name}`,
            content: game.i18n.localize("FOLDER.RemoveConfirm"),
            yes: () => folder.delete({deleteSubfolders: false, deleteContents: false}),
            options: {
              top: Math.min(li[0].offsetTop, window.innerHeight - 350),
              left: window.innerWidth - 720,
              width: 400
            }
          });
        }
      },
      {
        name: "FOLDER.Delete",
        icon: '<i class="fas fa-dumpster"></i>',
        condition: game.user.isGM,
        callback: header => {
          const li = header.parent();
          const folder = game.folders.get(li.data("folderId"));
          Dialog.confirm({
            title: `${game.i18n.localize("FOLDER.Delete")} ${folder.name}`,
            content: game.i18n.localize("FOLDER.DeleteConfirm"),
            yes: () => folder.delete({deleteSubfolders: true, deleteContents: true}),
            options: {
              top: Math.min(li[0].offsetTop, window.innerHeight - 350),
              left: window.innerWidth - 720,
              width: 400
            }
          });
        }
      }
    ];
  }

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

  /**
   * Get the set of ContextMenu options which should be used for Entities in a SidebarDirectory
   * @return {object[]}   The Array of context options passed to the ContextMenu instance
   * @private
   */
  _getEntryContextOptions() {
    return [
      {
        name: "FOLDER.Clear",
        icon: '<i class="fas fa-folder"></i>',
        condition: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          return game.user.isGM && !!entity.data.folder;
        },
        callback: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          entity.update({folder: null});
        }
      },
      {
        name: "SIDEBAR.Delete",
        icon: '<i class="fas fa-trash"></i>',
        condition: () => game.user.isGM,
        callback: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          Dialog.confirm({
            title: `${game.i18n.localize("SIDEBAR.Delete")} ${entity.name}`,
            content: game.i18n.localize("SIDEBAR.DeleteConfirm"),
            yes: entity.delete.bind(entity),
            options: {
              top: Math.min(li[0].offsetTop, window.innerHeight - 350),
              left: window.innerWidth - 720
            }
          });
        }
      },
      {
        name: "SIDEBAR.Duplicate",
        icon: '<i class="far fa-copy"></i>',
        condition: () => game.user.isGM,
        callback: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          return entity.clone({name: `${entity.name} (Copy)`});
        }
      },
      {
        name: "PERMISSION.Configure",
        icon: '<i class="fas fa-lock"></i>',
        condition: () => game.user.isGM,
        callback: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          new PermissionControl(entity, {
            top: Math.min(li[0].offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 720
          }).render(true);
        }
      },
      {
        name: "SIDEBAR.Export",
        icon: '<i class="fas fa-file-export"></i>',
        condition: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          return entity.owner;
        },
        callback: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          entity.exportToJSON();
        }
      },
      {
        name: "SIDEBAR.Import",
        icon: '<i class="fas fa-file-import"></i>',
        condition: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          return entity.owner;
        },
        callback: li => {
          const entity = this.constructor.collection.get(li.data("entityId"));
          entity.importFromJSONDialog();
        }
      }
    ];
  }
}

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

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

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

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

  /** @override */
  get id() {
    const actor = this.actor;
    let id = `actor-${actor.id}`;
    if (actor.isToken) id += `-${actor.token.id}`;
    return id;
  }

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

  /** @override */
  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(options) {
    const data = super.getData(options);

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

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

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

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

  /** @override */
  _updateObject(event, formData) {
    const diffData = diffObject(this.actor.data, expandObject(formData));
    return super._updateObject(event, diffData);
  }

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

    // Skip entity links, since they should be handled differently
    if ( event.target.classList.contains("entity-link") ) return;

    // Create drag data for an owned item
    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.sceneId = canvas.scene.id;
      dragData.tokenId = this.actor.token.id;
    }

    // Set data transfer
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

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

  /** @override */
  async _onDrop(event) {

    // Try to extract the data
    let data;
    try {
      data = JSON.parse(event.dataTransfer.getData('text/plain'));
    } catch (err) {
      return false;
    }
    const actor = this.actor;

    // Handle the drop with a Hooked function
    const allowed = Hooks.call("dropActorSheetData", actor, this, data);
    if ( allowed === false ) return;

    // Handle different data types
    switch ( data.type ) {
      case "Item":
        return this._onDropItem(event, data);
      case "Actor":
        return this._onDropActor(event, data);
    }
  }

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

  /**
   * Handle dropping of an item reference or item data onto an Actor Sheet
   * @param {DragEvent} event     The concluding DragEvent which contains drop data
   * @param {Object} data         The data transfer extracted from the event
   * @return {Object}             A data object which describes the result of the drop
   * @private
   */
  async _onDropItem(event, data) {
    if ( !this.actor.owner ) return false;
    const item = await Item.fromDropData(data);
    const itemData = duplicate(item.data);

    // Handle item sorting within the same Actor
    const actor = this.actor;
    let sameActor = (data.actorId === actor._id) || (actor.isToken && (data.tokenId === actor.token.id));
    if (sameActor) return this._onSortItem(event, itemData);

    // Create the owned item
    return this._onDropItemCreate(itemData);
  }

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

  /**
   * Handle the final creation of dropped Item data on the Actor.
   * This method is factored out to allow downstream classes the opportunity to override item creation behavior.
   * @param {object} itemData     The item data requested for creation
   * @return {Promise<Actor>}
   * @private
   */
  async _onDropItemCreate(itemData) {
    return this.actor.createEmbeddedEntity("OwnedItem", itemData);
  }

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

  /**
   * 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 overrides
    if (this.actor.isToken) return;

    // Get the drag source and its siblings
    const source = this.actor.getOwnedItem(itemData._id);
    const siblings = this.actor.items.filter(i => {
      return (i.data.type === source.data.type) && (i.data._id !== source.data._id);
    });

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

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

  /**
   * Handle dropping of an Actor data onto another Actor sheet
   * @param {DragEvent} event     The concluding DragEvent which contains drop data
   * @param {Object} data         The data transfer extracted from the event
   * @return {Object}             A data object which describes the result of the drop
   * @private
   */
  async _onDropActor(event, data) {
    if ( !this.actor.owner ) return false;
  }
}
/**
 * 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);
  }
}

/**
 * Configure or create a single Combatant within a Combat entity.
 * @type {FormApplication}
 */
class CombatantConfig extends FormApplication {
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "combatant-config",
      title: game.i18n.localize("COMBAT.CombatantConfig"),
      classes: ["sheet", "combat-sheet"],
      template: "templates/sheets/combatant-config.html",
      width: 420
    });
  }

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

  /** @override */
  get title() {
    return game.i18n.localize(this.object._id ? "COMBAT.CombatantUpdate" : "COMBAT.CombatantCreate");
  }

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

  /** @override */
  async _updateObject(event, formData) {
    if ( this.object._id ) {
      formData._id = this.object._id;
      return game.combat.updateCombatant(formData);
    }
    return game.combat.createCombatant(formData);
  }
}

/**
 * A form designed for creating and editing an Active Effect on an Actor or Item entity.
 * @implements {FormApplication}
 *
 * @param {ActiveEffect} object     The target active effect being configured
 * @param {object} [options]        Additional options which modify this application instance
 */
class ActiveEffectConfig extends FormApplication {

  /** @override */
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      classes: ["sheet", "active-effect-sheet"],
      title: "EFFECT.ConfigTitle",
      template: "templates/sheets/active-effect-config.html",
      width: 560,
      height: "auto",
      tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "details"}]
    });
  }

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

  /** @override */
  get title() {
    return `${game.i18n.localize("EFFECT.ConfigTitle")}: ${this.object.data.label}`;
  }

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

  /** @override */
  getData(options) {
    return {
      effect: duplicate(this.object.data),
      isActorEffect: this.object.parent.entity === "Actor",
      isItemEffect: this.object.parent.entity === "Item",
      submitText: "EFFECT.Submit",
      modes: Object.entries(CONST.ACTIVE_EFFECT_MODES).reduce((obj, e) => {
        obj[e[1]] = game.i18n.localize("EFFECT.MODE_"+e[0]);
        return obj;
      }, {})
    };
  }

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

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".effect-control").click(this._onEffectControl.bind(this));
  }

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

  /**
   * Provide centralized handling of mouse clicks on control buttons.
   * Delegate responsibility out to action-specific handlers depending on the button action.
   * @param {MouseEvent} event      The originating click event
   * @private
   */
  _onEffectControl(event) {
    event.preventDefault();
    const button = event.currentTarget;
    switch ( button.dataset.action ) {
      case "add":
        this._addEffectChange(button);
        return this.submit({preventClose: true}).then(() => this.render());
      case "delete":
        button.closest(".effect-change").remove();
        return this.submit({preventClose: true}).then(() => this.render());
    }
  }

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

  /**
   * Handle adding a new change to the changes array.
   * @param {HTMLElement} button    The clicked action button
   * @private
   */
  _addEffectChange(button) {
    const changes = button.closest(".tab").querySelector(".changes-list");
    const last = changes.lastElementChild;
    const idx = last ? last.dataset.index+1 : 0;
    const change = $(`
    <li class="effect-change" data-index="${idx}">
        <input type="text" name="changes.${idx}.key" value=""/>
        <input type="number" name="changes.${idx}.mode" value="2"/>
        <input type="text" name="changes.${idx}.value" value=""/>
    </li>`);
    changes.appendChild(change[0]);
  }

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

  /** @override */
  async _updateObject(event, formData) {
    formData = expandObject(formData);
    formData.changes = Object.values(formData.changes || {});
    for ( let c of formData.changes ) {
      if ( Number.isNumeric(c.value) ) c.value = parseFloat(c.value);
    }
    return this.object.update(formData);
  }
}

/**
 * Edit a folder, configuring its name and appearance
 * @extends {FormApplication}
 */
class FolderConfig extends FormApplication {

  /** @override */
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "folder-edit",
      classes: ["sheet"],
      template: "templates/sidebar/folder-edit.html",
      width: 360
    });
  }

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

  /** @override */
  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,
      sortingModes: {"a": "FOLDER.SortAlphabetical", "m": "FOLDER.SortManual"},
      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(mergeObject(this.object.data, 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"));
    }
    for ( let l of canvas.layers ) {
      if ( !["BackgroundLayer", "GridLayer"].includes(l.name) ) l.visible = false;
    }
    this._refresh({grid: true, background: true});
  }

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

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    this._keyHandler = this._keyHandler || this._onKeyDown.bind(this);
    document.addEventListener("keydown", this._keyHandler);
    this._wheelHandler = this._wheelHandler || this._onWheel.bind(this);
    document.addEventListener("wheel", this._wheelHandler, {passive: false});
    html.find('button[name="reset"]').click(this._onReset.bind(this));
  }

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

  /** @override */
  async close(options) {
    document.removeEventListener("keydown", this._keyHandler);
    document.removeEventListener("wheel", this._wheelHandler);
    this._keyHandler = this._wheelHandler = null;
    if ( canvas.ready ) await 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,
      padding: this.object.data.padding,
      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, {fromSheet: true});
  }
}

/**
 * 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 {Promise<*>}
   */
  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: "JOURNAL.ActionShow",
        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
    });
    ui.notifications.info(game.i18n.format("JOURNAL.ActionShowSuccess", {
      mode: "image",
      title: this.options.title,
      which: "all"
    }));
  }

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

  /**
   * 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 `item-${this.object.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(options) {
    const data = super.getData(options);
    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;
    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
 * @implements {BaseEntitySheet}
 *
 * @param {JournalEntry} entity     The JournalEntry instance which is being edited
 * @param {object} [options]        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,
      viewPermission: ENTITY_PERMISSIONS.NONE
    });
  }

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

  /** @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() {
    return this.object.permission ? 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 hasText = this.object.data.content;

    // If the user only has limited permission, show an image or nothing
    if ( this.object.limited ) return hasImage ? "image" : null;

    // Otherwise prefer text if it exists
    return hasText || !hasImage ? "text" : "image";
  }

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

  /** @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() {
    const buttons = super._getHeaderButtons();
    const isOwner = this.object.owner;
    const atLeastLimited = !!this.object.compendium || this.object.hasPerm(game.user, "LIMITED");
    const atLeastObserver = !!this.object.compendium || this.object.hasPerm(game.user, "OBSERVER");
    const hasMultipleModes = !!this.object.data.img && !!this.object.data.content;

    // Image Mode
    if ( isOwner || (atLeastLimited && hasMultipleModes) ) {
      buttons.unshift({
        label: "JOURNAL.ModeImage",
        class: "entry-image",
        icon: "fas fa-image",
        onclick: ev => this._onSwapMode(ev, "image")
      })
    }

    // Text Mode
    if ( isOwner || (atLeastObserver && hasMultipleModes) ) {
      buttons.unshift({
        label: "JOURNAL.ModeText",
        class: "entry-text",
        icon: "fas fa-file-alt",
        onclick: ev => this._onSwapMode(ev, "text")
      })
    }

    // Share Entry
    if ( game.user.isGM ) {
      buttons.unshift({
        label: "JOURNAL.ActionShow",
        class: "share-image",
        icon: "fas fa-eye",
        onclick: ev => this._onShowPlayers(ev)
      });
    }
    return buttons;
  }

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

  /** @override */
  getData(options) {
    const data = super.getData(options);
    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() {
	  return mergeObject(super.defaultOptions, {
      id: "permission",
      template: "templates/apps/permission.html",
      width: 400
    });
  }

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

  /** @override */
  get title() {
    return `${game.i18n.localize("PERMISSION.Title")}: ${this.entity.name}`;
  }

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

  /** @override */
  getData(options) {
    const e = this.entity;
    const isFolder = e instanceof Folder;

    // User permission levels
    const playerLevels = {};
    if ( isFolder ) {
      playerLevels["-2"] = game.i18n.localize("PERMISSION.DEFAULT");
      playerLevels["-1"] = game.i18n.localize("PERMISSION.NOCHANGE");
    } else {
      playerLevels["-1"] = game.i18n.localize("PERMISSION.DEFAULT");
    }
    for ( let [n, l] of Object.entries(CONST.ENTITY_PERMISSIONS) ) {
      playerLevels[l] = game.i18n.localize(`PERMISSION.${n}`);
    }

    // Default permission levels
    const defaultLevels = duplicate(playerLevels);
    if ( isFolder ) delete defaultLevels["-2"];
    else delete defaultLevels["-1"];

    // Player users
    const users = game.users.map(u => {
      return {
        user: u,
        level: e.data.permission?.[u._id] ?? "-1"
      };
    });

    // Construct and return the data object
    return {
      entity: e,
      currentDefault: e.data.permission?.default ?? "-1",
      instructions: game.i18n.localize(isFolder ? "PERMISSION.HintFolder" : "PERMISSION.HintEntity"),
      defaultLevels,
      playerLevels,
      isFolder,
      users
    };
  }

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

  /** @override */
 async _updateObject(event, formData) {
    event.preventDefault();
    if (!game.user.isGM) throw new Error("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 all entities in a Folder
    if ( this.entity instanceof Folder ) {
      const cls = CONFIG[this.entity.type].entityClass;
      const updates = this.entity.content.map(e => {
        const p = duplicate(e.data.permission);
        for ( let [k, v] of Object.entries(perms) ) {
          if ( v === -2 ) delete p[k];
          else p[k] = v;
        }
        return {_id: e.id, permission: p}
      });
      return cls.update(updates, {diff: false, recursive: false, noHook: true});
    }

    // Update a single Entity
    return this.entity.update({permission: perms}, {diff: false, recursive: false, noHook: true});
  }
}

/**
 * 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 `${game.i18n.localize("PLAYLIST.Edit")}: ${this.object.name}`;
  }

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

  /** @override */
  getData(options) {
    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 `${game.i18n.localize("PLAYLIST.SoundEdit")}: ${this.object.name}`;
  }

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

  /** @override */
  getData(options) {
    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() {
    return mergeObject(super.defaultOptions, {
      classes: ["sheet", "roll-table-config"],
      template: "templates/sheets/roll-table-config.html",
      width: 720,
      height: "auto",
      closeOnSubmit: false,
      viewPermission: ENTITY_PERMISSIONS.OBSERVER,
      scrollY: ["ol.table-results"],
      dragDrop: [{dragSelector: null, dropSelector: null}]
    })
  }

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

  /** @override */
  get title() {
    return `${game.i18n.localize("TABLE.SheetTitle")}: ${this.entity.name}`;
  }

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

  /** @override */
  getData(options) {
    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('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 {MouseEvent} event        The originating mouse event
   * @param {object} [resultData]     An optional object of result data to use
   * @return {Promise}
   * @private
   */
  async _onCreateResult(event, resultData={}) {
    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
    resultData = mergeObject({
      type: last ? last.type : CONST.TABLE_RESULT_TYPES.TEXT,
      collection: last ? last.collection : null,
      weight: weight,
      range: range,
      drawn: false
    }, resultData);
    return this.entity.createEmbeddedEntity("TableResult", resultData);
  }

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

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

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

  /** @override */
  async _onDrop(event) {

    // Try to extract the data
    let data;
    try {
      data = JSON.parse(event.dataTransfer.getData('text/plain'));
    } catch (err) {
      return false;
    }

    // Handle the drop with a Hooked function
    const allowed = Hooks.call("dropRollTableSheetData", this.entity, this, data);
    if (allowed === false) return;

    // Get the dropped entity
    if (!CONST.ENTITY_TYPES.includes(data.type)) return;
    const cls = CONFIG[data.type].entityClass;
    const entity = await cls.fromDropData(data);
    if ( !entity ) return;

    // Delegate to the onCreate handler
    const isCompendium = !!entity.compendium;
    return this._onCreateResult(event, {
      type: isCompendium ? CONST.TABLE_RESULT_TYPES.COMPENDIUM : CONST.TABLE_RESULT_TYPES.ENTITY,
      collection: isCompendium ? data.pack : entity.entity,
      text: entity.name,
      resultId: entity.id,
      img: entity.data.img || null
    });
  }

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

  /**
   * Handle changing the actor profile image by opening a FilePicker
   * @param {Event} event
   * @private
   */
  _onEditImage(event) {
    const img = event.currentTarget;
    const isHeader = img.dataset.edit === "img";
    let current = this.entity.data.img;
    if ( !isHeader ) {
      const li = img.closest(".table-result");
      const result = this.entity.getTableResult(li.dataset.resultId);
      if (result.type !== CONST.TABLE_RESULT_TYPES.TEXT) return;
      current = result.img;
    }
    const fp = new FilePicker({
      type: "image",
      current: current,
      callback: path => {
        img.src = path;
        this._onSubmit(event);
      },
      top: this.position.top + 40,
      left: this.position.left + 10
    });
    return fp.browse();
  }

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

  /**
   * 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: 680,
      height: "auto"
    });
  }

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

  /** @override */
  get id() {
    return `scene-config-${this.object._id}`;
  }

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

  /** @override */
  get title() {
    return `${game.i18n.localize("SCENES.ConfigTitle")}: ${this.object.name}`;
  }

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

  /** @override */
  getData(options) {
    const data = super.getData(options);

    // Selectable types
    data.gridTypes = this.constructor._getGridTypes();
    data.weatherTypes = this._getWeatherTypes();

    // Referenced entities
    data.playlists = this._getEntities(game.playlists);
    data.journals = this._getEntities(game.journal);

    // Global illumination threshold
    data.hasGlobalThreshold = data.entity.globalLightThreshold !== null;
    data.entity.globalLightThreshold = data.entity.globalLightThreshold ?? 0;
    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 {object[]}
   * @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.refresh(Number(rng.value));
    }
  }

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

  /**
   * Handle click events to open the grid configuration application
   * @param {Event} event   The originating click event
   * @private
   */
  async _onGridConfig(event) {
    event.preventDefault();
    if ( !this.object.isView ) await this.object.view();
    new GridConfig(this.object, this).render(true);
    return this.minimize();
  }

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

  /** @override */
  async _updateObject(event, formData) {

    // Capture initial camera position
    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;
    }

    // Toggle global illumination threshold
    if ( formData.hasGlobalThreshold === false ) formData.globalLightThreshold = null;

    // Perform the update
    return this.entity.update(formData, {fromSheet: true});
  }
}

/**
 * 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(options) {
    const entityName = this.object.entity;
    const config = CONFIG[entityName];
    const type = this.object.data.type || CONST.BASE_ENTITY_TYPE;
    let defaultClass = null;

    // Classes which can be chosen
    const classes = Object.values(config.sheetClasses[type]).reduce((obj, c) => {
      obj[c.id] = c.label;
      if ( c.default && !defaultClass ) defaultClass = c.id;
      return obj;
    }, {});

    // Return data
    return {
      entityName: entityName,
      isGM: game.user.isGM,
      object: duplicate(this.object.data),
      options: this.options,
      sheetClass: this.object.getFlag("core", "sheetClass") ?? "",
      sheetClasses: classes,
      defaultClass: defaultClass,
      blankLabel: game.i18n.localize("SHEETS.DefaultSheet")
    }
  }

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

  /**
   * 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
   * @param {string} [options.label]          A human readable label for the sheet name, which will be localized
   * @param {string[]} [options.types]        An array of entity types for which this sheet should be used
   * @param {boolean} [options.makeDefault]   Whether to make this sheet the default for provided types
   */
  static registerSheet(entityClass, scope, sheetClass, {label, types=[], makeDefault=false}={}) {
    const id = `${scope}.${sheetClass.name}`;
    const config = {entityClass, id, label, 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, label, 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,
        label: label ? game.i18n.localize(label) : id
      };
    }
  }

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

  /**
   * 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 {object[]} 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 = [];
/**
 * 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
   * @returns {Promise<void>}   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
    const duration = this._getDuration(html);
    const scroll = dimensions.unconstrained - dimensions.height;

    // Animate the bubble
    html.fadeIn(250, () => {
      if ( scroll > 0 ) {
        html.find(".bubble-content").animate({ top: -1 * scroll }, duration - 1000, 'linear');
      }
      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<string>}    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})`;
  }
}

/**
 * @typedef {{name: string, label: string, icon: string}} SceneControlTool
 * @typedef {{name: string, title: string, layer: string, icon: string, tools: SceneControlTool[]}} SceneControl
 */

/**
 * 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 {SceneControl[]}
     */
	  this.controls = this._getControlButtons();
	}

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

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    width: 100,
      id: "controls",
      template: "templates/hud/controls.html",
      popOut: false
    });
  }

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

  /**
   * Return the active control set
   * @type {SceneControl|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 controlled tool
   * @type {SceneControlTool|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
   * @type {boolean}
   */
  get isRuler() {
	  return this.activeTool === "ruler";
  }

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

  /**
   * Initialize the Scene Controls by obtaining the set of control buttons and rendering the HTML
   * @param {object} options      Options which modify how the controls UI is initialized
   * @param {string} [options.control]      An optional control set to set as active
   * @param {string} [options.layer]        An optional layer name to target as the active control
   * @param {string} [options.tool]         A specific named tool to set as active for the palette
   */
  initialize({control, layer, tool}={}) {
    const tools = this.controls.reduce((obj, t) => {
      obj[t.name] = t.activeTool;
      return obj;
    }, {});

    // Set the new control
    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;
    }

    // Update the control buttons
    this.controls = this._getControlButtons();
    if ( tool ) tools[this.activeControl] = tool;
    for ( let c of this.controls ) {
      c.activeTool = tools[c.name];
    }
    this.render(true);
  }

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

  /** @override */
	getData(options) {
	  const isActive = !!canvas?.scene;

	  // 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 = isActive && (this.activeControl === s.name) ? "active" : "";

      // Prepare contained tools
      s.tools = s.tools.filter(t => t.visible !== false).map(t => {
        let active = isActive && ((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;
    });

    // Return data for rendering
	  return {
	    active: isActive,
      cssClass: isActive ? "" : "disabled",
      controls: controls.filter(s => s.tools.length)
    };
  }


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

  /** @override */
  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();
    if ( !canvas?.ready ) return;
    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();
    if ( !canvas?.ready ) return;
    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();
  }

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

  /**
   * 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 {SceneControl[]}
   * @private
   */
	_getControlButtons() {
    const controls = [];
    const isGM = game.user.isGM;

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

/**
 * 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 {Macro[]}
     */
    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 {Macro[]}
   * @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);
      });
    });
  }

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

  /**
   * Change to a specific numbered page from 1 to 5
   * @param {number} page     The page number to change to.
   */
  changePage(page) {
    this.page = Math.clamped(page ?? 1, 1, 5);
    this.render();
  }

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

  /**
   * Change the page of the hotbar by cycling up (positive) or down (negative)
   * @param {number} direction    The direction to cycle
   */
  cyclePage(direction) {
    direction = Number.isNumeric(direction) ? Math.sign(direction) : 1;
    if ( direction > 0 ) {
      this.page = this.page < 5 ? this.page+1 : 1;
    } else {
      this.page = this.page > 1 ? this.page-1 : 5;
    }
    this.render();
  }

	/* -------------------------------------------- */
  /*  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: "MACRO.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: "MACRO.Remove",
        icon: '<i class="fas fa-times"></i>',
        callback: li => {
          game.user.assignHotbarMacro(null, li.data("slot"));
        }
      },
      {
        name: "MACRO.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"));
          return 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) {
    this.cyclePage(event.currentTarget.dataset.action === "page-up" ? 1 : -1);
  }

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

  /** @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}
 * @abstract
 * @interface
 */
class BasePlaceableHUD extends Application {
  constructor(...args) {
    super(...args);

    /**
     * Reference a PlaceableObject this HUD is currently bound to
     * @type {PlaceableObject}
     */
    this.object = null;
  }

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

  /** @override */
	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 ( [states.CLOSING, states.RENDERING].includes(this._state) ) 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
  }

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

  /** @override */
	async _render(...args) {
	  await super._render(...args);
	  this.setPosition();
  }

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

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

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

  /** @override */
	setPosition({left, top, width, height, scale}={}) {
	  const position = {
	    width: width || this.object.width,
      height: height || this.object.height,
      left: left ?? this.object.x,
      top: top ?? this.object.y
    };
    this.element.css(position);
  }

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

  /** @override */
  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 {Scene[]}
   */
  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 = TextEditor.truncateText(data.navName || data.name, {maxLength: 32});
	    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;
        Hooks.callAll("collapseSceneNavigation", this, this._collapsed);
        return resolve(true);
      });
    });
  }

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

  /**
   * Collapse the SceneNavigation menu, sliding it up if it is currently expanded
   * @return {Promise<boolean>}
   */
  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;
        Hooks.callAll("collapseSceneNavigation", this, this._collapsed);
        return resolve(true);
      });
    });
  }

	/* -------------------------------------------- */
  /*  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 {object[]}   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 >= AVSettings.AV_MODES.VIDEO ) {
      hide = game.webrtc.settings.client.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 {object[]}
   * @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();
  }
}

/**
 * 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/sidebar/apps/av-config.html",
      popOut: true,
      width: 480,
      height: "auto",
      tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "general"}]
    });
  }

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

  /** @override */
  async getData(options) {
    const settings = this.object.settings;
    const videoSources = await this.object.client.getVideoSources();
    const audioSources = await this.object.client.getAudioSources();
    const audioSinks = await this.object.client.getAudioSinks();

    // If the currently chosen device is unavailable, display a separate option for 'unavailable device (use default)'
    const { videoSrc, audioSrc, audioSink } = settings.client;
    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:";

    // Audio/Video modes
    const modes = {
      [AVSettings.AV_MODES.DISABLED]: "WEBRTC.ModeDisabled",
      [AVSettings.AV_MODES.AUDIO]: "WEBRTC.ModeAudioOnly",
      [AVSettings.AV_MODES.VIDEO]: "WEBRTC.ModeVideoOnly",
      [AVSettings.AV_MODES.AUDIO_VIDEO]: "WEBRTC.ModeAudioVideo"
    };

    // Voice Broadcast modes
    const voiceModes = Object.values(AVSettings.VOICE_MODES).reduce((obj, m) => {
      obj[m] = game.i18n.localize("WEBRTC.VoiceMode"+m.titleCase());
      return obj;
    }, {});

    // Return data to the template
    return {
      user: game.user,
      modes,
      voiceModes,
      serverTypes: {FVTT: "WEBRTC.FVTTSignalingServer", custom: "WEBRTC.CustomSignalingServer"},
      turnTypes: {server: "WEBRTC.TURNServerProvisioned", custom: "WEBRTC.CustomTURNServer"},
      settings,
      canSelectMode: game.user.isGM && isSSL,
      noSSL: !isSSL,
      videoSources,
      audioSources,
      audioSinks: isObjectEmpty(audioSinks) ? false : audioSinks,
      videoSrcUnavailable,
      audioSrcUnavailable,
      audioSinkUnavailable
    };
  }

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

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);

    // Push-to-talk key assignment
    const ptt = html.find('input[name="client.voice.pttName"]');
    ptt.keydown(this._onPTTKeyDown.bind(this)).mousedown(this._onPTTMouseDown.bind(this));

    // Options below are GM only
    if ( !game.user.isGM ) return;
    html.find('select[name="world.server.type"]').change(this._onServerTypeChanged.bind(this));
    html.find('select[name="world.turn.type"]').change(this._onTurnTypeChanged.bind(this));

    // Activate or de-activate the custom server and turn configuration sections based on current settings
    const settings = this.object.settings;
    this._setConfigSectionEnabled(".webrtc-custom-server-config", settings.world.server.type === "custom");
    this._setConfigSectionEnabled(".webrtc-custom-turn-config", settings.world.turn.type === "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["client.voice.pttKey"].value = event.originalEvent.keyCode;
    form["client.voice.pttMouse"].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["client.voice.pttKey"].value = button;
    form["client.voice.pttMouse"].value = true;
  }

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

  /** @override */
  async _updateObject(event, formData) {
    const settings = game.webrtc.settings;
    settings.client.videoSrc = settings.client.videoSrc || null;
    settings.client.audioSrc = settings.client.audioSrc || null;
    const update = expandObject(formData);

    // Update world settings
    if ( game.user.isGM ) {
      const world = mergeObject(settings.world, update.world);
      game.settings.set("core", "rtcWorldSettings", world);
    }

    // Update client settings
    const client = mergeObject(settings.client, update.client);
    game.settings.set("core", "rtcClientSettings", client);
  }
}
/**
 * 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 = game.webrtc.settings.getUser(userId);
    this.setPosition({ left: setting.x, top: setting.y, width: setting.width });
    new Draggable(this, element.find(".camera-view"), element.find(".video-container")[0], true);
  }

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

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

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

  /** @override */
  setPosition({ left, top, width, height, scale } = {}) {
    const updates = {};

    // Constrain aspect ratio + 30px for nameplate
    if (width || height) {
      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);
      updates.width = width;
    }

    // Position
    this.element.css({ left, top });
    if (left) updates.x = left;
    if ( top ) updates.y = top;

    // Save settings
    if ( !isObjectEmpty(updates) ) {
      const current = game.webrtc.settings.client.users[this.userId] || {};
      const update = mergeObject(current, updates);
      game.webrtc.settings.set("client", `users.${this.userId}`, update);
    }
  }

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

  _onResize(event) {}

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

  /** @override */
  bringToTop() {
    let parent = this.element.parent();
    let children = parent.children();
    let lastElement = children[children.length - 1];
    if (lastElement !== this.element[0]) {
      game.webrtc.settings.set("client", `users.${this.userId}.z`, ++this.view.maxZ);
      parent.append(this.element);
    }
  }
}

/**
 * The Camera UI View that displays all the camera feeds as individual video elements.
 * @type {Application}
 *
 * @param {WebRTC} webrtc     The WebRTC Implementation to display
 */
class CameraViews extends Application {
  constructor(webrtc, options) {
    super(options);
    game.users.apps.push(this);
  }

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

  /** @override */
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "camera-views",
      template: "templates/hud/camera-views.html",
      popOut: false
    });
  }

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

  /**
   * A reference to the master AV orchestrator instance
   * @type {AVMaster}
   */
  get webrtc() {
    return game.webrtc;
  }

  /* -------------------------------------------- */
  /* Public API                                   */
  /* -------------------------------------------- */

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

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

  /**
   * 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) {
    const view = this.getUserCameraView(userId);
    if ( view ) view.classList.toggle("speaking", speaking);
  }

  /* -------------------------------------------- */
  /*  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.mode === AVSettings.AV_MODES.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);
  }

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

  /** @override */
  async _render(force = false, options = {}) {
    await super._render(force, options);
    this._setPlayerListVisibility();
    this.webrtc.onRender();
  }

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

  /** @override */
  getData(options) {
    const settings = this.webrtc.settings;
    const userSettings = settings.users;

    // Get the sorted array of connected users
    const connectedIds = this.webrtc.client.getConnectedUsers();
    const users = connectedIds.reduce((users, u) => {
      const data = this._getDataForUser(u, userSettings[u]);
      if ( data ) users.push(data);
      return users;
    }, []);
    users.sort(this.constructor._sortUsers);

    // Maximum Z of all user popout windows
    this.maxZ = Math.max(...users.map(u => userSettings[u.id].z));

    // Define a dynamic class for the camera dock container which affects it's rendered style
    let dockClass = `camera-size-${settings.client.dockSize} camera-position-${settings.client.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.client.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,
      muteAll: settings.muteAll
    };
  }

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

  /**
   * Prepare rendering data for a single user
   * @private
   */
  _getDataForUser(userId, settings) {
    const user = game.users.get(userId);
    if ( !user || !user.active ) return null;
    const charname = user.character ? user.character.name.split(" ")[0] : "";

    // CSS classes for the frame
    const frameClass = settings.popout ? "camera-box-popout" : "camera-box-dock";
    const audioClass = this.webrtc.canUserBroadcastAudio(userId) ? null : "no-audio";
    const videoClass = this.webrtc.canUserBroadcastVideo(userId) ? null : "no-video";

    // 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,
      settings: settings,
      volume: AudioHelper.volumeToInput(settings.volume),
      cameraViewClass: [frameClass, videoClass, audioClass].filterJoin(" ")
    };
  }

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

  /**
   * A custom sorting function that orders/arranges the user display frames
   * @return {number}
   * @private
   */
  static _sortUsers(a, b) {
    const as = a.settings;
    const bs = b.settings;
    if (as.popout && bs.popout) return as.z - bs.z; // Sort popouts by z-index
    if (as.popout) return -1;                       // Show popout feeds first
    if (bs.popout) return 1;
    if (a.user.isSelf) return -1;                   // Show local feed first
    if (b.user.isSelf) return 1;
    if (a.hasVideo && !b.hasVideo) return -1;       // Show remote users with a camera before those without
    if (b.hasVideo && !a.hasVideo) return 1;
    return a.user.data.sort - b.user.data.sort;     // Sort according to user order
  }

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

  /** @override */
  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
    const mode = this.webrtc.mode;
    if (mode === AVSettings.AV_MODES.VIDEO) html.find('[data-action="toggle-audio"]').hide();
    if (mode === AVSettings.AV_MODES.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");
      this._refreshView(view);
      video.addEventListener('webrtcVideoSet', ev => {
        const view = video.closest(".camera-view");
        if ( view.dataset.user !== ev.detail ) return;
        this._refreshView(view);
      });
    }
  }

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

  /**
   * 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 {MouseEvent} event   The originating click event
   * @private
   */
  async _onClickControl(event) {
    event.preventDefault();

    // Reference relevant data
    const button = event.currentTarget;
    const action = button.dataset.action;
    const view = button.closest(".camera-view");
    const user = game.users.get(view.dataset.user);
    const settings = this.webrtc.settings;
    const userSettings = settings.getUser(user.id);

    // Handle different actions
    switch ( action ) {

      // Globally block video
      case "block-video": {
        if (!game.user.isGM) break;
        await user.update({"permissions.BROADCAST_VIDEO": !userSettings.canBroadcastVideo});
        this._refreshView(view);
        break;
      }

      // Globally block audio
      case "block-audio": {
        if (!game.user.isGM) break;
        await user.update({"permissions.BROADCAST_AUDIO": !userSettings.canBroadcastAudio});
        this._refreshView(view);
        break;
      }

      // Toggle video display
      case "toggle-video": {
        if ( !user.isSelf ) break;
        if ( userSettings.hidden && !userSettings.canBroadcastVideo ) {
          return ui.notifications.warn(game.i18n.localize("WEBRTC.WarningCannotEnableVideo"))
        }
        await settings.set("client", `users.${user.id}.hidden`, !userSettings.hidden);
        this._refreshView(view);
        break;
      }

      // Toggle audio output
      case "toggle-audio":
        if ( !user.isSelf ) break;
        if ( userSettings.muted && !userSettings.canBroadcastAudio ) {
          return ui.notifications.warn(game.i18n.localize("WEBRTC.WarningCannotEnableAudio"))
        }
        await settings.set("client", `users.${user.id}.muted`, !userSettings.muted);
        this._refreshView(view);
        break;

      // Toggle mute all peers
      case "mute-peers":
        if ( !user.isSelf ) break;
        await settings.set("client", "muteAll", !settings.client.muteAll);
        this._refreshView(view);
        break;

      // Configure settings
      case "configure":
        return this.webrtc.config.render(true);

      // Toggle popout
      case "toggle-popout":
        await settings.set("client", `users.${user.id}.popout`, !userSettings.popout);
        return this.render();

      // Hide players
      case "toggle-players":
        await settings.set("client", "hidePlayerList", !settings.client.hidePlayerList);
        return this.render();

      // Cycle camera size
      case "change-size":
        const sizes = ["large", "medium", "small"];
        const size = sizes.indexOf(settings.client.dockSize);
        const next = size+1 >= sizes.length ? 0 : size+1;
        await settings.set("client", "dockSize", sizes[next]);
        return this.render();
    }
  }

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

  /**
   * 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;
    let volume = AudioHelper.inputToVolume(input.value);
    box.getElementsByTagName("video")[0].volume = volume;
    this.webrtc.settings.set("client", `users.${userId}.volume`, volume);
  }

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

  /**
   * Dynamically refresh the state of a single camera view
   * @param {HTMLElement} view      The view container div
   * @private
   */
  _refreshView(view) {
    const userId = view.dataset.user;
    const isSelf = game.user._id === userId;
    const clientSettings = game.webrtc.settings.client;
    const userSettings = game.webrtc.settings.getUser(userId);

    // Identify permissions
    const cbv = game.webrtc.canUserBroadcastVideo(userId);
    const csv = game.webrtc.canUserShareVideo(userId);
    const cba = game.webrtc.canUserBroadcastAudio(userId);
    const csa = game.webrtc.canUserShareAudio(userId);

    // Refresh video display
    const video = view.querySelector("video.user-camera");
    const avatar = view.querySelector("img.user-avatar");
    if (video && avatar) {
      video.style.visibility = csv ? 'visible' : "hidden";
      video.style.display = csv ? "block" : "none";
      avatar.style.display = csv ? "none" : "unset";
    }

    // Hidden and muted status icons
    view.querySelector(".status-hidden").classList.toggle("hidden", csv);
    view.querySelector(".status-muted").classList.toggle("hidden", csa);

    // Volume bar and video output volume
    video.volume = userSettings.volume;
    video.muted = isSelf || clientSettings.muteAll; // Mute your own video
    const volBar = view.querySelector(".volume-bar");
    const displayBar = (userId !== game.user._id) && cba;
    volBar.style.display = displayBar ? "block" : "none";
    volBar.disabled = !displayBar;

    // Control toggle states
    const actions = {
      "block-video": {state: !cbv, display: game.user.isGM && !isSelf},
      "block-audio": {state: !cba, display: game.user.isGM && !isSelf},
      "toggle-video": {state: !csv, display: isSelf},
      "toggle-audio": {state: !csa, display: isSelf},
      "mute-peers": {state: clientSettings.muteAll, display: isSelf},
      "toggle-players": {state: !clientSettings.hidePlayerList, display: isSelf}
    };
    const toggles = view.querySelectorAll(".av-control.toggle");
    for ( let button of toggles ) {
      const action = button.dataset.action;
      if (!(action in actions) ) continue;
      const state = actions[action].state;
      const displayed = actions[action].display;
      button.style.display = displayed ? "block" : "none";
      button.enabled = displayed;
      button.children[0].classList.remove(this._getToggleIcon(action, !state));
      button.children[0].classList.add(this._getToggleIcon(action, state));
      button.setAttribute("title", this._getToggleTooltip(action, state));
    }
  }

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

  /**
   * Render changes needed to the PlayerList ui.
   * Show/Hide players depending on option.
   * @private
   */
  _setPlayerListVisibility() {
    let players = document.getElementById("players");
    if (! players ) return;
    players.classList.toggle("hidden", this.webrtc.settings.client.hidePlayerList);
  }

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

  /**
   * Get the icon class that should be used for various action buttons with different toggled states.
   * The returned icon should represent the visual status of the NEXT state (not the CURRENT state).
   *
   * @param {string} action     The named av-control button action
   * @param {boolean} state     The CURRENT action state.
   * @return {string}           The icon that represents the NEXT action state.
   * @private
   */
  _getToggleIcon(action, state) {
    const actionMapping = {
      "block-video": ["fa-video", "fa-video-slash"],            // True means "blocked"
      "block-audio": ["fa-microphone", "fa-microphone-slash"],  // True means "blocked"
      "toggle-video": ["fa-video", "fa-video-slash"],           // True means "enabled"
      "toggle-audio": ["fa-microphone", "fa-microphone-slash"], // True means "enabled"
      "mute-peers": ["fa-volume-up", "fa-volume-mute"],         // True means "muted"
      "toggle-players": ["fa-caret-square-right", "fa-caret-square-left"] // True means "displayed"
    };
    const icons = actionMapping[action];
    return icons ? icons[state ? 1: 0] : null;
  }

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

  /**
   * Get the text title that should be used for various action buttons with different toggled states.
   * The returned title should represent the tooltip of the NEXT state (not the CURRENT state).
   *
   * @param {string} action     The named av-control button action
   * @param {boolean} state     The CURRENT action state.
   * @return {string}           The icon that represents the NEXT action state.
   * @private
   */
  _getToggleTooltip(action, state) {
    const actionMapping = {
      "block-video": ["BlockUserVideo", "AllowUserVideo"],      // True means "blocked"
      "block-audio": ["BlockUserAudio", "AllowUserAudio"],      // True means "blocked"
      "toggle-video": ["DisableMyVideo", "EnableMyVideo"],      // True means "enabled"
      "toggle-audio": ["DisableMyAudio", "EnableMyAudio"],      // True means "enabled"
      "mute-peers": ["MutePeers", "UnmutePeers"],               // True means "muted"
      "toggle-players": ["ShowPlayers", "HidePlayers"]          // True means "displayed"
    };
    const labels = actionMapping[action];
    return game.i18n.localize(`WEBRTC.Tooltip${labels ? labels[state ? 1 : 0] : ""}`);
  }

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

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

/**
 * Configuration sheet for the Drawing object
 * @type {FormApplication}
 *
 * @param {Drawing} drawing         The Drawing object being configured
 * @param {object} options          Additional application rendering options
 * @param {boolean} [options.preview]  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() {
    const title = this.options.configureDefault ? "DRAWING.ConfigDefaultTitle" : "DRAWING.ConfigTitle";
    return game.i18n.localize(title);
  }

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

  /** @override */
  getData(options) {
    const author = game.users.get(this.object.data.author);

    // Submit text
    let submit;
    if ( this.options.configureDefault ) submit = "DRAWING.SubmitDefault";
    else submit = this.options.preview ? "DRAWING.SubmitCreate" : "DRAWING.SubmitUpdate";

    // Return data
    return {
      author: author ? author.name : "",
      isDefault: this.options.configureDefault,
      fillTypes: this.constructor._getFillTypes(),
      fontFamilies: CONFIG.fontFamilies.reduce((obj, f) => {
        obj[f] = f;
        return obj;
      }, {}),
      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]] = `DRAWING.FillType${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
 * @implements {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 {
  constructor(...args) {
    super(...args);
    if (!game.user.isGM) throw "You do not have the ability to configure an AmbientLight object.";
  }

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

  /** @override */
	static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      classes: ["sheet", "light-sheet"],
      title: "LIGHT.ConfigTitle",
      template: "templates/scene/light-config.html",
      width: 480
    });
  }

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

  /** @override */
  getData(options) {
    const animationTypes = {"": "None"};
    for ( let [k, v] of Object.entries(CONFIG.Canvas.lightAnimations) ) {
      animationTypes[k] = v.label;
    }
    const lightTypes = Object.entries(CONST.SOURCE_TYPES).reduce((obj, e) => {
      obj[e[1]] = `LIGHT.Type${e[0].titleCase()}`;
      return obj;
    }, {});
    return {
      object: duplicate(this.object.data),
      options: this.options,
      submitText: game.i18n.localize(this.options.preview ? "LIGHT.Create" : "LIGHT.Update"),
      lightTypes: lightTypes,
      lightAnimations: animationTypes
    }
  }

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

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("input, select").change(this._onPreviewChange.bind(this));
  }

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

  /** @override */
  close(options) {
    this._resetObject(true);
    return super.close(options);
  }

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

  /**
   * Preview the change caused by a change on the form by refreshing the display of the light source
   * @private
   */
  _onPreviewChange(event) {
    const input = event.currentTarget;
    const fd = new FormDataExtended(this.form);
    if ( !this._originalData ) this._originalData = duplicate(this.object.data);
    this.object.data = mergeObject(this.object.data, fd.toObject(), {inplace: false});
    if ( input.dataset.edit ) this.object.data[input.dataset.edit] = input.value;
    this.object.updateSource();
    this.object.refresh();
  }

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

  /** @override */
  async _updateObject(event, formData) {
    this._resetObject(false);
    if ( this.object.id ) return this.object.update(formData);
    return this.object.constructor.create(formData);
  }

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

  /**
   * Reset the state of the previewed light source object to its original data
   * @private
   */
  _resetObject(refresh=true) {
    if ( this._originalData ) {
      this.object.data = this._originalData;
      if ( refresh ) this.object.refresh();
    }
    this._originalData = null;
  }
}

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

  /** @override */
	static get defaultOptions() {
	  return mergeObject(super.defaultOptions, {
	    id: "note-config",
      title: game.i18n.localize("NOTE.ConfigTitle"),
      template: "templates/scene/note-config.html",
      width: 400
    });
  }

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

  /** @override */
  getData(options) {
    const entry = game.journal.get(this.object.data.entryId) || {};
    return {
      entryIcons: CONFIG.JournalEntry.noteIcons,
      entryId: entry._id,
      entryName: entry.name,
      entries: game.journal.entities,
      fontFamilies: CONFIG.fontFamilies.reduce((obj, f) => {
        obj[f] = f;
        return obj;
      }, {}),
      object: duplicate(this.object.data),
      options: this.options,
      textAnchors: Object.entries(CONST.TEXT_ANCHOR_POINTS).reduce((obj, e) => {
        obj[e[1]] = e[0].titleCase();
        return obj;
      }, {})
    }
  }

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

  /** @override */
  async _updateObject(event, formData) {
    if ( this.object.id ) return this.object.update(formData);
    else {
      canvas.notes.preview.removeChildren();
      return this.object.constructor.create(formData);
    }
  }

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

  /** @override */
  async close(options) {
    if ( !this.object.id ) canvas.notes.preview.removeChildren();
    return super.close(options);
  }
}

/**
 * Ambient Sound Config Sheet
 * @extends {FormApplication}
 *
 * @param {AmbientSound} sound       The sound object being configured
 * @param {object} options           Additional application rendering options
 * @param {boolean} options.preview  Configure a preview version of a sound which is not yet saved
 */
class AmbientSoundConfig extends FormApplication {
  constructor(...args) {
    super(...args);
    if (!game.user.isGM) throw "You do not have the ability to configure an AmbientSound object.";
  }

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

  /** @override */
	static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "sound-config",
      classes: ["sheet", "sound-sheet"],
      title: "SOUND.ConfigTitle",
      template: "templates/scene/sound-config.html",
      width: 480
    });
  }

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

  /** @override */
  getData(options) {
    return {
      object: duplicate(this.object.data),
      options: this.options,
      submitText: game.i18n.localize(this.options.preview ? "SOUND.Create" : "SOUND.Update"),
    }
  }

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

  /** @override */
  async _updateObject(event, formData) {
    if ( 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;
    }
  }
}

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

/**
 * 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 = new FormDataExtended(event.currentTarget.form);
    for ( let [k, v] of Object.values(fd.toObject()) ) {
      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 {object} options   TokenConfig ui options (see Application)
 * @param {boolean} [options.configureDefault]   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")}`;
  }

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

  /** @override */
  async getData(options) {
    const actor = this.token.actor;
    let hasAlternates = actor && 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: Object.entries(CONST.TOKEN_DISPLAY_MODES).reduce((obj, e) => {
        obj[e[1]] = game.i18n.localize(`TOKEN.DISPLAY_${e[0]}`);
        return obj;
      }, {}),
      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.entries(CONST.TOKEN_DISPOSITIONS).reduce((obj, e) => {
        obj[e[1]] = game.i18n.localize(`TOKEN.${e[0]}`);
        return obj;
      }, {}),
      lightAnimations: Object.entries(CONFIG.Canvas.lightAnimations).reduce((obj, e) => {
        obj[e[0]] = game.i18n.localize(e[1].label);
        return obj;
      }, {"": game.i18n.localize("None")}),
      isGM: game.user.isGM
    };
  }

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

  /** @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 {string[]}
   */
  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 "value" and "max" field
   * @param {Object} data     The data object to search
   * @param {string[]} _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 = ("value" in v) && ("max" in v);
        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 values which are numeric or null
      else if ( Number.isNumeric(v) || (v === null) ) {
        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
   * @param {MouseEvent} event  The left-click event on the assign token button
   * @private
   */
  async _onAssignToken(event) {
    event.preventDefault();

    // Get controlled Token data
    let tokens = canvas.ready ? canvas.tokens.controlled : [];
    if ( tokens.length !== 1 ) {
      ui.notifications.warn(game.i18n.localize("TOKEN.AssignWarn"));
      return;
    }
    const token = duplicate(tokens.pop().data);
    token.tokenId = token.x = token.y = null;

    // Update the prototype token for the actor using the existing Token instance
    const actor = this.actor;
    await actor.update({token: token}, {diff: false, recursive: false, noHook: true});
    ui.notifications.info(game.i18n.format("TOKEN.AssignSuccess", {name: actor.name}));
    return 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 {

  /** @override */
  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: "token-hud",
      template: "templates/hud/token-hud.html"
    });
  }

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

  /** @override */
  bind(object) {
    this._statusEffects = false;
    return super.bind(object);
  }

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

  /**
   * Refresh the currently active state of all status effect icons in the Token HUD selector.
   */
  refreshStatusIcons() {
    const effects = this.element.find(".status-effects")[0];
    const statuses = this._getStatusEffectChoices();
    for ( let img of effects.children ) {
      const status = statuses[img.getAttribute("src")] || {};
      img.classList.toggle("overlay", !!status.isOverlay);
      img.classList.toggle("active", !!status.isActive);
    }
  }

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

  /** @override */
  setPosition(_position) {
    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(options) {
    const data = super.getData(options);
    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: this._getStatusEffectChoices(data)
    });
  }

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

  /**
   * Get an array of icon paths which represent valid status effect choices
   * @private
   */
  _getStatusEffectChoices() {
    const token = this.object;

    // Get statuses which are active for the token actor
    const actor = token.actor || null;
    const statuses = actor ? actor.effects.reduce((obj, e) => {
      const id = e.getFlag("core", "statusId");
      if ( id ) {
        obj[id] = {
          id: id,
          overlay: !!e.getFlag("core", "overlay")
        }
      }
      return obj;
    }, {}) : {};

    // Prepare the list of effects from the configured defaults and any additional effects present on the Token
    const tokenEffects = duplicate(token.data.effects) || [];
    if ( token.data.overlayEffect ) tokenEffects.push(token.data.overlayEffect);
    return CONFIG.statusEffects.concat(tokenEffects).reduce((obj, e) => {
      const src = e.icon ?? e;
      if ( src in obj ) return obj;
      const status = statuses[e.id] || {};
      const isActive = !!status.id || token.data.effects.includes(src);
      const isOverlay = !!status.overlay || token.data.overlayEffect === src;
      obj[src] = {
        id: e.id ?? "",
        title: e.label ? game.i18n.localize(e.label) : null,
        src,
        isActive,
        isOverlay,
        cssClass: [
          isActive ? "active" : null,
          isOverlay ? "overlay" : null
        ].filterJoin(" ")
      };
      return obj;
    }, {});
  }

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

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);

    // Attribute Bars
    html.find(".attribute input").click(this._onAttributeClick).change(this._onAttributeUpdate.bind(this));

    // 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(".target").click(this._onToggleTarget.bind(this));

    // Status Effects Controls
    html.find(".status-effects")
      .on("click", ".effect-control", this._onToggleEffect.bind(this))
      .on("contextmenu", ".effect-control", event => this._onToggleEffect(event, {overlay: true}));
  }

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

  /**
   * Handle initial click to focus an attribute update field
   * @private
   */
  _onAttributeClick(event) {
    event.currentTarget.select();
  }

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

  /**
   * Handle attribute bar update
   * @private
   */
  _onAttributeUpdate(event) {
    event.preventDefault();
    if ( !this.object ) return;

    // 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
    const bar = input.dataset.bar;
    const actor = this.object?.actor;
    if ( bar && actor ) {
      const attr = this.object.getBarAttribute(bar);
      actor.modifyTokenAttribute(attr.attribute, value, isDelta, attr.type === "bar");
    }

    // Otherwise update the Token directly
    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, {overlay=false}={}) {
    event.preventDefault();
    let img = event.currentTarget;
    const effect = ( img.dataset.statusId && this.object.actor ) ?
      CONFIG.statusEffects.find(e => e.id === img.dataset.statusId) :
      img.getAttribute("src");
    return this.object.toggleEffect(effect, {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.`);
    }
  }
}

/**
 * A special class of Dialog which allows for the installation of Packages.
 * @extends {Application}
 */
class InstallPackage extends Application {
  constructor(data, options) {
    super(options);
    this.data = data;

    /**
     * The instance of the setup form to which this is linked
     * @type {SetupConfigurationForm}
     */
    this.setup = data.setup;

    /**
     * The category being filtered for
     * @type {string}
     */
    this._category = "all";

    /**
     * The visibility being filtered for
     * @type {string}
     */
    this._visibility = "all";
  }

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